Complete re-validation remediation: KMP architecture, iOS platform, XCUITest rewrite
Phases 1-6 of fixes.md — closes all 13 issues from codex_issues_2.md re-validation: KMP Architecture: - Fix subscription purchase/restore response contract (VerificationResponse aligned) - Add feature benefits auth token + APILayer init flow - Remove ResidenceFormScreen direct API bypass (use APILayer) - Wire paywall purchase/restore to real SubscriptionApi calls iOS Platform: - Add iOS Keychain token storage via Swift KeychainHelper - Implement Google Sign-In via ASWebAuthenticationSession (GoogleSignInManager) - DocumentViewModelWrapper observes DataManager for auto-updates - Add missing accessibility identifiers (document, task columns, Google Sign-In) XCUITest Rewrite: - Rewrite test infrastructure: zero sleep() calls, accessibility ID lookups - Create AuthCriticalPathTests and NavigationCriticalPathTests - Delete 14 legacy brittle test files (Suite0-10, templates) - Fix CaseraTests module import (@testable import Casera) All platforms build clean. TEST BUILD SUCCEEDED. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
package com.example.casera.platform
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.example.casera.ui.subscription.UpgradeScreen
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
actual fun PlatformUpgradeScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onSubscriptionChanged: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as? Activity
|
||||
val billingManager = remember { BillingManager.getInstance(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Load products on launch
|
||||
LaunchedEffect(Unit) {
|
||||
billingManager.startConnection(
|
||||
onSuccess = {
|
||||
scope.launch { billingManager.loadProducts() }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Watch for successful purchase
|
||||
val purchasedProductIDs by billingManager.purchasedProductIDs.collectAsState()
|
||||
var initialPurchaseCount by remember { mutableStateOf(purchasedProductIDs.size) }
|
||||
|
||||
LaunchedEffect(purchasedProductIDs) {
|
||||
if (purchasedProductIDs.size > initialPurchaseCount) {
|
||||
onSubscriptionChanged()
|
||||
onNavigateBack()
|
||||
}
|
||||
}
|
||||
|
||||
UpgradeScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onPurchase = { planId ->
|
||||
val product = billingManager.getProduct(planId)
|
||||
if (product != null && activity != null) {
|
||||
billingManager.launchPurchaseFlow(activity, product)
|
||||
}
|
||||
},
|
||||
onRestorePurchases = {
|
||||
scope.launch {
|
||||
val restored = billingManager.restorePurchases()
|
||||
if (restored) {
|
||||
onSubscriptionChanged()
|
||||
onNavigateBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -44,7 +44,6 @@ import com.example.casera.ui.screens.MainScreen
|
||||
import com.example.casera.ui.screens.ManageUsersScreen
|
||||
import com.example.casera.ui.screens.NotificationPreferencesScreen
|
||||
import com.example.casera.ui.screens.ProfileScreen
|
||||
import com.example.casera.ui.subscription.UpgradeScreen
|
||||
import com.example.casera.ui.theme.MyCribTheme
|
||||
import com.example.casera.ui.theme.ThemeManager
|
||||
import com.example.casera.navigation.*
|
||||
@@ -59,6 +58,7 @@ import com.example.casera.network.AuthApi
|
||||
import com.example.casera.data.DataManager
|
||||
import com.example.casera.network.APILayer
|
||||
import com.example.casera.platform.ContractorImportHandler
|
||||
import com.example.casera.platform.PlatformUpgradeScreen
|
||||
import com.example.casera.platform.ResidenceImportHandler
|
||||
|
||||
import casera.composeapp.generated.resources.Res
|
||||
@@ -613,16 +613,12 @@ fun App(
|
||||
}
|
||||
|
||||
composable<UpgradeRoute> {
|
||||
UpgradeScreen(
|
||||
PlatformUpgradeScreen(
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
onPurchase = { planId ->
|
||||
// Handle purchase - integrate with billing system
|
||||
navController.popBackStack()
|
||||
},
|
||||
onRestorePurchases = {
|
||||
// Handle restore - integrate with billing system
|
||||
onSubscriptionChanged = {
|
||||
// Subscription state updated via DataManager
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -63,9 +63,33 @@ data class PurchaseVerificationRequest(
|
||||
@SerialName("product_id") val productId: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Nested subscription info returned by backend purchase/restore endpoints.
|
||||
*/
|
||||
@Serializable
|
||||
data class VerificationSubscriptionInfo(
|
||||
val tier: String = "",
|
||||
@SerialName("subscribed_at") val subscribedAt: String? = null,
|
||||
@SerialName("expires_at") val expiresAt: String? = null,
|
||||
@SerialName("auto_renew") val autoRenew: Boolean = true,
|
||||
@SerialName("cancelled_at") val cancelledAt: String? = null,
|
||||
val platform: String = "",
|
||||
@SerialName("is_active") val isActive: Boolean = false,
|
||||
@SerialName("is_pro") val isPro: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* Response from backend purchase/restore endpoints.
|
||||
* Backend returns: { "message": "...", "subscription": { "tier": "pro", ... } }
|
||||
*/
|
||||
@Serializable
|
||||
data class VerificationResponse(
|
||||
val success: Boolean,
|
||||
val tier: String? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
val message: String = "",
|
||||
val subscription: VerificationSubscriptionInfo? = null
|
||||
) {
|
||||
/** Backward-compatible: success when subscription is present */
|
||||
val success: Boolean get() = subscription != null
|
||||
|
||||
/** Backward-compatible: tier extracted from nested subscription */
|
||||
val tier: String? get() = subscription?.tier
|
||||
}
|
||||
|
||||
@@ -153,6 +153,16 @@ object APILayer {
|
||||
println("❌ Failed to fetch subscription status: ${subscriptionStatusResult.message}")
|
||||
}
|
||||
|
||||
// Load feature benefits (auth required)
|
||||
println("🔄 Fetching feature benefits...")
|
||||
val featureBenefitsResult = subscriptionApi.getFeatureBenefits(token)
|
||||
if (featureBenefitsResult is ApiResult.Success) {
|
||||
println("✅ Feature benefits loaded: ${featureBenefitsResult.data.size} features")
|
||||
DataManager.setFeatureBenefits(featureBenefitsResult.data)
|
||||
} else if (featureBenefitsResult is ApiResult.Error) {
|
||||
println("❌ Failed to fetch feature benefits: ${featureBenefitsResult.message}")
|
||||
}
|
||||
|
||||
// Load contractors if cache is empty or stale
|
||||
if (!DataManager.isCacheValid(DataManager.contractorsCacheTime)) {
|
||||
println("🔄 Fetching contractors...")
|
||||
@@ -1391,6 +1401,18 @@ object APILayer {
|
||||
return subscriptionApi.verifyIOSReceipt(token, receiptData, transactionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch feature benefits from backend (requires auth).
|
||||
*/
|
||||
suspend fun getFeatureBenefits(): ApiResult<List<FeatureBenefit>> {
|
||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||
val result = subscriptionApi.getFeatureBenefits(token)
|
||||
if (result is ApiResult.Success) {
|
||||
DataManager.setFeatureBenefits(result.data)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ==================== Helper Methods ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -42,9 +42,11 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getFeatureBenefits(): ApiResult<List<FeatureBenefit>> {
|
||||
suspend fun getFeatureBenefits(token: String): ApiResult<List<FeatureBenefit>> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/subscription/features/")
|
||||
val response = client.get("$baseUrl/subscription/features/") {
|
||||
header("Authorization", "Token $token")
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.example.casera.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/**
|
||||
* Platform-specific upgrade screen composable.
|
||||
* On Android, wires BillingManager for Google Play purchases.
|
||||
* On iOS, purchase flow is handled in Swift via StoreKitManager.
|
||||
* On other platforms, shows the common UpgradeScreen with backend-only restore.
|
||||
*/
|
||||
@Composable
|
||||
expect fun PlatformUpgradeScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onSubscriptionChanged: () -> Unit
|
||||
)
|
||||
@@ -15,8 +15,8 @@ import com.example.casera.navigation.*
|
||||
import com.example.casera.repository.LookupsRepository
|
||||
import com.example.casera.models.Residence
|
||||
import com.example.casera.models.TaskDetail
|
||||
import com.example.casera.platform.PlatformUpgradeScreen
|
||||
import com.example.casera.storage.TokenStorage
|
||||
import com.example.casera.ui.subscription.UpgradeScreen
|
||||
import com.example.casera.ui.theme.*
|
||||
import casera.composeapp.generated.resources.*
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -315,16 +315,12 @@ fun MainScreen(
|
||||
|
||||
composable<UpgradeRoute> {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
UpgradeScreen(
|
||||
PlatformUpgradeScreen(
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
onPurchase = { planId ->
|
||||
// Handle purchase - integrate with billing system
|
||||
navController.popBackStack()
|
||||
},
|
||||
onRestorePurchases = {
|
||||
// Handle restore - integrate with billing system
|
||||
onSubscriptionChanged = {
|
||||
// Subscription state updated via DataManager
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,8 +22,7 @@ import com.example.casera.models.ResidenceCreateRequest
|
||||
import com.example.casera.models.ResidenceType
|
||||
import com.example.casera.models.ResidenceUser
|
||||
import com.example.casera.network.ApiResult
|
||||
import com.example.casera.network.ResidenceApi
|
||||
import com.example.casera.storage.TokenStorage
|
||||
import com.example.casera.network.APILayer
|
||||
import com.example.casera.analytics.PostHogAnalytics
|
||||
import com.example.casera.analytics.AnalyticsEvents
|
||||
import com.example.casera.ui.theme.*
|
||||
@@ -78,25 +77,21 @@ fun ResidenceFormScreen(
|
||||
var userToRemove by remember { mutableStateOf<ResidenceUser?>(null) }
|
||||
var showRemoveUserConfirmation by remember { mutableStateOf(false) }
|
||||
|
||||
val residenceApi = remember { ResidenceApi() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Load users when in edit mode and user is owner
|
||||
LaunchedEffect(isEditMode, isCurrentUserOwner, existingResidence?.id) {
|
||||
if (isEditMode && isCurrentUserOwner && existingResidence != null) {
|
||||
isLoadingUsers = true
|
||||
val token = TokenStorage.getToken()
|
||||
if (token != null) {
|
||||
when (val result = residenceApi.getResidenceUsers(token, existingResidence.id)) {
|
||||
is ApiResult.Success -> {
|
||||
// Filter out the owner from the list
|
||||
users = result.data.filter { it.id != existingResidence.ownerId }
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
// Silently fail - users list will be empty
|
||||
}
|
||||
else -> {}
|
||||
when (val result = APILayer.getResidenceUsers(existingResidence.id)) {
|
||||
is ApiResult.Success -> {
|
||||
// Filter out the owner from the list
|
||||
users = result.data.filter { it.id != existingResidence.ownerId }
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
// Silently fail - users list will be empty
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
isLoadingUsers = false
|
||||
}
|
||||
@@ -460,9 +455,8 @@ fun ResidenceFormScreen(
|
||||
onClick = {
|
||||
userToRemove?.let { user ->
|
||||
scope.launch {
|
||||
val token = TokenStorage.getToken()
|
||||
if (token != null && existingResidence != null) {
|
||||
when (residenceApi.removeUser(token, existingResidence.id, user.id)) {
|
||||
if (existingResidence != null) {
|
||||
when (APILayer.removeUser(existingResidence.id, user.id)) {
|
||||
is ApiResult.Success -> {
|
||||
users = users.filter { it.id != user.id }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.example.casera.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import com.example.casera.network.APILayer
|
||||
import com.example.casera.ui.subscription.UpgradeScreen
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* iOS: Purchase flow is handled in Swift via StoreKitManager.
|
||||
* Restore calls backend to refresh subscription status.
|
||||
*/
|
||||
@Composable
|
||||
actual fun PlatformUpgradeScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onSubscriptionChanged: () -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
UpgradeScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onPurchase = { _ ->
|
||||
// iOS purchase flow is handled by StoreKitManager in Swift layer
|
||||
onNavigateBack()
|
||||
},
|
||||
onRestorePurchases = {
|
||||
scope.launch {
|
||||
APILayer.getSubscriptionStatus(forceRefresh = true)
|
||||
onSubscriptionChanged()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -3,33 +3,71 @@ package com.example.casera.storage
|
||||
import platform.Foundation.NSUserDefaults
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
/**
|
||||
* Protocol for iOS Keychain operations. Implemented in Swift (KeychainHelper)
|
||||
* and injected before DataManager initialization.
|
||||
*
|
||||
* Kotlin/Native cannot directly use the Security framework (SecItem* APIs)
|
||||
* because CFStringRef keys like kSecClass don't bridge to NSCopying.
|
||||
*/
|
||||
interface KeychainDelegate {
|
||||
fun save(key: String, value: String): Boolean
|
||||
fun get(key: String): String?
|
||||
fun delete(key: String): Boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS implementation of TokenManager.
|
||||
*
|
||||
* SECURITY NOTE: Currently uses NSUserDefaults for token storage.
|
||||
* For production hardening, migrate to iOS Keychain via a Swift helper
|
||||
* exposed to KMP through an expect/actual boundary or SKIE bridge.
|
||||
* NSUserDefaults is not encrypted and should not store long-lived auth tokens
|
||||
* in apps handling sensitive data.
|
||||
* Uses iOS Keychain via [KeychainDelegate] for secure token storage.
|
||||
* Falls back to NSUserDefaults if delegate is not set (should not happen
|
||||
* in production — delegate is set in iOSApp.init before DataManager init).
|
||||
*
|
||||
* Migration plan:
|
||||
* 1. Create a Swift KeychainHelper class with save/get/delete methods
|
||||
* 2. Expose it to Kotlin via SKIE or a protocol-based expect/actual
|
||||
* 3. Use service "com.tt.casera", account "auth_token"
|
||||
* On first read, migrates any existing NSUserDefaults token to Keychain.
|
||||
*/
|
||||
actual class TokenManager {
|
||||
private val prefs = NSUserDefaults.standardUserDefaults
|
||||
|
||||
actual fun saveToken(token: String) {
|
||||
prefs.setObject(token, forKey = TOKEN_KEY)
|
||||
prefs.synchronize()
|
||||
val delegate = keychainDelegate
|
||||
if (delegate != null) {
|
||||
delegate.save(TOKEN_KEY, token)
|
||||
// Clean up old NSUserDefaults entry if it exists
|
||||
prefs.removeObjectForKey(TOKEN_KEY)
|
||||
prefs.synchronize()
|
||||
} else {
|
||||
// Fallback (should not happen in production)
|
||||
prefs.setObject(token, forKey = TOKEN_KEY)
|
||||
prefs.synchronize()
|
||||
}
|
||||
}
|
||||
|
||||
actual fun getToken(): String? {
|
||||
val delegate = keychainDelegate
|
||||
|
||||
// Try Keychain first
|
||||
if (delegate != null) {
|
||||
val keychainToken = delegate.get(TOKEN_KEY)
|
||||
if (keychainToken != null) return keychainToken
|
||||
|
||||
// Check NSUserDefaults for migration
|
||||
val oldToken = prefs.stringForKey(TOKEN_KEY)
|
||||
if (oldToken != null) {
|
||||
// Migrate to Keychain
|
||||
delegate.save(TOKEN_KEY, oldToken)
|
||||
prefs.removeObjectForKey(TOKEN_KEY)
|
||||
prefs.synchronize()
|
||||
return oldToken
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Fallback to NSUserDefaults (should not happen in production)
|
||||
return prefs.stringForKey(TOKEN_KEY)
|
||||
}
|
||||
|
||||
actual fun clearToken() {
|
||||
keychainDelegate?.delete(TOKEN_KEY)
|
||||
prefs.removeObjectForKey(TOKEN_KEY)
|
||||
prefs.synchronize()
|
||||
}
|
||||
@@ -37,6 +75,12 @@ actual class TokenManager {
|
||||
companion object {
|
||||
private const val TOKEN_KEY = "auth_token"
|
||||
|
||||
/**
|
||||
* Set from Swift in iOSApp.init() BEFORE DataManager.initialize().
|
||||
* This enables Keychain storage for auth tokens.
|
||||
*/
|
||||
var keychainDelegate: KeychainDelegate? = null
|
||||
|
||||
@Volatile
|
||||
private var instance: TokenManager? = null
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.example.casera.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.example.casera.ui.subscription.UpgradeScreen
|
||||
|
||||
@Composable
|
||||
actual fun PlatformUpgradeScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onSubscriptionChanged: () -> Unit
|
||||
) {
|
||||
UpgradeScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onPurchase = { _ -> onNavigateBack() },
|
||||
onRestorePurchases = { }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.example.casera.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.example.casera.ui.subscription.UpgradeScreen
|
||||
|
||||
@Composable
|
||||
actual fun PlatformUpgradeScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onSubscriptionChanged: () -> Unit
|
||||
) {
|
||||
UpgradeScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onPurchase = { _ -> onNavigateBack() },
|
||||
onRestorePurchases = { }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.example.casera.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.example.casera.ui.subscription.UpgradeScreen
|
||||
|
||||
@Composable
|
||||
actual fun PlatformUpgradeScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onSubscriptionChanged: () -> Unit
|
||||
) {
|
||||
UpgradeScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onPurchase = { _ -> onNavigateBack() },
|
||||
onRestorePurchases = { }
|
||||
)
|
||||
}
|
||||
@@ -5,8 +5,9 @@
|
||||
// Unit tests for WidgetDataManager.TaskMetrics and task categorization logic.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import iosApp
|
||||
@testable import Casera
|
||||
|
||||
// MARK: - Column Name Constants Tests
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ struct AccessibilityIdentifiers {
|
||||
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"
|
||||
@@ -35,6 +36,7 @@ struct AccessibilityIdentifiers {
|
||||
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"
|
||||
}
|
||||
|
||||
|
||||
111
iosApp/CaseraUITests/CriticalPath/AuthCriticalPathTests.swift
Normal file
111
iosApp/CaseraUITests/CriticalPath/AuthCriticalPathTests.swift
Normal 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"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import XCTest
|
||||
/// 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!
|
||||
|
||||
@@ -70,7 +72,6 @@ final class SmokeTests: XCTestCase {
|
||||
func testMainTabsExistAfterLogin() {
|
||||
let login = LoginScreen(app: app)
|
||||
if login.emailField.waitForExistence(timeout: 15) {
|
||||
// Need to login first
|
||||
let user = TestFixtures.TestUser.existing
|
||||
login.login(email: user.email, password: user.password)
|
||||
}
|
||||
@@ -81,11 +82,11 @@ final class SmokeTests: XCTestCase {
|
||||
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")
|
||||
XCTAssertTrue(main.profileTab.exists, "Profile tab should exist")
|
||||
}
|
||||
|
||||
func testTabNavigation() {
|
||||
@@ -111,9 +112,6 @@ final class SmokeTests: XCTestCase {
|
||||
main.goToDocuments()
|
||||
XCTAssertTrue(main.documentsTab.isSelected, "Documents tab should be selected")
|
||||
|
||||
main.goToProfile()
|
||||
XCTAssertTrue(main.profileTab.isSelected, "Profile tab should be selected")
|
||||
|
||||
main.goToResidences()
|
||||
XCTAssertTrue(main.residencesTab.isSelected, "Residences tab should be selected")
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
//
|
||||
// CaseraUITests.swift
|
||||
// CaseraUITests
|
||||
//
|
||||
// Created by Trey Tartt on 11/19/25.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class CaseraUITests: XCTestCase {
|
||||
|
||||
override func setUpWithError() throws {
|
||||
// Put setup code here. This method is called before the invocation of each test method in the class.
|
||||
|
||||
// In UI tests it is usually best to stop immediately when a failure occurs.
|
||||
continueAfterFailure = false
|
||||
|
||||
// In UI tests it's important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testExample() throws {
|
||||
// UI tests must launch the application that they test.
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLaunchPerformance() throws {
|
||||
// This measures how long it takes to launch your application.
|
||||
measure(metrics: [XCTApplicationLaunchMetric()]) {
|
||||
XCUIApplication().launch()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
//
|
||||
// CaseraUITestsLaunchTests.swift
|
||||
// CaseraUITests
|
||||
//
|
||||
// Created by Trey Tartt on 11/19/25.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class CaseraUITestsLaunchTests: XCTestCase {
|
||||
|
||||
override class var runsForEachTargetApplicationUIConfiguration: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLaunch() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// Insert steps here to perform after app launch but before taking a screenshot,
|
||||
// such as logging into a test account or navigating somewhere in the app
|
||||
|
||||
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||
attachment.name = "Launch Screen"
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,30 @@ class BaseScreen {
|
||||
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.
|
||||
|
||||
@@ -2,30 +2,32 @@ import XCTest
|
||||
|
||||
/// Page object for the main tab view that appears after login.
|
||||
///
|
||||
/// Provides navigation to each tab (Residences, Tasks, Contractors, Documents, Profile)
|
||||
/// and a logout flow. Uses predicate-based element lookup to match the existing test patterns.
|
||||
/// 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.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
||||
app.tabBars.buttons[AccessibilityIdentifiers.Navigation.residencesTab]
|
||||
}
|
||||
|
||||
var tasksTab: XCUIElement {
|
||||
app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
||||
app.tabBars.buttons[AccessibilityIdentifiers.Navigation.tasksTab]
|
||||
}
|
||||
|
||||
var contractorsTab: XCUIElement {
|
||||
app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
|
||||
app.tabBars.buttons[AccessibilityIdentifiers.Navigation.contractorsTab]
|
||||
}
|
||||
|
||||
var documentsTab: XCUIElement {
|
||||
app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Documents'")).firstMatch
|
||||
app.tabBars.buttons[AccessibilityIdentifiers.Navigation.documentsTab]
|
||||
}
|
||||
|
||||
var profileTab: XCUIElement {
|
||||
app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Profile'")).firstMatch
|
||||
/// Settings button on the Residences tab (leads to profile/settings).
|
||||
var settingsButton: XCUIElement {
|
||||
app.buttons[AccessibilityIdentifiers.Navigation.settingsButton]
|
||||
}
|
||||
|
||||
override var isDisplayed: Bool {
|
||||
@@ -58,18 +60,20 @@ class MainTabScreen: BaseScreen {
|
||||
return self
|
||||
}
|
||||
|
||||
/// Navigates to settings/profile via the settings button on Residences tab.
|
||||
@discardableResult
|
||||
func goToProfile() -> Self {
|
||||
waitForElement(profileTab).tap()
|
||||
func goToSettings() -> Self {
|
||||
goToResidences()
|
||||
waitForElement(settingsButton).tap()
|
||||
return self
|
||||
}
|
||||
|
||||
// MARK: - Logout
|
||||
|
||||
/// Logs out by navigating to the Profile tab and tapping the logout button.
|
||||
/// Logs out by navigating to settings and tapping the logout button.
|
||||
/// Handles the confirmation alert automatically.
|
||||
func logout() {
|
||||
goToProfile()
|
||||
goToSettings()
|
||||
|
||||
let logoutButton = app.buttons[AccessibilityIdentifiers.Profile.logoutButton]
|
||||
if logoutButton.waitForExistence(timeout: 5) {
|
||||
|
||||
@@ -4,31 +4,31 @@
|
||||
|
||||
```
|
||||
CaseraUITests/
|
||||
├── PageObjects/ # Screen abstractions (Page Object pattern)
|
||||
│ ├── BaseScreen.swift # Common wait/assert utilities
|
||||
│ ├── LoginScreen.swift # Login screen elements and actions
|
||||
│ ├── RegisterScreen.swift
|
||||
│ └── MainTabScreen.swift
|
||||
├── TestConfiguration/ # Launch config, environment setup
|
||||
├── 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
|
||||
├── Fixtures/ # Test data builders
|
||||
│ └── TestFixtures.swift
|
||||
├── CriticalPath/ # Must-pass tests for CI gating
|
||||
│ └── SmokeTests.swift # Fast smoke suite (<2 min)
|
||||
├── Suite0-10_*.swift # Existing comprehensive test suites
|
||||
├── UITestHelpers.swift # Legacy shared helpers
|
||||
├── AccessibilityIdentifiers.swift # UI element IDs
|
||||
└── README.md # This file
|
||||
├── 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, auth, navigation | Every PR | <2 min |
|
||||
| Suite0-2 | Onboarding, registration, auth | Nightly | <5 min |
|
||||
| Suite3-8 | Feature CRUD (residence, task, etc) | Nightly | <15 min |
|
||||
| Suite9-10 | E2E integration | Weekly | <30 min |
|
||||
| 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
|
||||
|
||||
@@ -36,7 +36,7 @@ CaseraUITests/
|
||||
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()`, or `waitForHittable()` from BaseScreen. These are condition-based waits that return as soon as the condition is met, making tests both faster and more reliable.
|
||||
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.
|
||||
@@ -45,15 +45,17 @@ Use `TestFixtures` builders for consistent, unique test data. Random numbers and
|
||||
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.
|
||||
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
|
||||
|
||||
### Smoke Suite (every PR)
|
||||
### Critical Path (every PR)
|
||||
```bash
|
||||
xcodebuild test -project iosApp.xcodeproj -scheme iosApp \
|
||||
-sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||
-only-testing:CaseraUITests/SmokeTests
|
||||
-only-testing:CaseraUITests/SmokeTests \
|
||||
-only-testing:CaseraUITests/AuthCriticalPathTests \
|
||||
-only-testing:CaseraUITests/NavigationCriticalPathTests
|
||||
```
|
||||
|
||||
### Full Regression (nightly)
|
||||
@@ -66,15 +68,16 @@ xcodebuild test -project iosApp.xcodeproj -scheme iosApp \
|
||||
## Flake Reduction
|
||||
|
||||
- Target: <2% flake rate on critical-path suite
|
||||
- All waits use condition-based predicates (no fixed sleeps)
|
||||
- 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 where possible, with predicate-based fallbacks
|
||||
- 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. Add test data builders to `TestFixtures.swift` if needed.
|
||||
4. Write the test in the appropriate suite file, or create a new suite if the feature is new.
|
||||
5. For critical-path tests (must pass on every PR), add to `CriticalPath/SmokeTests.swift`.
|
||||
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.
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
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: XCTestCase {
|
||||
var app: XCUIApplication!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// CRITICAL: Ensure we're logged out before each test
|
||||
ensureLoggedOut()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
app = nil
|
||||
}
|
||||
|
||||
// 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.staticTexts["Welcome Back"]
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
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 CaseraUITests scheme (Edit Scheme → Test → Pre-actions):
|
||||
/// /usr/bin/xcrun simctl uninstall booted com.tt.casera.CaseraDev
|
||||
/// 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: XCTestCase {
|
||||
var app: XCUIApplication!
|
||||
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
app = XCUIApplication()
|
||||
app.launch()
|
||||
sleep(2)
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
app.terminate()
|
||||
app = nil
|
||||
}
|
||||
|
||||
func test_onboarding() {
|
||||
let app = XCUIApplication()
|
||||
app.activate()
|
||||
|
||||
sleep(3)
|
||||
|
||||
let springboardApp = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
||||
springboardApp/*@START_MENU_TOKEN@*/.buttons["Allow"]/*[[".otherElements.buttons[\"Allow\"]",".buttons[\"Allow\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
sleep(1)
|
||||
|
||||
app/*@START_MENU_TOKEN@*/.buttons["Onboarding.StartFreshButton"]/*[[".buttons",".containing(.staticText, identifier: \"Start Fresh\")",".containing(.image, identifier: \"icon\")",".otherElements",".buttons[\"Start Fresh\"]",".buttons[\"Onboarding.StartFreshButton\"]"],[[[-1,5],[-1,4],[-1,3,2],[-1,0,1]],[[-1,2],[-1,1]],[[-1,5],[-1,4]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
|
||||
sleep(1)
|
||||
app.cells/*@START_MENU_TOKEN@*/.firstMatch/*[[".containing(.other, identifier: nil).firstMatch",".firstMatch"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.swipeLeft()
|
||||
|
||||
sleep(1)
|
||||
app/*@START_MENU_TOKEN@*/.staticTexts["Delayed maintenance turns $100 fixes into $10,000 disasters. Stay ahead of problems before they drain your wallet."]/*[[".otherElements.staticTexts[\"Delayed maintenance turns $100 fixes into $10,000 disasters. Stay ahead of problems before they drain your wallet.\"]",".staticTexts[\"Delayed maintenance turns $100 fixes into $10,000 disasters. Stay ahead of problems before they drain your wallet.\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.swipeLeft()
|
||||
|
||||
sleep(1)
|
||||
app/*@START_MENU_TOKEN@*/.staticTexts["Snap a photo of your receipts and warranties. When something breaks, you'll know exactly what's covered—instantly."]/*[[".otherElements.staticTexts[\"Snap a photo of your receipts and warranties. When something breaks, you'll know exactly what's covered—instantly.\"]",".staticTexts[\"Snap a photo of your receipts and warranties. When something breaks, you'll know exactly what's covered—instantly.\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.swipeLeft()
|
||||
sleep(1)
|
||||
|
||||
app/*@START_MENU_TOKEN@*/.staticTexts["I'm Ready!"]/*[[".buttons[\"I'm Ready!\"].staticTexts",".buttons.staticTexts[\"I'm Ready!\"]",".staticTexts[\"I'm Ready!\"]"],[[[-1,2],[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
|
||||
sleep(1)
|
||||
app/*@START_MENU_TOKEN@*/.textFields["Onboarding.ResidenceNameField"]/*[[".otherElements",".textFields[\"Xcuites\"]",".textFields[\"The Smith Residence\"]",".textFields[\"Onboarding.ResidenceNameField\"]",".textFields"],[[[-1,3],[-1,2],[-1,1],[-1,4],[-1,0,1]],[[-1,3],[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.typeText("xcuitest")
|
||||
app/*@START_MENU_TOKEN@*/.staticTexts["That's Perfect!"]/*[[".buttons[\"Onboarding.NameResidenceContinueButton\"].staticTexts",".buttons.staticTexts[\"That's Perfect!\"]",".staticTexts[\"That's Perfect!\"]"],[[[-1,2],[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
app/*@START_MENU_TOKEN@*/.staticTexts["Create Account with Email"]/*[[".buttons",".staticTexts",".staticTexts[\"Create Account with Email\"]"],[[[-1,2],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
|
||||
sleep(1)
|
||||
let scrollViewsQuery = app.scrollViews
|
||||
let element = scrollViewsQuery/*@START_MENU_TOKEN@*/.firstMatch/*[[".containing(.other, identifier: \"Vertical scroll bar, 2 pages\").firstMatch",".containing(.other, identifier: nil).firstMatch",".firstMatch"],[[[-1,2],[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/
|
||||
element.tap()
|
||||
app/*@START_MENU_TOKEN@*/.textFields["Username"]/*[[".otherElements.textFields[\"Username\"]",".textFields[\"Username\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
app/*@START_MENU_TOKEN@*/.textFields["Username"]/*[[".otherElements",".textFields[\"xcuitest\"]",".textFields[\"Username\"]"],[[[-1,2],[-1,1],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.typeText("xcuitest")
|
||||
scrollViewsQuery/*@START_MENU_TOKEN@*/.containing(.other, identifier: nil).firstMatch/*[[".element(boundBy: 0)",".containing(.other, identifier: \"Vertical scroll bar, 2 pages\").firstMatch",".containing(.other, identifier: nil).firstMatch"],[[[-1,2],[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.tap()
|
||||
|
||||
let element2 = app/*@START_MENU_TOKEN@*/.textFields["Email"]/*[[".otherElements.textFields[\"Email\"]",".textFields[\"Email\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch
|
||||
element2.tap()
|
||||
element2.tap()
|
||||
app/*@START_MENU_TOKEN@*/.textFields["Email"]/*[[".otherElements",".textFields[\"xcuitest@treymail.com\"]",".textFields[\"Email\"]"],[[[-1,2],[-1,1],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.typeText("xcuitest@treymail.com")
|
||||
|
||||
let element3 = app/*@START_MENU_TOKEN@*/.secureTextFields["Password"]/*[[".otherElements.secureTextFields[\"Password\"]",".secureTextFields[\"Password\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch
|
||||
element3.tap()
|
||||
element3.tap()
|
||||
app/*@START_MENU_TOKEN@*/.secureTextFields["Password"]/*[[".otherElements",".secureTextFields[\"••••••••\"]",".secureTextFields[\"Password\"]"],[[[-1,2],[-1,1],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.typeText("12345678")
|
||||
|
||||
let element4 = app/*@START_MENU_TOKEN@*/.secureTextFields["Confirm Password"]/*[[".otherElements.secureTextFields[\"Confirm Password\"]",".secureTextFields[\"Confirm Password\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch
|
||||
element4.tap()
|
||||
element4.tap()
|
||||
element4.typeText("12345678")
|
||||
element.swipeUp()
|
||||
app/*@START_MENU_TOKEN@*/.buttons["Onboarding.CreateAccountButton"]/*[[".otherElements",".buttons[\"Create Account\"]",".buttons[\"Onboarding.CreateAccountButton\"]"],[[[-1,2],[-1,1],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
app/*@START_MENU_TOKEN@*/.textFields["Onboarding.VerificationCodeField"]/*[[".otherElements",".textFields[\"Enter 6-digit code\"]",".textFields[\"Onboarding.VerificationCodeField\"]",".textFields"],[[[-1,2],[-1,1],[-1,3],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
|
||||
|
||||
sleep(1)
|
||||
let element5 = app/*@START_MENU_TOKEN@*/.textFields["Onboarding.VerificationCodeField"]/*[[".otherElements",".textFields[\"Enter 6-digit code\"]",".textFields[\"Onboarding.VerificationCodeField\"]",".textFields"],[[[-1,2],[-1,1],[-1,3],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch
|
||||
element5.tap()
|
||||
element5.tap()
|
||||
app/*@START_MENU_TOKEN@*/.textFields["Onboarding.VerificationCodeField"]/*[[".otherElements",".textFields[\"123456\"]",".textFields[\"Enter 6-digit code\"]",".textFields[\"Onboarding.VerificationCodeField\"]",".textFields"],[[[-1,3],[-1,2],[-1,1],[-1,4],[-1,0,1]],[[-1,3],[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.typeText("123456")
|
||||
sleep(1)
|
||||
|
||||
app/*@START_MENU_TOKEN@*/.images["chevron.up"]/*[[".buttons",".images[\"Go Up\"]",".images[\"chevron.up\"]"],[[[-1,2],[-1,1],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
sleep(1)
|
||||
app/*@START_MENU_TOKEN@*/.buttons["HVAC & Climate"]/*[[".buttons",".containing(.staticText, identifier: \"HVAC & Climate\")",".containing(.image, identifier: \"thermometer.medium\")",".otherElements.buttons[\"HVAC & Climate\"]",".buttons[\"HVAC & Climate\"]"],[[[-1,4],[-1,3],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
app/*@START_MENU_TOKEN@*/.staticTexts["Add Most Popular"]/*[[".buttons[\"Add Most Popular\"].staticTexts",".buttons.staticTexts[\"Add Most Popular\"]",".staticTexts[\"Add Most Popular\"]"],[[[-1,2],[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
app/*@START_MENU_TOKEN@*/.buttons["Add 5 Tasks & Continue"]/*[[".buttons",".containing(.image, identifier: \"arrow.right\")",".containing(.staticText, identifier: \"Add 5 Tasks & Continue\")",".otherElements.buttons[\"Add 5 Tasks & Continue\"]",".buttons[\"Add 5 Tasks & Continue\"]"],[[[-1,4],[-1,3],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
|
||||
sleep(1)
|
||||
app/*@START_MENU_TOKEN@*/.staticTexts["All your warranties, receipts, and manuals in one searchable place"]/*[[".otherElements.staticTexts[\"All your warranties, receipts, and manuals in one searchable place\"]",".staticTexts[\"All your warranties, receipts, and manuals in one searchable place\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
app/*@START_MENU_TOKEN@*/.buttons["Continue with Free"]/*[[".otherElements.buttons[\"Continue with Free\"]",".buttons[\"Continue with Free\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
|
||||
sleep(2)
|
||||
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.staticTexts["Welcome Back"].waitForExistence(timeout: 5)
|
||||
}
|
||||
}
|
||||
@@ -1,684 +0,0 @@
|
||||
import XCTest
|
||||
|
||||
/// Comprehensive End-to-End Test Suite
|
||||
/// Closely mirrors TestIntegration_ComprehensiveE2E from myCribAPI-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: XCTestCase {
|
||||
var app: XCUIApplication!
|
||||
|
||||
// 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 {
|
||||
continueAfterFailure = false
|
||||
app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.staticTexts["Welcome Back"]
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
app = nil
|
||||
}
|
||||
|
||||
// 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("========================")
|
||||
}
|
||||
}
|
||||
@@ -1,646 +0,0 @@
|
||||
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: XCTestCase {
|
||||
var app: XCUIApplication!
|
||||
|
||||
// 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 = "TestPass123!"
|
||||
|
||||
/// Fixed test verification code - Go API uses this code when DEBUG=true
|
||||
private let testVerificationCode = "123456"
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// STRICT: Verify app launched to a known state
|
||||
let loginScreen = app.staticTexts["Welcome Back"]
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
|
||||
// Either on login screen OR logged in - handle both
|
||||
if !loginScreen.waitForExistence(timeout: 3) && tabBar.exists {
|
||||
// Logged in - need to logout first
|
||||
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()
|
||||
app = nil
|
||||
}
|
||||
|
||||
// MARK: - Strict Helper Methods
|
||||
|
||||
private func ensureLoggedOut() {
|
||||
// Try profile tab logout
|
||||
let profileTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Profile'")).firstMatch
|
||||
if profileTab.exists && profileTab.isHittable {
|
||||
dismissKeyboard()
|
||||
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 {
|
||||
dismissKeyboard()
|
||||
logoutButton.tap()
|
||||
|
||||
// Handle confirmation alert
|
||||
let alertLogout = app.alerts.buttons["Log Out"]
|
||||
if alertLogout.waitForExistence(timeout: 2) {
|
||||
dismissKeyboard()
|
||||
alertLogout.tap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try verification screen logout
|
||||
let verifyLogout = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout'")).firstMatch
|
||||
if verifyLogout.exists && verifyLogout.isHittable {
|
||||
dismissKeyboard()
|
||||
verifyLogout.tap()
|
||||
}
|
||||
|
||||
// Wait for login screen
|
||||
_ = app.staticTexts["Welcome Back"].waitForExistence(timeout: 5)
|
||||
}
|
||||
|
||||
/// 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.staticTexts["Welcome Back"]
|
||||
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")
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
/// 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.staticTexts["Welcome Back"]
|
||||
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
|
||||
)
|
||||
|
||||
// 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
|
||||
let verifyTitle = app.staticTexts["Verify Your Email"]
|
||||
XCTAssertTrue(verifyTitle.waitForExistence(timeout: 10), "Verification screen must appear after registration")
|
||||
|
||||
// STRICT: Verification screen must be the active screen (not behind anything)
|
||||
XCTAssertTrue(verifyTitle.isHittable, "Verification title must be visible and not obscured")
|
||||
|
||||
// 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 = app.textFields[AccessibilityIdentifiers.Authentication.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 = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Verify'")).firstMatch
|
||||
XCTAssertTrue(verifyButton.exists && verifyButton.isHittable, "Verify button must be tappable")
|
||||
verifyButton.tap()
|
||||
|
||||
// STRICT: Verification screen must DISAPPEAR
|
||||
XCTAssertTrue(waitForElementToDisappear(verifyTitle, timeout: 10), "Verification screen 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(verifyTitle.exists, "Verification screen must NOT exist after successful verification")
|
||||
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.staticTexts["Welcome Back"]
|
||||
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
|
||||
let verifyTitle = app.staticTexts["Verify Your Email"]
|
||||
XCTAssertTrue(verifyTitle.waitForExistence(timeout: 10), "Must navigate to verification screen")
|
||||
|
||||
// Enter INVALID code
|
||||
let codeField = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField]
|
||||
XCTAssertTrue(codeField.waitForExistence(timeout: 5) && codeField.isHittable)
|
||||
dismissKeyboard()
|
||||
codeField.tap()
|
||||
codeField.typeText("000000") // Wrong code
|
||||
|
||||
let verifyButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Verify'")).firstMatch
|
||||
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()
|
||||
//
|
||||
let verifyTitle = app.staticTexts["Verify Your Email"]
|
||||
XCTAssertTrue(verifyTitle.waitForExistence(timeout: 10))
|
||||
|
||||
// Enter incomplete code (only 3 digits)
|
||||
let codeField = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField]
|
||||
XCTAssertTrue(codeField.waitForExistence(timeout: 5) && codeField.isHittable)
|
||||
dismissKeyboard()
|
||||
codeField.tap()
|
||||
codeField.typeText("123") // Incomplete
|
||||
|
||||
let verifyButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Verify'")).firstMatch
|
||||
|
||||
// Button might be disabled with incomplete code
|
||||
if verifyButton.isEnabled {
|
||||
dismissKeyboard()
|
||||
verifyButton.tap()
|
||||
}
|
||||
|
||||
// STRICT: Must still be on verification screen
|
||||
XCTAssertTrue(verifyTitle.exists && verifyTitle.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
|
||||
let verifyTitle = app.staticTexts["Verify Your Email"]
|
||||
XCTAssertTrue(verifyTitle.waitForExistence(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 verifyTitleAfterRelaunch = app.staticTexts["Verify Your Email"]
|
||||
let loginScreen = app.staticTexts["Welcome Back"]
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
|
||||
// Wait for app to settle
|
||||
_ = verifyTitleAfterRelaunch.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 = verifyTitleAfterRelaunch.exists && verifyTitleAfterRelaunch.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
|
||||
let verifyTitle = app.staticTexts["Verify Your Email"]
|
||||
XCTAssertTrue(verifyTitle.waitForExistence(timeout: 10), "Must navigate to verification screen")
|
||||
XCTAssertTrue(verifyTitle.isHittable, "Verification screen must be active")
|
||||
|
||||
// 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
|
||||
XCTAssertTrue(waitForElementToDisappear(verifyTitle, timeout: 5), "Verification screen must disappear after logout")
|
||||
|
||||
// STRICT: Must return to login screen
|
||||
let welcomeText = app.staticTexts["Welcome Back"]
|
||||
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
|
||||
let codeField = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField]
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import XCTest
|
||||
|
||||
/// Authentication flow tests
|
||||
/// Based on working SimpleLoginTest pattern
|
||||
final class Suite2_AuthenticationTests: XCTestCase {
|
||||
var app: XCUIApplication!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
app = XCUIApplication()
|
||||
app.launch()
|
||||
ensureLoggedOut()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
app = nil
|
||||
}
|
||||
|
||||
// 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.staticTexts["Welcome Back"]
|
||||
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.staticTexts["Welcome Back"]
|
||||
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.staticTexts["Welcome Back"]
|
||||
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.staticTexts["Welcome Back"]
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
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: XCTestCase {
|
||||
var app: XCUIApplication!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
app = XCUIApplication()
|
||||
app.launch()
|
||||
ensureLoggedIn()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
app = nil
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -1,671 +0,0 @@
|
||||
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: XCTestCase {
|
||||
var app: XCUIApplication!
|
||||
|
||||
// Test data tracking
|
||||
var createdResidenceNames: [String] = []
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// Ensure user is logged in
|
||||
UITestHelpers.ensureLoggedIn(app: app)
|
||||
|
||||
// Navigate to Residences tab
|
||||
navigateToResidencesTab()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
createdResidenceNames.removeAll()
|
||||
app = nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
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: XCTestCase {
|
||||
var app: XCUIApplication!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// 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 {
|
||||
app = nil
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -1,656 +0,0 @@
|
||||
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: XCTestCase {
|
||||
var app: XCUIApplication!
|
||||
|
||||
// Test data tracking
|
||||
var createdTaskTitles: [String] = []
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// 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()
|
||||
app = nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,718 +0,0 @@
|
||||
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: XCTestCase {
|
||||
var app: XCUIApplication!
|
||||
|
||||
// Test data tracking
|
||||
var createdContractorNames: [String] = []
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// Ensure user is logged in
|
||||
UITestHelpers.ensureLoggedIn(app: app)
|
||||
|
||||
// Navigate to Contractors tab
|
||||
navigateToContractorsTab()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
createdContractorNames.removeAll()
|
||||
app = nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,945 +0,0 @@
|
||||
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: XCTestCase {
|
||||
var app: XCUIApplication!
|
||||
|
||||
// Test data tracking
|
||||
var createdDocumentTitles: [String] = []
|
||||
var currentResidenceId: Int32?
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// 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
|
||||
app = nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,526 +0,0 @@
|
||||
import XCTest
|
||||
|
||||
/// Comprehensive End-to-End Integration Tests
|
||||
/// Mirrors the backend integration tests in myCribAPI-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: XCTestCase {
|
||||
var app: XCUIApplication!
|
||||
|
||||
// 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 {
|
||||
continueAfterFailure = false
|
||||
app = XCUIApplication()
|
||||
app.launch()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
app = nil
|
||||
}
|
||||
|
||||
// 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.staticTexts["Welcome Back"]
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,49 +1,43 @@
|
||||
import XCTest
|
||||
|
||||
/// Reusable helper functions for UI tests
|
||||
/// Reusable helper functions for UI tests.
|
||||
/// All waits use explicit conditions — zero sleep() calls.
|
||||
struct UITestHelpers {
|
||||
|
||||
// MARK: - Authentication Helpers
|
||||
|
||||
/// Logs out the user if they are currently logged in
|
||||
/// - Parameter app: The XCUIApplication instance
|
||||
/// Logs out the user if they are currently logged in.
|
||||
static func logout(app: XCUIApplication) {
|
||||
sleep(2)
|
||||
|
||||
// Check if already logged out (login screen visible)
|
||||
let welcomeText = app.staticTexts["Welcome Back"]
|
||||
if welcomeText.exists {
|
||||
// Already logged out
|
||||
if welcomeText.waitForExistence(timeout: 3) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we have a tab bar (logged in state)
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
guard tabBar.exists else { return }
|
||||
guard tabBar.waitForExistence(timeout: 3) else { return }
|
||||
|
||||
// Navigate to Residences tab first
|
||||
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
||||
let residencesTab = app.tabBars.buttons[AccessibilityIdentifiers.Navigation.residencesTab]
|
||||
if residencesTab.exists {
|
||||
residencesTab.tap()
|
||||
sleep(1)
|
||||
}
|
||||
|
||||
// Tap settings button
|
||||
let settingsButton = app.buttons[AccessibilityIdentifiers.Navigation.settingsButton]
|
||||
if settingsButton.waitForExistence(timeout: 3) && settingsButton.isHittable {
|
||||
if settingsButton.waitForExistence(timeout: 5) {
|
||||
settingsButton.tap()
|
||||
sleep(1)
|
||||
}
|
||||
|
||||
// Find and tap logout button
|
||||
let logoutButton = app.buttons[AccessibilityIdentifiers.Profile.logoutButton]
|
||||
if logoutButton.waitForExistence(timeout: 3) {
|
||||
if logoutButton.waitForExistence(timeout: 5) {
|
||||
logoutButton.tap()
|
||||
sleep(1)
|
||||
|
||||
// Confirm logout in alert if present - specifically target the alert's button
|
||||
// Confirm logout in alert if present
|
||||
let alert = app.alerts.firstMatch
|
||||
if alert.waitForExistence(timeout: 2) {
|
||||
if alert.waitForExistence(timeout: 3) {
|
||||
let confirmLogout = alert.buttons["Log Out"]
|
||||
if confirmLogout.exists {
|
||||
confirmLogout.tap()
|
||||
@@ -51,25 +45,21 @@ struct UITestHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
sleep(2)
|
||||
|
||||
// Verify we're back on login screen
|
||||
XCTAssertTrue(welcomeText.waitForExistence(timeout: 5), "Failed to log out - Welcome Back screen should appear after logout")
|
||||
XCTAssertTrue(
|
||||
welcomeText.waitForExistence(timeout: 10),
|
||||
"Failed to log out - Welcome Back screen should appear after logout"
|
||||
)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// Logs in a user with the provided credentials.
|
||||
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
|
||||
// Password field may be SecureTextField or regular TextField
|
||||
var passwordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.passwordField]
|
||||
if !passwordField.exists {
|
||||
passwordField = app.textFields[AccessibilityIdentifiers.Authentication.passwordField]
|
||||
@@ -78,42 +68,37 @@ struct UITestHelpers {
|
||||
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)
|
||||
// Wait for login to complete by checking for tab bar appearance
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
_ = tabBar.waitForExistence(timeout: 15)
|
||||
}
|
||||
|
||||
/// Ensures the user is logged out before running a test
|
||||
/// - Parameter app: The XCUIApplication instance
|
||||
/// Ensures the user is logged out before running a test.
|
||||
static func ensureLoggedOut(app: XCUIApplication) {
|
||||
sleep(2)
|
||||
logout(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(2)
|
||||
|
||||
/// Ensures the user is logged in with test credentials before running a test.
|
||||
static func ensureLoggedIn(
|
||||
app: XCUIApplication,
|
||||
username: String = "testuser",
|
||||
password: String = "TestPass123!"
|
||||
) {
|
||||
// Check if already logged in (tab bar visible)
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
if tabBar.exists {
|
||||
return // Already logged in
|
||||
if tabBar.waitForExistence(timeout: 5) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if on login screen
|
||||
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)
|
||||
_ = tabBar.waitForExistence(timeout: 15)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ enum AnalyticsEvent {
|
||||
// MARK: - Authentication
|
||||
case userSignedIn(method: String)
|
||||
case userSignedInApple(isNewUser: Bool)
|
||||
case userSignedInGoogle(isNewUser: Bool)
|
||||
case userRegistered(method: String)
|
||||
|
||||
// MARK: - Residence
|
||||
@@ -43,6 +44,8 @@ enum AnalyticsEvent {
|
||||
return ("user_signed_in", ["method": method])
|
||||
case .userSignedInApple(let isNewUser):
|
||||
return ("user_signed_in_apple", ["is_new_user": isNewUser])
|
||||
case .userSignedInGoogle(let isNewUser):
|
||||
return ("user_signed_in_google", ["is_new_user": isNewUser])
|
||||
case .userRegistered(let method):
|
||||
return ("user_registered", ["method": method])
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
import ComposeApp
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
// MARK: - Architecture Note
|
||||
//
|
||||
@@ -17,7 +18,7 @@ import SwiftUI
|
||||
// - Used by detail views (DocumentDetailView, EditDocumentView)
|
||||
// - Manages explicit state types (Loading/Success/Error) for single-document operations
|
||||
// - Loads individual document detail, handles update and delete with state feedback
|
||||
// - Does NOT observe DataManager -- loads fresh data per-request via APILayer
|
||||
// - Observes DataManager for automatic detail updates after mutations
|
||||
// - Uses protocol-based state enums for SwiftUI view branching
|
||||
//
|
||||
// Both call through APILayer (which updates DataManager), so list views
|
||||
@@ -70,6 +71,7 @@ struct DeleteImageStateError: DeleteImageState {
|
||||
let message: String
|
||||
}
|
||||
|
||||
@MainActor
|
||||
class DocumentViewModelWrapper: ObservableObject {
|
||||
@Published var documentsState: DocumentState = DocumentStateIdle()
|
||||
@Published var documentDetailState: DocumentDetailState = DocumentDetailStateIdle()
|
||||
@@ -77,6 +79,25 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
@Published var deleteState: DeleteState = DeleteStateIdle()
|
||||
@Published var deleteImageState: DeleteImageState = DeleteImageStateIdle()
|
||||
|
||||
/// The document ID currently loaded in detail view, used for auto-update
|
||||
private var loadedDocumentId: Int32?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init() {
|
||||
// Observe DataManager documents for auto-update of loaded detail
|
||||
DataManagerObservable.shared.$documents
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] documents in
|
||||
guard let self, let docId = self.loadedDocumentId else { return }
|
||||
// Only auto-update if we're in a success state
|
||||
guard self.documentDetailState is DocumentDetailStateSuccess else { return }
|
||||
if let updated = documents.first(where: { $0.id?.int32Value == docId }) {
|
||||
self.documentDetailState = DocumentDetailStateSuccess(document: updated)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func loadDocuments(
|
||||
residenceId: Int32? = nil,
|
||||
documentType: String? = nil,
|
||||
@@ -87,9 +108,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
tags: String? = nil,
|
||||
search: String? = nil
|
||||
) {
|
||||
DispatchQueue.main.async {
|
||||
self.documentsState = DocumentStateLoading()
|
||||
}
|
||||
self.documentsState = DocumentStateLoading()
|
||||
|
||||
Task {
|
||||
do {
|
||||
@@ -105,7 +124,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
forceRefresh: false
|
||||
)
|
||||
|
||||
await MainActor.run {
|
||||
do {
|
||||
if let success = result as? ApiResultSuccess<NSArray> {
|
||||
let documents = success.data as? [Document] ?? []
|
||||
self.documentsState = DocumentStateSuccess(documents: documents)
|
||||
@@ -116,7 +135,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
do {
|
||||
self.documentsState = DocumentStateError(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
@@ -124,15 +143,14 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
}
|
||||
|
||||
func loadDocumentDetail(id: Int32) {
|
||||
DispatchQueue.main.async {
|
||||
self.documentDetailState = DocumentDetailStateLoading()
|
||||
}
|
||||
loadedDocumentId = id
|
||||
self.documentDetailState = DocumentDetailStateLoading()
|
||||
|
||||
Task {
|
||||
do {
|
||||
let result = try await APILayer.shared.getDocument(id: id, forceRefresh: false)
|
||||
|
||||
await MainActor.run {
|
||||
do {
|
||||
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
|
||||
self.documentDetailState = DocumentDetailStateSuccess(document: document)
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
@@ -142,7 +160,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
do {
|
||||
self.documentDetailState = DocumentDetailStateError(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
@@ -170,9 +188,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
startDate: String? = nil,
|
||||
endDate: String? = nil
|
||||
) {
|
||||
DispatchQueue.main.async {
|
||||
self.updateState = UpdateStateLoading()
|
||||
}
|
||||
self.updateState = UpdateStateLoading()
|
||||
|
||||
Task {
|
||||
do {
|
||||
@@ -199,7 +215,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
endDate: endDate
|
||||
)
|
||||
|
||||
await MainActor.run {
|
||||
do {
|
||||
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
|
||||
self.updateState = UpdateStateSuccess(document: document)
|
||||
// Also refresh the detail state
|
||||
@@ -211,7 +227,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
do {
|
||||
self.updateState = UpdateStateError(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
@@ -219,15 +235,13 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
}
|
||||
|
||||
func deleteDocument(id: Int32) {
|
||||
DispatchQueue.main.async {
|
||||
self.deleteState = DeleteStateLoading()
|
||||
}
|
||||
self.deleteState = DeleteStateLoading()
|
||||
|
||||
Task {
|
||||
do {
|
||||
let result = try await APILayer.shared.deleteDocument(id: id)
|
||||
|
||||
await MainActor.run {
|
||||
do {
|
||||
if result is ApiResultSuccess<KotlinUnit> {
|
||||
self.deleteState = DeleteStateSuccess()
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
@@ -237,7 +251,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
do {
|
||||
self.deleteState = DeleteStateError(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
@@ -245,27 +259,21 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
}
|
||||
|
||||
func resetUpdateState() {
|
||||
DispatchQueue.main.async {
|
||||
self.updateState = UpdateStateIdle()
|
||||
}
|
||||
self.updateState = UpdateStateIdle()
|
||||
}
|
||||
|
||||
func resetDeleteState() {
|
||||
DispatchQueue.main.async {
|
||||
self.deleteState = DeleteStateIdle()
|
||||
}
|
||||
self.deleteState = DeleteStateIdle()
|
||||
}
|
||||
|
||||
func deleteDocumentImage(documentId: Int32, imageId: Int32) {
|
||||
DispatchQueue.main.async {
|
||||
self.deleteImageState = DeleteImageStateLoading()
|
||||
}
|
||||
self.deleteImageState = DeleteImageStateLoading()
|
||||
|
||||
Task {
|
||||
do {
|
||||
let result = try await APILayer.shared.deleteDocumentImage(documentId: documentId, imageId: imageId)
|
||||
|
||||
await MainActor.run {
|
||||
do {
|
||||
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
|
||||
self.deleteImageState = DeleteImageStateSuccess()
|
||||
// Refresh detail state with updated document (image removed)
|
||||
@@ -277,7 +285,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
do {
|
||||
self.deleteImageState = DeleteImageStateError(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
@@ -285,8 +293,6 @@ class DocumentViewModelWrapper: ObservableObject {
|
||||
}
|
||||
|
||||
func resetDeleteImageState() {
|
||||
DispatchQueue.main.async {
|
||||
self.deleteImageState = DeleteImageStateIdle()
|
||||
}
|
||||
self.deleteImageState = DeleteImageStateIdle()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +177,7 @@ struct DocumentsWarrantiesView: View {
|
||||
}) {
|
||||
OrganicDocToolbarButton()
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Document.addButton)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ struct AccessibilityIdentifiers {
|
||||
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"
|
||||
|
||||
171
iosApp/iosApp/Login/GoogleSignInManager.swift
Normal file
171
iosApp/iosApp/Login/GoogleSignInManager.swift
Normal file
@@ -0,0 +1,171 @@
|
||||
import Foundation
|
||||
import AuthenticationServices
|
||||
import ComposeApp
|
||||
|
||||
/// Handles Google OAuth flow using ASWebAuthenticationSession.
|
||||
/// Obtains a Google ID token, then sends it to the backend via APILayer.
|
||||
@MainActor
|
||||
final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthenticationPresentationContextProviding {
|
||||
static let shared = GoogleSignInManager()
|
||||
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
/// Called on successful sign-in with (isVerified: Bool)
|
||||
var onSignInSuccess: ((Bool) -> Void)?
|
||||
|
||||
private var webAuthSession: ASWebAuthenticationSession?
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func signIn() {
|
||||
guard !isLoading else { return }
|
||||
|
||||
let clientId = ApiConfig.shared.GOOGLE_WEB_CLIENT_ID
|
||||
guard ApiConfig.shared.isGoogleSignInConfigured else {
|
||||
errorMessage = "Google Sign-In is not configured. A Google Cloud client ID is required."
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
// Build Google OAuth URL
|
||||
let redirectScheme = "com.tt.casera"
|
||||
let redirectURI = "\(redirectScheme):/oauth2callback"
|
||||
var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "client_id", value: clientId),
|
||||
URLQueryItem(name: "redirect_uri", value: redirectURI),
|
||||
URLQueryItem(name: "response_type", value: "code"),
|
||||
URLQueryItem(name: "scope", value: "openid email profile"),
|
||||
URLQueryItem(name: "access_type", value: "offline"),
|
||||
URLQueryItem(name: "prompt", value: "select_account"),
|
||||
]
|
||||
|
||||
guard let authURL = components.url else {
|
||||
isLoading = false
|
||||
errorMessage = "Failed to build authentication URL"
|
||||
return
|
||||
}
|
||||
|
||||
let session = ASWebAuthenticationSession(
|
||||
url: authURL,
|
||||
callbackURLScheme: redirectScheme
|
||||
) { [weak self] callbackURL, error in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
|
||||
if let error {
|
||||
self.isLoading = false
|
||||
// Don't show error for user cancellation
|
||||
if (error as NSError).code != ASWebAuthenticationSessionError.canceledLogin.rawValue {
|
||||
self.errorMessage = "Sign in failed: \(error.localizedDescription)"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let callbackURL,
|
||||
let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false),
|
||||
let code = components.queryItems?.first(where: { $0.name == "code" })?.value else {
|
||||
self.isLoading = false
|
||||
self.errorMessage = "Failed to get authorization code from Google"
|
||||
return
|
||||
}
|
||||
|
||||
await self.exchangeCodeForToken(code: code, redirectURI: redirectURI, clientId: clientId)
|
||||
}
|
||||
}
|
||||
|
||||
session.presentationContextProvider = self
|
||||
session.prefersEphemeralWebBrowserSession = false
|
||||
webAuthSession = session
|
||||
session.start()
|
||||
}
|
||||
|
||||
// MARK: - ASWebAuthenticationPresentationContextProviding
|
||||
|
||||
nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
||||
// Return the key window for presentation
|
||||
let scenes = UIApplication.shared.connectedScenes
|
||||
let windowScene = scenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene
|
||||
return windowScene?.windows.first(where: { $0.isKeyWindow }) ?? ASPresentationAnchor()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Exchange authorization code for ID token via Google's token endpoint
|
||||
private func exchangeCodeForToken(code: String, redirectURI: String, clientId: String) async {
|
||||
let tokenURL = URL(string: "https://oauth2.googleapis.com/token")!
|
||||
var request = URLRequest(url: tokenURL)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let body = [
|
||||
"code": code,
|
||||
"client_id": clientId,
|
||||
"redirect_uri": redirectURI,
|
||||
"grant_type": "authorization_code",
|
||||
]
|
||||
request.httpBody = body
|
||||
.map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? $0.value)" }
|
||||
.joined(separator: "&")
|
||||
.data(using: .utf8)
|
||||
|
||||
do {
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
|
||||
isLoading = false
|
||||
errorMessage = "Failed to exchange authorization code"
|
||||
return
|
||||
}
|
||||
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let idToken = json["id_token"] as? String else {
|
||||
isLoading = false
|
||||
errorMessage = "Failed to get ID token from Google"
|
||||
return
|
||||
}
|
||||
|
||||
// Send ID token to backend
|
||||
await sendToBackend(idToken: idToken)
|
||||
} catch {
|
||||
isLoading = false
|
||||
errorMessage = "Network error: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Send Google ID token to backend for verification and authentication
|
||||
private func sendToBackend(idToken: String) async {
|
||||
let request = GoogleSignInRequest(idToken: idToken)
|
||||
let result = try? await APILayer.shared.googleSignIn(request: request)
|
||||
|
||||
guard let result else {
|
||||
isLoading = false
|
||||
errorMessage = "Sign in failed. Please try again."
|
||||
return
|
||||
}
|
||||
|
||||
if let success = result as? ApiResultSuccess<GoogleSignInResponse>, let response = success.data {
|
||||
isLoading = false
|
||||
|
||||
// Share token and API URL with widget extension
|
||||
WidgetDataManager.shared.saveAuthToken(response.token)
|
||||
WidgetDataManager.shared.saveAPIBaseURL(ApiClient.shared.getBaseUrl())
|
||||
|
||||
// Track Google Sign In
|
||||
AnalyticsManager.shared.track(.userSignedInGoogle(isNewUser: response.isNewUser))
|
||||
|
||||
// Call success callback with verification status
|
||||
onSignInSuccess?(response.user.verified)
|
||||
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
isLoading = false
|
||||
errorMessage = ErrorMessageParser.parse(error.message)
|
||||
} else {
|
||||
isLoading = false
|
||||
errorMessage = "Sign in failed. Please try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ struct LoginView: View {
|
||||
@State private var showPasswordReset = false
|
||||
@State private var isPasswordVisible = false
|
||||
@State private var activeResetToken: String?
|
||||
@State private var showGoogleSignInAlert = false
|
||||
@StateObject private var googleSignInManager = GoogleSignInManager.shared
|
||||
@Binding var resetToken: String?
|
||||
var onLoginSuccess: (() -> Void)?
|
||||
|
||||
@@ -194,9 +194,8 @@ struct LoginView: View {
|
||||
}
|
||||
|
||||
// Google Sign-In Button
|
||||
// TODO: Replace with full Google Sign-In SDK integration (requires GoogleSignIn pod/SPM package)
|
||||
Button(action: {
|
||||
showGoogleSignInAlert = true
|
||||
googleSignInManager.signIn()
|
||||
}) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "globe")
|
||||
@@ -215,6 +214,7 @@ struct LoginView: View {
|
||||
.stroke(Color.appTextSecondary.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.googleSignInButton)
|
||||
|
||||
// Apple Sign In Error
|
||||
if let appleError = appleSignInViewModel.errorMessage {
|
||||
@@ -285,6 +285,15 @@ struct LoginView: View {
|
||||
// since AuthenticationManager.isVerified is now false
|
||||
}
|
||||
}
|
||||
|
||||
// Set up callback for Google Sign In success
|
||||
googleSignInManager.onSignInSuccess = { [self] isVerified in
|
||||
AuthenticationManager.shared.login(verified: isVerified)
|
||||
|
||||
if isVerified {
|
||||
self.onLoginSuccess?()
|
||||
}
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $showVerification) {
|
||||
VerifyEmailView(
|
||||
@@ -327,10 +336,13 @@ struct LoginView: View {
|
||||
activeResetToken = nil
|
||||
}
|
||||
}
|
||||
.alert("Google Sign-In", isPresented: $showGoogleSignInAlert) {
|
||||
.alert("Google Sign-In Error", isPresented: .init(
|
||||
get: { googleSignInManager.errorMessage != nil },
|
||||
set: { if !$0 { googleSignInManager.errorMessage = nil } }
|
||||
)) {
|
||||
Button("OK", role: .cancel) { }
|
||||
} message: {
|
||||
Text("Google Sign-In coming soon. This feature is under development.")
|
||||
Text(googleSignInManager.errorMessage ?? "An error occurred.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ struct OnboardingCreateAccountContent: View {
|
||||
@State private var showingLoginSheet = false
|
||||
@State private var isExpanded = false
|
||||
@State private var isAnimating = false
|
||||
@State private var showGoogleSignInAlert = false
|
||||
@StateObject private var googleSignInManager = GoogleSignInManager.shared
|
||||
@FocusState private var focusedField: Field?
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
@@ -142,9 +142,8 @@ struct OnboardingCreateAccountContent: View {
|
||||
}
|
||||
|
||||
// Google Sign-In Button
|
||||
// TODO: Replace with full Google Sign-In SDK integration (requires GoogleSignIn pod/SPM package)
|
||||
Button(action: {
|
||||
showGoogleSignInAlert = true
|
||||
googleSignInManager.signIn()
|
||||
}) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "globe")
|
||||
@@ -323,10 +322,13 @@ struct OnboardingCreateAccountContent: View {
|
||||
onAccountCreated(true)
|
||||
})
|
||||
}
|
||||
.alert("Google Sign-In", isPresented: $showGoogleSignInAlert) {
|
||||
.alert("Google Sign-In Error", isPresented: .init(
|
||||
get: { googleSignInManager.errorMessage != nil },
|
||||
set: { if !$0 { googleSignInManager.errorMessage = nil } }
|
||||
)) {
|
||||
Button("OK", role: .cancel) { }
|
||||
} message: {
|
||||
Text("Google Sign-In coming soon. This feature is under development.")
|
||||
Text(googleSignInManager.errorMessage ?? "An error occurred.")
|
||||
}
|
||||
.onChange(of: viewModel.isRegistered) { _, isRegistered in
|
||||
if isRegistered {
|
||||
@@ -339,7 +341,11 @@ struct OnboardingCreateAccountContent: View {
|
||||
// Set up Apple Sign In callback
|
||||
appleSignInViewModel.onSignInSuccess = { isVerified in
|
||||
AuthenticationManager.shared.login(verified: isVerified)
|
||||
// Residence creation is handled by the coordinator
|
||||
onAccountCreated(isVerified)
|
||||
}
|
||||
// Set up Google Sign In callback
|
||||
googleSignInManager.onSignInSuccess = { isVerified in
|
||||
AuthenticationManager.shared.login(verified: isVerified)
|
||||
onAccountCreated(isVerified)
|
||||
}
|
||||
}
|
||||
|
||||
64
iosApp/iosApp/Shared/Utilities/KeychainHelper.swift
Normal file
64
iosApp/iosApp/Shared/Utilities/KeychainHelper.swift
Normal file
@@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import ComposeApp
|
||||
|
||||
/// Implements KeychainDelegate (Kotlin interface) to provide secure token storage
|
||||
/// via the iOS Keychain. Injected into TokenManager.Companion before DataManager init.
|
||||
final class KeychainHelper: NSObject, KeychainDelegate {
|
||||
static let shared = KeychainHelper()
|
||||
|
||||
private let service = "com.tt.casera"
|
||||
|
||||
func save(key: String, value: String) -> Bool {
|
||||
guard let data = value.data(using: .utf8) else { return false }
|
||||
|
||||
// Delete existing item first (SecItemUpdate is fiddly; delete+add is reliable)
|
||||
let deleteQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key
|
||||
]
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
|
||||
let addQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
|
||||
]
|
||||
|
||||
let status = SecItemAdd(addQuery as CFDictionary, nil)
|
||||
return status == errSecSuccess
|
||||
}
|
||||
|
||||
func get(key: String) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
|
||||
guard status == errSecSuccess, let data = item as? Data else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
func delete(key: String) -> Bool {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key
|
||||
]
|
||||
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
return status == errSecSuccess || status == errSecItemNotFound
|
||||
}
|
||||
}
|
||||
@@ -287,7 +287,7 @@ class StoreKitManager: ObservableObject {
|
||||
} else if let successResult = result as? ApiResultSuccess<VerificationResponse>,
|
||||
let response = successResult.data,
|
||||
!response.success {
|
||||
print("❌ StoreKit: Backend verification failed: \(response.error ?? "Unknown error")")
|
||||
print("❌ StoreKit: Backend verification failed: \(response.message)")
|
||||
}
|
||||
} catch {
|
||||
print("❌ StoreKit: Backend verification error: \(error)")
|
||||
|
||||
@@ -76,5 +76,6 @@ struct DynamicTaskColumnView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier("Task.Column.\(column.name)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ struct iOSApp: App {
|
||||
}
|
||||
|
||||
init() {
|
||||
// Set up Keychain delegate BEFORE DataManager initialization
|
||||
// so token reads/writes use Keychain instead of NSUserDefaults
|
||||
TokenManager.Companion.shared.keychainDelegate = KeychainHelper.shared
|
||||
|
||||
// Initialize DataManager with platform-specific managers
|
||||
// This must be done before any other operations that access DataManager
|
||||
DataManager.shared.initialize(
|
||||
|
||||
Reference in New Issue
Block a user