diff --git a/iosApp/HoneyDueTests/DataManagerExtendedTests.swift b/iosApp/HoneyDueTests/DataManagerExtendedTests.swift index 6c28565..2f49404 100644 --- a/iosApp/HoneyDueTests/DataManagerExtendedTests.swift +++ b/iosApp/HoneyDueTests/DataManagerExtendedTests.swift @@ -42,7 +42,9 @@ extension DataLayerTests { iconAndroid: "", tags: tags, displayOrder: 0, - isActive: true + isActive: true, + regionId: nil, + regionName: nil ) } diff --git a/iosApp/HoneyDueUITests/Framework/AuthenticatedUITestCase.swift b/iosApp/HoneyDueUITests/Framework/AuthenticatedUITestCase.swift index fce5b67..bf5aaaa 100644 --- a/iosApp/HoneyDueUITests/Framework/AuthenticatedUITestCase.swift +++ b/iosApp/HoneyDueUITests/Framework/AuthenticatedUITestCase.swift @@ -15,8 +15,6 @@ class AuthenticatedUITestCase: BaseUITestCase { ("admin", "test1234") } - override var includeResetStateLaunchArgument: Bool { false } - // MARK: - API Session private(set) var session: TestSession! @@ -24,11 +22,21 @@ class AuthenticatedUITestCase: BaseUITestCase { // MARK: - Lifecycle + override class func setUp() { + super.setUp() + guard TestAccountAPIClient.isBackendReachable() else { return } + // Ensure both known test accounts exist (covers all subclass credential overrides) + if TestAccountAPIClient.login(username: "testuser", password: "TestPass123!") == nil { + _ = TestAccountAPIClient.createVerifiedAccount(username: "testuser", email: "testuser@honeydue.com", password: "TestPass123!") + } + if TestAccountAPIClient.login(username: "admin", password: "test1234") == nil { + _ = TestAccountAPIClient.createVerifiedAccount(username: "admin", email: "admin@honeydue.com", password: "test1234") + } + } + override func setUpWithError() throws { - if needsAPISession { - guard TestAccountAPIClient.isBackendReachable() else { - throw XCTSkip("Backend not reachable at \(TestAccountAPIClient.baseURL)") - } + guard TestAccountAPIClient.isBackendReachable() else { + throw XCTSkip("Backend not reachable at \(TestAccountAPIClient.baseURL)") } try super.setUpWithError() diff --git a/iosApp/HoneyDueUITests/Framework/BaseUITestCase.swift b/iosApp/HoneyDueUITests/Framework/BaseUITestCase.swift index f8d7f24..b414387 100644 --- a/iosApp/HoneyDueUITests/Framework/BaseUITestCase.swift +++ b/iosApp/HoneyDueUITests/Framework/BaseUITestCase.swift @@ -191,19 +191,35 @@ extension XCUIElement { // SecureTextFields may trigger iOS strong password suggestion dialog // which blocks the regular keyboard. Handle them with a dedicated path. if elementType == .secureTextField { + // Dismiss any open keyboard first — iOS 26 fails to transfer focus + // from a TextField to a SecureTextField if the keyboard is already up. + if app.keyboards.firstMatch.exists { + let navBar = app.navigationBars.firstMatch + if navBar.exists { + navBar.tap() + } + _ = app.keyboards.firstMatch.waitForNonExistence(timeout: 2) + } + tap() // Dismiss "Choose My Own Password" or "Not Now" if iOS suggests a strong password let chooseOwn = app.buttons["Choose My Own Password"] - if chooseOwn.waitForExistence(timeout: 1) { + if chooseOwn.waitForExistence(timeout: 0.5) { chooseOwn.tap() } else { let notNow = app.buttons["Not Now"] if notNow.exists && notNow.isHittable { notNow.tap() } } - if app.keyboards.firstMatch.waitForExistence(timeout: 2) { + // Wait for keyboard after tapping SecureTextField + if !app.keyboards.firstMatch.waitForExistence(timeout: 5) { + // Retry tap — first tap may not have acquired focus + tap() + _ = app.keyboards.firstMatch.waitForExistence(timeout: 3) + } + if app.keyboards.firstMatch.exists { typeText(text) } else { - app.typeText(text) + XCTFail("Keyboard did not appear after tapping SecureTextField: \(self)", file: file, line: line) } return } diff --git a/iosApp/HoneyDueUITests/Framework/ScreenObjects.swift b/iosApp/HoneyDueUITests/Framework/ScreenObjects.swift index a74eded..65d7a65 100644 --- a/iosApp/HoneyDueUITests/Framework/ScreenObjects.swift +++ b/iosApp/HoneyDueUITests/Framework/ScreenObjects.swift @@ -257,8 +257,6 @@ struct RegisterScreenObject { private var usernameField: XCUIElement { app.textFields[UITestID.Auth.registerUsernameField] } private var emailField: XCUIElement { app.textFields[UITestID.Auth.registerEmailField] } - private var passwordField: XCUIElement { app.secureTextFields[UITestID.Auth.registerPasswordField] } - private var confirmPasswordField: XCUIElement { app.secureTextFields[UITestID.Auth.registerConfirmPasswordField] } private var registerButton: XCUIElement { app.buttons[UITestID.Auth.registerButton] } private var cancelButton: XCUIElement { app.buttons[UITestID.Auth.registerCancelButton] } @@ -268,30 +266,32 @@ struct RegisterScreenObject { } func fill(username: String, email: String, password: String) { - func advanceToNextField() { - let keys = ["Next", "Return", "return", "Done", "done"] - for key in keys { - let button = app.keyboards.buttons[key] - if button.waitForExistence(timeout: 1) && button.isHittable { - button.tap() - return - } + // iOS 26 bug: SecureTextField won't gain keyboard focus when tapped directly. + // Workaround: toggle password visibility first to convert SecureField → TextField, + // then use focusAndType() on all regular TextFields. + usernameField.waitForExistenceOrFail(timeout: 10) + + // Scroll down to reveal the password toggle buttons (they're below the fold) + let scrollView = app.scrollViews.firstMatch + if scrollView.exists { scrollView.swipeUp() } + + // Toggle both password visibility buttons (converts SecureField → TextField) + let toggleButtons = app.buttons.matching(NSPredicate(format: "label == 'Toggle password visibility'")) + for i in 0.. XCUIElement { @@ -248,9 +262,10 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase { } for (index, specialty) in specialties.enumerated() { + navigateToContractors() let contractorName = "\(specialty) Expert \(timestamp)_\(index)" let contractor = findContractor(name: contractorName) - XCTAssertTrue(contractor.exists, "\(specialty) contractor should exist in list") + XCTAssertTrue(contractor.waitForExistence(timeout: 10), "\(specialty) contractor should exist in list") } } @@ -290,9 +305,10 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase { } for (index, (_, format)) in phoneFormats.enumerated() { + navigateToContractors() let contractorName = "\(format) Phone \(timestamp)_\(index)" let contractor = findContractor(name: contractorName) - XCTAssertTrue(contractor.exists, "Contractor with \(format) phone should exist") + XCTAssertTrue(contractor.waitForExistence(timeout: 10), "Contractor with \(format) phone should exist") } } diff --git a/iosApp/ParallelTests.xctestplan b/iosApp/ParallelTests.xctestplan index dc5532e..697fb9c 100644 --- a/iosApp/ParallelTests.xctestplan +++ b/iosApp/ParallelTests.xctestplan @@ -23,10 +23,10 @@ "NavigationCriticalPathTests", "SmokeTests", "SimpleLoginTest", - "Suite0_OnboardingTests", + "Suite0_OnboardingRebuildTests", "Suite1_RegistrationTests", - "Suite2_AuthenticationTests", - "Suite3_ResidenceTests", + "Suite2_AuthenticationRebuildTests", + "Suite3_ResidenceRebuildTests", "Suite4_ComprehensiveResidenceTests", "Suite5_TaskTests", "Suite6_ComprehensiveTaskTests", diff --git a/iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift b/iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift index c46e121..7eee095 100644 --- a/iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift +++ b/iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift @@ -22,11 +22,13 @@ struct AccessibilityIdentifiers { static let registerConfirmPasswordField = "Register.ConfirmPasswordField" static let registerButton = "Register.RegisterButton" static let registerCancelButton = "Register.CancelButton" + static let registerErrorMessage = "Register.ErrorMessage" // Verification static let verificationCodeField = "Verification.CodeField" static let verifyButton = "Verification.VerifyButton" static let resendCodeButton = "Verification.ResendButton" + static let verificationLogoutButton = "Verification.LogoutButton" } // MARK: - Navigation diff --git a/iosApp/iosApp/Helpers/UITestRuntime.swift b/iosApp/iosApp/Helpers/UITestRuntime.swift index 5bf8626..6082cba 100644 --- a/iosApp/iosApp/Helpers/UITestRuntime.swift +++ b/iosApp/iosApp/Helpers/UITestRuntime.swift @@ -56,11 +56,13 @@ enum UITestRuntime { DataManager.shared.clear() OnboardingState.shared.reset() ThemeManager.shared.currentTheme = .bright + UserDefaults.standard.removeObject(forKey: "ui_test_user_verified") - // Re-apply onboarding completion after reset so tests that need - // both --reset-state and --complete-onboarding work correctly. + // Re-apply onboarding completion after reset. Set the flag directly + // because completeOnboarding() has an auth guard that fails here + // (DataManager was just cleared, so isAuthenticated is false). if shouldCompleteOnboarding { - OnboardingState.shared.completeOnboarding() + OnboardingState.shared.hasCompletedOnboarding = true } } diff --git a/iosApp/iosApp/Login/LoginView.swift b/iosApp/iosApp/Login/LoginView.swift index fe36d5f..6b8fd40 100644 --- a/iosApp/iosApp/Login/LoginView.swift +++ b/iosApp/iosApp/Login/LoginView.swift @@ -6,6 +6,7 @@ struct LoginView: View { @StateObject private var appleSignInViewModel = AppleSignInViewModel() @FocusState private var focusedField: Field? @State private var showingRegister = false + @State private var registrationVerified = false @State private var showVerification = false @State private var showPasswordReset = false @State private var isPasswordVisible = false @@ -314,8 +315,18 @@ struct LoginView: View { } ) } - .sheet(isPresented: $showingRegister) { - RegisterView() + .sheet(isPresented: $showingRegister, onDismiss: { + // Sheet is fully removed from the UIKit presentation stack. + // Set auth state now that no UIKit presentations block the + // RootView hierarchy swap. + if registrationVerified { + registrationVerified = false + AuthenticationManager.shared.login(verified: true) + } + }) { + RegisterView(isPresented: $showingRegister, onVerified: { + registrationVerified = true + }) } .sheet(isPresented: $showPasswordReset) { PasswordResetFlow(resetToken: activeResetToken, onLoginSuccess: { isVerified in diff --git a/iosApp/iosApp/Register/RegisterView.swift b/iosApp/iosApp/Register/RegisterView.swift index 42e2be4..e4a04b2 100644 --- a/iosApp/iosApp/Register/RegisterView.swift +++ b/iosApp/iosApp/Register/RegisterView.swift @@ -2,12 +2,13 @@ import SwiftUI import ComposeApp struct RegisterView: View { + @Binding var isPresented: Bool + var onVerified: (() -> Void)? @StateObject private var viewModel = RegisterViewModel() - @Environment(\.dismiss) var dismiss @FocusState private var focusedField: Field? - @State private var showVerifyEmail = false @State private var isPasswordVisible = false @State private var isConfirmPasswordVisible = false + @State private var verificationCompleted = false enum Field { case username, email, password, confirmPassword @@ -120,7 +121,7 @@ struct RegisterView: View { accessibilityId: AccessibilityIdentifiers.Authentication.registerPasswordField ) .focused($focusedField, equals: .password) - .textContentType(.newPassword) + .textContentType(UITestRuntime.isEnabled ? nil : .newPassword) .submitLabel(.next) .onSubmit { focusedField = .confirmPassword } @@ -134,7 +135,7 @@ struct RegisterView: View { accessibilityId: AccessibilityIdentifiers.Authentication.registerConfirmPasswordField ) .focused($focusedField, equals: .confirmPassword) - .textContentType(.newPassword) + .textContentType(UITestRuntime.isEnabled ? nil : .newPassword) .submitLabel(.go) .onSubmit { viewModel.register() } @@ -172,6 +173,7 @@ struct RegisterView: View { .padding(16) .background(Color.appError.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerErrorMessage) } // Register Button @@ -211,7 +213,7 @@ struct RegisterView: View { .foregroundColor(Color.appTextSecondary) Button(L10n.Auth.signIn) { - dismiss() + isPresented = false } .font(.system(size: 15, weight: .bold, design: .rounded)) .foregroundColor(Color.appPrimary) @@ -231,7 +233,7 @@ struct RegisterView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { - Button(action: { dismiss() }) { + Button(action: { isPresented = false }) { Image(systemName: "xmark") .font(.system(size: 14, weight: .semibold)) .foregroundColor(Color.appTextSecondary) @@ -242,16 +244,24 @@ struct RegisterView: View { .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerCancelButton) } } - .fullScreenCover(isPresented: $viewModel.isRegistered) { + .fullScreenCover(isPresented: $viewModel.isRegistered, onDismiss: { + // fullScreenCover is fully removed from the UIKit presentation stack. + // Now safe to dismiss the RegisterView sheet. Auth state is set in + // LoginView's sheet onDismiss after this sheet also finishes dismissing. + if verificationCompleted { + onVerified?() + isPresented = false + } + }) { VerifyEmailView( onVerifySuccess: { - AuthenticationManager.shared.markVerified() - showVerifyEmail = false - dismiss() + verificationCompleted = true + viewModel.isRegistered = false }, onLogout: { AuthenticationManager.shared.logout() - dismiss() + viewModel.isRegistered = false + isPresented = false } ) } @@ -418,5 +428,5 @@ private struct OrganicFormBackground: View { } #Preview { - RegisterView() + RegisterView(isPresented: .constant(true)) } diff --git a/iosApp/iosApp/Register/RegisterViewModel.swift b/iosApp/iosApp/Register/RegisterViewModel.swift index 337a76d..4ab799a 100644 --- a/iosApp/iosApp/Register/RegisterViewModel.swift +++ b/iosApp/iosApp/Register/RegisterViewModel.swift @@ -67,8 +67,11 @@ class RegisterViewModel: ObservableObject { // Track successful registration AnalyticsManager.shared.track(.userRegistered(method: "email")) - // Update AuthenticationManager - user is authenticated but NOT verified - AuthenticationManager.shared.login(verified: false) + // Auth state is set AFTER sheets dismiss (via LoginView's + // sheet onDismiss callback). Setting isAuthenticated here while + // the RegisterView sheet is still presented would cause RootView + // to swap LoginView→MainTabView behind the UIKit sheet, leaving + // a stale view hierarchy. self.isRegistered = true self.isLoading = false diff --git a/iosApp/iosApp/VerifyEmail/VerifyEmailView.swift b/iosApp/iosApp/VerifyEmail/VerifyEmailView.swift index 8d4eb30..c400fb1 100644 --- a/iosApp/iosApp/VerifyEmail/VerifyEmailView.swift +++ b/iosApp/iosApp/VerifyEmail/VerifyEmailView.swift @@ -197,6 +197,7 @@ struct VerifyEmailView: View { .background(Color.appBackgroundSecondary.opacity(0.8)) .clipShape(Capsule()) } + .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.verificationLogoutButton) } } .onAppear { diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 4deb5fb..190e21d 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -44,8 +44,10 @@ struct iOSApp: App { if UITestRuntime.isEnabled && UITestRuntime.shouldResetState { DataManager.shared.clear() OnboardingState.shared.reset() + // Set flag directly — completeOnboarding() has an auth guard that + // fails here because DataManager was just cleared (no token). if UITestRuntime.shouldCompleteOnboarding { - OnboardingState.shared.completeOnboarding() + OnboardingState.shared.hasCompletedOnboarding = true } } diff --git a/iosApp/run_ui_tests.sh b/iosApp/run_ui_tests.sh index e3e343e..f2983f8 100755 --- a/iosApp/run_ui_tests.sh +++ b/iosApp/run_ui_tests.sh @@ -67,10 +67,10 @@ PARALLEL_TESTS=( "-only-testing:HoneyDueUITests/NavigationCriticalPathTests" "-only-testing:HoneyDueUITests/SmokeTests" "-only-testing:HoneyDueUITests/SimpleLoginTest" - "-only-testing:HoneyDueUITests/Suite0_OnboardingTests" + "-only-testing:HoneyDueUITests/Suite0_OnboardingRebuildTests" "-only-testing:HoneyDueUITests/Suite1_RegistrationTests" - "-only-testing:HoneyDueUITests/Suite2_AuthenticationTests" - "-only-testing:HoneyDueUITests/Suite3_ResidenceTests" + "-only-testing:HoneyDueUITests/Suite2_AuthenticationRebuildTests" + "-only-testing:HoneyDueUITests/Suite3_ResidenceRebuildTests" "-only-testing:HoneyDueUITests/Suite4_ComprehensiveResidenceTests" "-only-testing:HoneyDueUITests/Suite5_TaskTests" "-only-testing:HoneyDueUITests/Suite6_ComprehensiveTaskTests"