Re-architect iOS XCUITest suite: per-test isolation + domain organization
Migrate the XCUITest suite off the legacy shared-account model (and the prior Django-style auth assumptions) to a parallel-safe, domain-organized architecture, validated end-to-end against the live Kratos stack. Isolation (parallel-safe by construction): - Core/Fixtures/TestAccount.swift: each test mints its own pre-verified Kratos identity (uit_<domain>_<uuid>@test.honeydue.local), logs in, seeds under its own token, and deletes the identity in teardown (cascading all data + clearing Kratos). No shared testuser; parallel workers no longer race. - AuthenticatedUITestCase rewritten to that model (member surface preserved); adds requiresResidence / seedAccountPreconditions to seed UI-gated data BEFORE login (a fresh account is empty at login). Organization (255 tests preserved, none dropped): - 21 domain suites under Auth/ Onboarding/ Residence/ Task/ Contractor/ Document/ Sharing/ Navigation/ Smoke/ CrossCutting/ E2E/, consistent <Domain>UITests naming. Removes the Suite1..11 / AAA_ / ZZ_ / Tests/Rebuild naming chaos and the overlapping task/residence/auth suites. Runner + test plans: - run_ui_tests.sh: Smoke gate -> Seed -> Parallel(8 workers) -> Sweep. The parallel phase runs the whole target minus phase-managed suites via -skip-testing, so new suites auto-include (no hand-maintained list to drift). Drops the 2-worker cap and Suite6 isolation (isolation made them moot). - HoneyDueUITests.xctestplan skips the 4 phase-managed suites; adds Smoke.xctestplan. Kratos auth fixes folded in (login/verify/reset endpoints removed under Kratos): real Mailpit verification codes replace the obsolete fixed "123456"; teardown deletes Kratos identities; admin-panel login uses the correct seeded password. Build green; isolation, parallelism, and the precondition/sharing migrations validated against the live stack (0 leaked accounts). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,411 @@
|
||||
import XCTest
|
||||
|
||||
/// Consolidated login authentication UI tests.
|
||||
///
|
||||
/// Merged from four legacy suites:
|
||||
/// - SimpleLoginTest (basic login screen smoke tests)
|
||||
/// - AuthCriticalPathTests (critical-path login / logout / entry navigation)
|
||||
/// - AuthenticationTests (F201–F209 login-screen element + navigation checks)
|
||||
/// - Suite2_AuthenticationRebuildTests (R201–R206 valid-credential landing / logout)
|
||||
///
|
||||
/// Logged-OUT suite: extends `BaseUITestCase` (no auth). Each test drives the
|
||||
/// login / registration-entry / forgot-password screens itself via the existing
|
||||
/// page objects and helpers (LoginScreenObject, UITestHelpers, TestFlows, …).
|
||||
final class AuthLoginUITests: BaseUITestCase {
|
||||
// Merged override: SimpleLogin and Suite2 both disabled reset-state; the
|
||||
// other two relied on the default. Disabling reset-state is the safe union
|
||||
// because every test here re-establishes its own starting screen state.
|
||||
override var includeResetStateLaunchArgument: Bool { false }
|
||||
// AuthCriticalPath, Authentication, and Suite2 all relaunched between tests.
|
||||
override var relaunchBetweenTests: Bool { true }
|
||||
// AuthCriticalPath, Authentication, and Suite2 all booted with onboarding
|
||||
// completed so a successful login lands on the main tabs (a freshly-seeded
|
||||
// user has no residence; without this it routes to onboarding).
|
||||
override var completeOnboarding: Bool { true }
|
||||
|
||||
private let validUser = RebuildTestUserFactory.seeded
|
||||
|
||||
/// The seeded user's Kratos login identifier. Kratos keys honeyDue
|
||||
/// identities by EMAIL, and the app sends the typed identifier straight to
|
||||
/// Kratos (AuthApi.login), so login must use the email — not the bare
|
||||
/// display username.
|
||||
private let seededLoginIdentifier = "testuser@honeydue.com"
|
||||
|
||||
private enum AuthLandingState {
|
||||
case main
|
||||
case verification
|
||||
}
|
||||
|
||||
override func setUpWithError() throws {
|
||||
// Force a clean app launch so no stale field text persists between tests
|
||||
// (union of SimpleLogin + Suite2 setUp behavior).
|
||||
app.terminate()
|
||||
try super.setUpWithError()
|
||||
|
||||
// CRITICAL: Ensure we're logged out before each test
|
||||
UITestHelpers.ensureLoggedOut(app: app)
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
try super.tearDownWithError()
|
||||
}
|
||||
|
||||
// MARK: - Helpers (from SimpleLoginTest)
|
||||
|
||||
/// Ensures the user is logged out and on the login screen
|
||||
private func ensureLoggedOut() {
|
||||
UITestHelpers.ensureLoggedOut(app: app)
|
||||
}
|
||||
|
||||
// MARK: - Helpers (from AuthCriticalPathTests)
|
||||
|
||||
private func navigateToLogin() {
|
||||
let loginField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
||||
if loginField.waitForExistence(timeout: defaultTimeout) { return }
|
||||
|
||||
// On onboarding — tap login button
|
||||
let onboardingLogin = app.buttons[AccessibilityIdentifiers.Onboarding.loginButton]
|
||||
if onboardingLogin.waitForExistence(timeout: navigationTimeout) {
|
||||
onboardingLogin.tap()
|
||||
}
|
||||
|
||||
loginField.waitForExistenceOrFail(timeout: navigationTimeout, message: "Login screen should appear")
|
||||
}
|
||||
|
||||
private func loginAsTestUser() {
|
||||
navigateToLogin()
|
||||
|
||||
let login = LoginScreenObject(app: app)
|
||||
// Kratos uses the EMAIL as the login identifier (no username trait).
|
||||
login.enterUsername("testuser@honeydue.com")
|
||||
login.enterPassword("TestPass123!")
|
||||
app.buttons[AccessibilityIdentifiers.Authentication.loginButton].tap()
|
||||
|
||||
// Wait for main app or verification gate
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
let verification = VerificationScreen(app: app)
|
||||
|
||||
let deadline = Date().addingTimeInterval(loginTimeout)
|
||||
while Date() < deadline {
|
||||
if tabBar.exists { return }
|
||||
if verification.codeField.exists {
|
||||
verification.enterCode(TestAccountAPIClient.debugVerificationCode)
|
||||
verification.submitCode()
|
||||
_ = tabBar.waitForExistence(timeout: loginTimeout)
|
||||
return
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||
}
|
||||
|
||||
XCTAssertTrue(tabBar.exists, "Should reach main app after login")
|
||||
}
|
||||
|
||||
// MARK: - Helpers (from Suite2_AuthenticationRebuildTests)
|
||||
|
||||
private func loginFromLoginScreen(user: RebuildTestUser = RebuildTestUserFactory.seeded) {
|
||||
UITestHelpers.ensureOnLoginScreen(app: app)
|
||||
let login = LoginScreenObject(app: app)
|
||||
login.waitForLoad(timeout: defaultTimeout)
|
||||
login.enterUsername(seededLoginIdentifier)
|
||||
login.enterPassword(user.password)
|
||||
|
||||
let loginButton = app.buttons[AccessibilityIdentifiers.Authentication.loginButton]
|
||||
loginButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
loginButton.forceTap()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func loginAndWaitForAuthenticatedLanding(user: RebuildTestUser = RebuildTestUserFactory.seeded) -> AuthLandingState {
|
||||
loginFromLoginScreen(user: user)
|
||||
|
||||
let mainRoot = app.otherElements[UITestID.Root.mainTabs]
|
||||
if mainRoot.waitForExistence(timeout: loginTimeout) || app.tabBars.firstMatch.waitForExistence(timeout: 2) {
|
||||
return .main
|
||||
}
|
||||
|
||||
let verification = VerificationScreen(app: app)
|
||||
if verification.codeField.waitForExistence(timeout: defaultTimeout) || verification.verifyButton.waitForExistence(timeout: 2) {
|
||||
return .verification
|
||||
}
|
||||
|
||||
XCTFail("Expected authenticated landing on main tabs or verification screen")
|
||||
return .verification
|
||||
}
|
||||
|
||||
private func logoutFromVerificationIfNeeded() {
|
||||
let verification = VerificationScreen(app: app)
|
||||
verification.waitForLoad(timeout: defaultTimeout)
|
||||
verification.tapLogoutIfAvailable()
|
||||
|
||||
let toolbarLogout = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout' OR label CONTAINS[c] 'Log Out'")).firstMatch
|
||||
if toolbarLogout.waitForExistence(timeout: 3) {
|
||||
toolbarLogout.forceTap()
|
||||
}
|
||||
}
|
||||
|
||||
private func logoutFromMainApp() {
|
||||
UITestHelpers.logout(app: app)
|
||||
}
|
||||
|
||||
// MARK: - Tests (from SimpleLoginTest)
|
||||
|
||||
/// Test 1: App launches and shows login screen (or logs out if needed)
|
||||
func testAppLaunchesAndShowsLoginScreen() {
|
||||
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
||||
XCTAssertTrue(usernameField.exists, "Username field should be visible on login screen after logout")
|
||||
}
|
||||
|
||||
/// Test 2: Can type in username and password fields
|
||||
func testCanTypeInLoginFields() {
|
||||
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
||||
usernameField.waitForExistenceOrFail(timeout: defaultTimeout, message: "Username field should exist on login screen")
|
||||
usernameField.focusAndType("testuser", app: app)
|
||||
|
||||
let passwordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.passwordField].exists
|
||||
? app.secureTextFields[AccessibilityIdentifiers.Authentication.passwordField]
|
||||
: app.textFields[AccessibilityIdentifiers.Authentication.passwordField]
|
||||
XCTAssertTrue(passwordField.exists, "Password field should exist on login screen")
|
||||
passwordField.focusAndType("testpass123", app: app)
|
||||
|
||||
let signInButton = app.buttons[AccessibilityIdentifiers.Authentication.loginButton]
|
||||
XCTAssertTrue(signInButton.exists, "Login button should exist on login screen")
|
||||
}
|
||||
|
||||
// MARK: - Tests (from AuthCriticalPathTests)
|
||||
|
||||
// MARK: Login
|
||||
|
||||
func testLoginWithValidCredentials() {
|
||||
loginAsTestUser()
|
||||
XCTAssertTrue(app.tabBars.firstMatch.exists, "Tab bar should be visible after login")
|
||||
}
|
||||
|
||||
func testLoginWithInvalidCredentials() {
|
||||
navigateToLogin()
|
||||
|
||||
let login = LoginScreenObject(app: app)
|
||||
login.enterUsername("invaliduser")
|
||||
login.enterPassword("wrongpassword")
|
||||
app.buttons[AccessibilityIdentifiers.Authentication.loginButton].tap()
|
||||
|
||||
// Should stay on login screen
|
||||
let loginField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
||||
XCTAssertTrue(loginField.waitForExistence(timeout: navigationTimeout), "Should remain on login screen after invalid credentials")
|
||||
XCTAssertFalse(app.tabBars.firstMatch.exists, "Tab bar should not appear after failed login")
|
||||
}
|
||||
|
||||
// MARK: Logout
|
||||
|
||||
func testLogoutFlow() {
|
||||
loginAsTestUser()
|
||||
UITestHelpers.logout(app: app)
|
||||
|
||||
let loginField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
||||
let onboardingLogin = app.buttons[AccessibilityIdentifiers.Onboarding.loginButton]
|
||||
let loggedOut = loginField.waitForExistence(timeout: loginTimeout)
|
||||
|| onboardingLogin.waitForExistence(timeout: navigationTimeout)
|
||||
XCTAssertTrue(loggedOut, "Should return to login or onboarding after logout")
|
||||
}
|
||||
|
||||
// MARK: Registration Entry
|
||||
|
||||
func testSignUpButtonNavigatesToRegistration() {
|
||||
navigateToLogin()
|
||||
app.buttons[AccessibilityIdentifiers.Authentication.signUpButton].tap()
|
||||
|
||||
let registerUsername = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
|
||||
XCTAssertTrue(registerUsername.waitForExistence(timeout: navigationTimeout), "Registration form should appear")
|
||||
}
|
||||
|
||||
// MARK: Forgot Password
|
||||
|
||||
func testForgotPasswordButtonExists() {
|
||||
navigateToLogin()
|
||||
let forgotButton = app.buttons[AccessibilityIdentifiers.Authentication.forgotPasswordButton]
|
||||
XCTAssertTrue(forgotButton.waitForExistence(timeout: defaultTimeout), "Forgot password button should exist")
|
||||
}
|
||||
|
||||
// MARK: - Tests (from AuthenticationTests)
|
||||
|
||||
func testF201_OnboardingLoginEntryShowsLoginScreen() {
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.waitForLoad(timeout: defaultTimeout)
|
||||
}
|
||||
|
||||
func testF202_LoginScreenCanTogglePasswordVisibility() {
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.enterUsername("u")
|
||||
login.enterPassword("p")
|
||||
login.tapPasswordVisibilityToggle()
|
||||
login.assertPasswordFieldVisible()
|
||||
}
|
||||
|
||||
func testF203_RegisterSheetCanOpenAndDismiss() {
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.waitForLoad(timeout: defaultTimeout)
|
||||
login.tapSignUp()
|
||||
|
||||
let register = RegisterScreenObject(app: app)
|
||||
register.waitForLoad(timeout: navigationTimeout)
|
||||
register.tapCancel()
|
||||
|
||||
login.waitForLoad(timeout: navigationTimeout)
|
||||
}
|
||||
|
||||
func testF204_RegisterFormAcceptsInput() {
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.waitForLoad(timeout: defaultTimeout)
|
||||
login.tapSignUp()
|
||||
|
||||
let register = RegisterScreenObject(app: app)
|
||||
register.waitForLoad(timeout: navigationTimeout)
|
||||
|
||||
XCTAssertTrue(app.buttons[UITestID.Auth.registerButton].exists, "Register button should exist on register form")
|
||||
}
|
||||
|
||||
func testF205_LoginButtonDisabledWhenCredentialsAreEmpty() {
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
let loginButton = app.buttons[UITestID.Auth.loginButton]
|
||||
loginButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
XCTAssertFalse(loginButton.isEnabled, "Login button should be disabled when username/password are empty")
|
||||
}
|
||||
|
||||
func testF206_ForgotPasswordButtonIsAccessible() {
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
let forgotButton = app.buttons[UITestID.Auth.forgotPasswordButton]
|
||||
forgotButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
XCTAssertTrue(forgotButton.isHittable, "Forgot password button should be hittable on login screen")
|
||||
}
|
||||
|
||||
func testF207_LoginScreenShowsAllExpectedElements() {
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
XCTAssertTrue(app.textFields[UITestID.Auth.usernameField].exists, "Username field should exist")
|
||||
XCTAssertTrue(
|
||||
app.secureTextFields[UITestID.Auth.passwordField].exists || app.textFields[UITestID.Auth.passwordField].exists,
|
||||
"Password field should exist"
|
||||
)
|
||||
XCTAssertTrue(app.buttons[UITestID.Auth.loginButton].exists, "Login button should exist")
|
||||
XCTAssertTrue(app.buttons[UITestID.Auth.signUpButton].exists, "Sign up button should exist")
|
||||
XCTAssertTrue(app.buttons[UITestID.Auth.forgotPasswordButton].exists, "Forgot password button should exist")
|
||||
XCTAssertTrue(app.buttons[UITestID.Auth.passwordVisibilityToggle].exists, "Password visibility toggle should exist")
|
||||
}
|
||||
|
||||
func testF208_RegisterFormShowsAllRequiredFields() {
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.waitForLoad(timeout: defaultTimeout)
|
||||
login.tapSignUp()
|
||||
|
||||
let register = RegisterScreenObject(app: app)
|
||||
register.waitForLoad(timeout: navigationTimeout)
|
||||
|
||||
XCTAssertTrue(app.textFields[UITestID.Auth.registerUsernameField].exists, "Register username field should exist")
|
||||
XCTAssertTrue(app.textFields[UITestID.Auth.registerEmailField].exists, "Register email field should exist")
|
||||
XCTAssertTrue(app.secureTextFields[UITestID.Auth.registerPasswordField].exists, "Register password field should exist")
|
||||
XCTAssertTrue(app.secureTextFields[UITestID.Auth.registerConfirmPasswordField].exists, "Register confirm password field should exist")
|
||||
XCTAssertTrue(app.buttons[UITestID.Auth.registerButton].exists, "Register button should exist")
|
||||
XCTAssertTrue(app.buttons[UITestID.Auth.registerCancelButton].exists, "Register cancel button should exist")
|
||||
}
|
||||
|
||||
func testF209_ForgotPasswordNavigatesToResetFlow() {
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.waitForLoad(timeout: defaultTimeout)
|
||||
login.tapForgotPassword()
|
||||
|
||||
// Verify forgot password screen loaded by checking for its email field (accessibility ID, not label)
|
||||
let emailField = app.textFields[UITestID.PasswordReset.emailField]
|
||||
let sendCodeButton = app.buttons[UITestID.PasswordReset.sendCodeButton]
|
||||
let loaded = emailField.waitForExistence(timeout: navigationTimeout)
|
||||
|| sendCodeButton.waitForExistence(timeout: navigationTimeout)
|
||||
XCTAssertTrue(loaded, "Forgot password screen should appear with email field or send code button")
|
||||
}
|
||||
|
||||
// MARK: - Tests (from Suite2_AuthenticationRebuildTests)
|
||||
|
||||
func testR201_loginScreenLoadsFromOnboardingEntry() {
|
||||
UITestHelpers.ensureOnLoginScreen(app: app)
|
||||
let login = LoginScreenObject(app: app)
|
||||
login.waitForLoad(timeout: defaultTimeout)
|
||||
}
|
||||
|
||||
func testR202_validCredentialsSubmitFromLogin() {
|
||||
UITestHelpers.ensureOnLoginScreen(app: app)
|
||||
let login = LoginScreenObject(app: app)
|
||||
login.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
login.enterUsername(seededLoginIdentifier)
|
||||
login.enterPassword(validUser.password)
|
||||
|
||||
let loginButton = app.buttons[AccessibilityIdentifiers.Authentication.loginButton]
|
||||
XCTAssertTrue(loginButton.waitForExistence(timeout: defaultTimeout), "Login button must exist before submit")
|
||||
XCTAssertTrue(loginButton.isHittable, "Login button must be tappable")
|
||||
}
|
||||
|
||||
func testR203_validLoginTransitionsToMainAppRoot() {
|
||||
let landing = loginAndWaitForAuthenticatedLanding(user: validUser)
|
||||
switch landing {
|
||||
case .main:
|
||||
RebuildSessionAssertions.assertOnMainApp(app, timeout: loginTimeout)
|
||||
case .verification:
|
||||
RebuildSessionAssertions.assertOnVerification(app, timeout: loginTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func testR204_mainAppHasExpectedPrimaryTabsAfterLogin() {
|
||||
let landing = loginAndWaitForAuthenticatedLanding(user: validUser)
|
||||
|
||||
switch landing {
|
||||
case .main:
|
||||
RebuildSessionAssertions.assertOnMainApp(app, timeout: loginTimeout)
|
||||
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
if tabBar.waitForExistence(timeout: 5) {
|
||||
let residences = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
||||
let tasks = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
||||
let contractors = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
|
||||
let docs = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Doc'")).firstMatch
|
||||
XCTAssertTrue(residences.exists, "Residences tab should exist")
|
||||
XCTAssertTrue(tasks.exists, "Tasks tab should exist")
|
||||
XCTAssertTrue(contractors.exists, "Contractors tab should exist")
|
||||
XCTAssertTrue(docs.exists, "Documents tab should exist")
|
||||
} else {
|
||||
XCTAssertTrue(app.otherElements[UITestID.Root.mainTabs].exists, "Main tabs root should exist")
|
||||
}
|
||||
case .verification:
|
||||
let verify = VerificationScreen(app: app)
|
||||
verify.waitForLoad(timeout: defaultTimeout)
|
||||
XCTAssertTrue(verify.codeField.exists, "Verification code field should exist for unverified accounts")
|
||||
}
|
||||
}
|
||||
|
||||
func testR205_logoutFromMainAppReturnsToLoginRoot() {
|
||||
let landing = loginAndWaitForAuthenticatedLanding(user: validUser)
|
||||
|
||||
switch landing {
|
||||
case .main:
|
||||
logoutFromMainApp()
|
||||
case .verification:
|
||||
logoutFromVerificationIfNeeded()
|
||||
}
|
||||
RebuildSessionAssertions.assertOnLogin(app, timeout: loginTimeout)
|
||||
}
|
||||
|
||||
func testR206_postLogoutMainAppIsNoLongerAccessible() {
|
||||
let landing = loginAndWaitForAuthenticatedLanding(user: validUser)
|
||||
|
||||
switch landing {
|
||||
case .main:
|
||||
logoutFromMainApp()
|
||||
case .verification:
|
||||
logoutFromVerificationIfNeeded()
|
||||
}
|
||||
RebuildSessionAssertions.assertOnLogin(app, timeout: loginTimeout)
|
||||
|
||||
XCTAssertFalse(app.otherElements[UITestID.Root.mainTabs].exists, "Main app root should not be visible after logout")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import XCTest
|
||||
|
||||
/// Tests for the password reset flow against the local stack.
|
||||
///
|
||||
/// The app's reset flow is wired to a real Kratos recovery flow: the
|
||||
/// forgot-password screen starts a Kratos recovery flow and submits the email
|
||||
/// (AuthApi.kt:406 `forgotPassword`), which makes Kratos EMAIL a 6-digit
|
||||
/// recovery code that lands in Mailpit locally. The verify screen submits that
|
||||
/// emailed code back to the same flow (AuthApi.kt:448 `verifyResetCode`). So
|
||||
/// these tests read the live code from Mailpit instead of any fixed code.
|
||||
///
|
||||
/// Test Plan IDs: AUTH-015, AUTH-016, AUTH-017
|
||||
final class AuthPasswordResetUITests: BaseUITestCase {
|
||||
override var relaunchBetweenTests: Bool { true }
|
||||
|
||||
private var testSession: TestSession?
|
||||
private var cleaner: TestDataCleaner?
|
||||
|
||||
override func setUpWithError() throws {
|
||||
guard TestAccountAPIClient.isBackendReachable() else {
|
||||
throw XCTSkip("Local backend is not reachable at \(TestAccountAPIClient.baseURL)")
|
||||
}
|
||||
|
||||
guard let session = TestAccountManager.createVerifiedAccount() else {
|
||||
throw XCTSkip("Could not create verified test account")
|
||||
}
|
||||
testSession = session
|
||||
cleaner = TestDataCleaner(token: session.token)
|
||||
|
||||
// Force clean app launch — password reset flow leaves complex screen state
|
||||
app.terminate()
|
||||
try super.setUpWithError()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
cleaner?.cleanAll()
|
||||
try super.tearDownWithError()
|
||||
}
|
||||
|
||||
// MARK: - AUTH-015: Verify reset code reaches new password screen
|
||||
|
||||
func testAUTH015_VerifyResetCodeSuccessPath() throws {
|
||||
let session = try XCTUnwrap(testSession)
|
||||
// Capture the recovery email ONCE and reuse it for both the request and
|
||||
// the Mailpit lookup, so the lookup address matches what we submitted.
|
||||
let email = session.user.email
|
||||
|
||||
// Navigate to forgot password
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.tapForgotPassword()
|
||||
|
||||
// Enter email and send code
|
||||
let forgotScreen = ForgotPasswordScreen(app: app)
|
||||
forgotScreen.waitForLoad()
|
||||
forgotScreen.enterEmail(email)
|
||||
forgotScreen.tapSendCode()
|
||||
|
||||
// Read the REAL Kratos recovery code Kratos emailed to Mailpit.
|
||||
let code = TestAccountAPIClient.latestVerificationCode(for: email) ?? ""
|
||||
XCTAssertFalse(code.isEmpty, "No Kratos recovery code arrived in Mailpit for \(email)")
|
||||
|
||||
let verifyScreen = VerifyResetCodeScreen(app: app)
|
||||
verifyScreen.waitForLoad()
|
||||
verifyScreen.enterCode(code)
|
||||
verifyScreen.tapVerify()
|
||||
|
||||
// Should reach the new password screen
|
||||
let resetScreen = ResetPasswordScreen(app: app)
|
||||
try resetScreen.waitForLoad(timeout: loginTimeout)
|
||||
}
|
||||
|
||||
// MARK: - AUTH-016: Full reset password cycle + login with new password
|
||||
|
||||
func testAUTH016_ResetPasswordSuccess() throws {
|
||||
let session = try XCTUnwrap(testSession)
|
||||
let newPassword = "NewPass9876!"
|
||||
// Capture the recovery email ONCE for both the request and Mailpit lookup.
|
||||
let email = session.user.email
|
||||
|
||||
// Navigate to forgot password
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.tapForgotPassword()
|
||||
|
||||
// Drive the full reset flow inline (NOT TestFlows.completeForgotPasswordFlow,
|
||||
// which hardcodes the obsolete debug code) so we submit the REAL Kratos
|
||||
// recovery code read from Mailpit.
|
||||
try completeForgotPasswordFlowWithRealCode(email: email, newPassword: newPassword)
|
||||
|
||||
// After reset, the app auto-logs in with the new password.
|
||||
// If auto-login succeeds → app goes directly to main tabs (sheet dismissed).
|
||||
// If auto-login fails → success message + "Return to Login" button appear.
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
let returnButton = app.buttons[UITestID.PasswordReset.returnToLoginButton]
|
||||
|
||||
let deadline = Date().addingTimeInterval(loginTimeout)
|
||||
var reachedPostReset = false
|
||||
while Date() < deadline {
|
||||
if tabBar.exists {
|
||||
// Auto-login succeeded — password reset worked!
|
||||
reachedPostReset = true
|
||||
break
|
||||
}
|
||||
if returnButton.exists {
|
||||
// Auto-login failed — manual login needed
|
||||
reachedPostReset = true
|
||||
returnButton.forceTap()
|
||||
break
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||
}
|
||||
|
||||
XCTAssertTrue(reachedPostReset, "Expected main tabs (auto-login) or return button (manual login) after password reset")
|
||||
|
||||
if tabBar.exists {
|
||||
// Already logged in via auto-login — test passed
|
||||
return
|
||||
}
|
||||
|
||||
// Manual login path: return button was tapped, now on login screen
|
||||
let loginScreen = LoginScreenObject(app: app)
|
||||
loginScreen.waitForLoad(timeout: loginTimeout)
|
||||
loginScreen.enterUsername(email) // Kratos login identifier is the EMAIL
|
||||
loginScreen.enterPassword(newPassword)
|
||||
|
||||
let loginButton = app.buttons[UITestID.Auth.loginButton]
|
||||
loginButton.waitUntilHittable(timeout: 10).tap()
|
||||
|
||||
XCTAssertTrue(tabBar.waitForExistence(timeout: loginTimeout), "Should login successfully with new password")
|
||||
}
|
||||
|
||||
// MARK: - AUTH-015 (alias): Verify reset code reaches the new password screen
|
||||
|
||||
func test03_verifyResetCodeSuccess() throws {
|
||||
try XCTSkipIf(!TestAccountAPIClient.isBackendReachable(), "Backend not reachable")
|
||||
|
||||
let session = try XCTUnwrap(testSession)
|
||||
// Capture the recovery email ONCE for both the request and Mailpit lookup.
|
||||
let email = session.user.email
|
||||
|
||||
// Navigate to forgot password
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.tapForgotPassword()
|
||||
|
||||
// Enter email and send the reset code
|
||||
let forgotScreen = ForgotPasswordScreen(app: app)
|
||||
forgotScreen.waitForLoad()
|
||||
forgotScreen.enterEmail(email)
|
||||
forgotScreen.tapSendCode()
|
||||
|
||||
// Read the REAL Kratos recovery code Kratos emailed to Mailpit.
|
||||
let code = TestAccountAPIClient.latestVerificationCode(for: email) ?? ""
|
||||
XCTAssertFalse(code.isEmpty, "No Kratos recovery code arrived in Mailpit for \(email)")
|
||||
|
||||
let verifyScreen = VerifyResetCodeScreen(app: app)
|
||||
verifyScreen.waitForLoad()
|
||||
verifyScreen.enterCode(code)
|
||||
verifyScreen.tapVerify()
|
||||
|
||||
// The reset password screen should now appear
|
||||
let resetScreen = ResetPasswordScreen(app: app)
|
||||
try resetScreen.waitForLoad(timeout: loginTimeout)
|
||||
}
|
||||
|
||||
// MARK: - AUTH-016 (alias): Full reset flow + login with new password
|
||||
|
||||
func test04_resetPasswordSuccessAndLogin() throws {
|
||||
try XCTSkipIf(!TestAccountAPIClient.isBackendReachable(), "Backend not reachable")
|
||||
|
||||
let session = try XCTUnwrap(testSession)
|
||||
let newPassword = "NewPass9876!"
|
||||
// Capture the recovery email ONCE for both the request and Mailpit lookup.
|
||||
let email = session.user.email
|
||||
|
||||
// Navigate to forgot password, then drive the complete 3-step reset flow
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.tapForgotPassword()
|
||||
|
||||
// Drive the full reset flow inline (NOT TestFlows.completeForgotPasswordFlow,
|
||||
// which hardcodes the obsolete debug code) so we submit the REAL Kratos
|
||||
// recovery code read from Mailpit.
|
||||
try completeForgotPasswordFlowWithRealCode(email: email, newPassword: newPassword)
|
||||
|
||||
// Wait for a success indication — either a success message or the return-to-login button
|
||||
let successText = app.staticTexts.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'success' OR label CONTAINS[c] 'reset'")
|
||||
).firstMatch
|
||||
let returnButton = app.buttons[UITestID.PasswordReset.returnToLoginButton]
|
||||
|
||||
// After reset, the app auto-logs in with the new password.
|
||||
// If auto-login succeeds → app goes to main tabs. If fails → return button appears.
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
|
||||
let deadline = Date().addingTimeInterval(loginTimeout)
|
||||
var reachedPostReset = false
|
||||
while Date() < deadline {
|
||||
if tabBar.exists {
|
||||
reachedPostReset = true
|
||||
break
|
||||
}
|
||||
if returnButton.exists {
|
||||
reachedPostReset = true
|
||||
returnButton.forceTap()
|
||||
break
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||
}
|
||||
|
||||
XCTAssertTrue(reachedPostReset, "Expected main tabs (auto-login) or return button after password reset")
|
||||
|
||||
if tabBar.exists { return }
|
||||
|
||||
// Manual login fallback
|
||||
let loginScreen = LoginScreenObject(app: app)
|
||||
loginScreen.waitForLoad(timeout: loginTimeout)
|
||||
loginScreen.enterUsername(email) // Kratos login identifier is the EMAIL
|
||||
loginScreen.enterPassword(newPassword)
|
||||
|
||||
let loginButton = app.buttons[UITestID.Auth.loginButton]
|
||||
loginButton.waitUntilHittable(timeout: 10).tap()
|
||||
|
||||
XCTAssertTrue(tabBar.waitForExistence(timeout: loginTimeout), "Should login successfully with new password")
|
||||
}
|
||||
|
||||
// MARK: - AUTH-017: Mismatched passwords are blocked
|
||||
|
||||
func testAUTH017_MismatchedPasswordBlocked() throws {
|
||||
let session = try XCTUnwrap(testSession)
|
||||
// Capture the recovery email ONCE for both the request and Mailpit lookup.
|
||||
let email = session.user.email
|
||||
|
||||
// Navigate to forgot password
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.tapForgotPassword()
|
||||
|
||||
// Get to the reset password screen
|
||||
let forgotScreen = ForgotPasswordScreen(app: app)
|
||||
forgotScreen.waitForLoad()
|
||||
forgotScreen.enterEmail(email)
|
||||
forgotScreen.tapSendCode()
|
||||
|
||||
// Read the REAL Kratos recovery code Kratos emailed to Mailpit.
|
||||
let code = TestAccountAPIClient.latestVerificationCode(for: email) ?? ""
|
||||
XCTAssertFalse(code.isEmpty, "No Kratos recovery code arrived in Mailpit for \(email)")
|
||||
|
||||
let verifyScreen = VerifyResetCodeScreen(app: app)
|
||||
verifyScreen.waitForLoad()
|
||||
verifyScreen.enterCode(code)
|
||||
verifyScreen.tapVerify()
|
||||
|
||||
// Enter mismatched passwords
|
||||
let resetScreen = ResetPasswordScreen(app: app)
|
||||
try resetScreen.waitForLoad(timeout: loginTimeout)
|
||||
resetScreen.enterNewPassword("ValidPass123!")
|
||||
resetScreen.enterConfirmPassword("DifferentPass456!")
|
||||
|
||||
// The reset button should be disabled when passwords don't match
|
||||
XCTAssertFalse(resetScreen.isResetButtonEnabled, "Reset button should be disabled when passwords don't match")
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Drive the full forgot-password → verify-code → reset-password UI flow using
|
||||
/// the REAL Kratos recovery code read from Mailpit.
|
||||
///
|
||||
/// This is a local replacement for `TestFlows.completeForgotPasswordFlow`, which
|
||||
/// still hardcodes the obsolete `debugVerificationCode` ("123456"). The caller is
|
||||
/// expected to have already reached the forgot-password screen (e.g. via
|
||||
/// `login.tapForgotPassword()`). The `email` MUST be the same captured value used
|
||||
/// elsewhere in the test so the Mailpit lookup matches the address submitted.
|
||||
private func completeForgotPasswordFlowWithRealCode(email: String, newPassword: String) throws {
|
||||
// Step 1: Enter email and request the recovery code.
|
||||
let forgotScreen = ForgotPasswordScreen(app: app)
|
||||
forgotScreen.waitForLoad()
|
||||
forgotScreen.enterEmail(email)
|
||||
forgotScreen.tapSendCode()
|
||||
|
||||
// Step 2: Read the REAL Kratos recovery code Kratos emailed to Mailpit.
|
||||
let code = TestAccountAPIClient.latestVerificationCode(for: email) ?? ""
|
||||
XCTAssertFalse(code.isEmpty, "No Kratos recovery code arrived in Mailpit for \(email)")
|
||||
|
||||
let verifyScreen = VerifyResetCodeScreen(app: app)
|
||||
verifyScreen.waitForLoad()
|
||||
verifyScreen.enterCode(code)
|
||||
verifyScreen.tapVerify()
|
||||
|
||||
// Step 3: Set the new password.
|
||||
let resetScreen = ResetPasswordScreen(app: app)
|
||||
try resetScreen.waitForLoad()
|
||||
resetScreen.enterNewPassword(newPassword)
|
||||
resetScreen.enterConfirmPassword(newPassword)
|
||||
resetScreen.tapReset()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
import XCTest
|
||||
|
||||
/// Comprehensive registration flow tests with strict, failure-first assertions
|
||||
/// Tests verify both positive AND negative conditions to ensure robust validation
|
||||
///
|
||||
/// Migrated verbatim from the legacy Suite1_RegistrationTests. The full
|
||||
/// registration flow (test07) reads the REAL Kratos verification code from
|
||||
/// Mailpit via `TestAccountAPIClient.latestVerificationCode` — it does NOT use a
|
||||
/// hardcoded "123456" code.
|
||||
final class AuthRegistrationUITests: BaseUITestCase {
|
||||
override var completeOnboarding: Bool { true }
|
||||
override var relaunchBetweenTests: Bool { true }
|
||||
|
||||
|
||||
// Test user credentials - using timestamp to ensure unique users
|
||||
private var testUsername: String {
|
||||
return "testuser_\(Int(Date().timeIntervalSince1970))"
|
||||
}
|
||||
private var testEmail: String {
|
||||
return "test_\(Int(Date().timeIntervalSince1970))@example.com"
|
||||
}
|
||||
private let testPassword = "Pass1234"
|
||||
|
||||
override func setUpWithError() throws {
|
||||
// Force clean app launch — registration tests leave sheet state that persists
|
||||
app.terminate()
|
||||
try super.setUpWithError()
|
||||
|
||||
let loginScreen = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
||||
if !loginScreen.waitForExistence(timeout: 3) {
|
||||
ensureLoggedOut()
|
||||
}
|
||||
XCTAssertTrue(loginScreen.waitForExistence(timeout: 10), "PRECONDITION FAILED: Must start on login screen")
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
ensureLoggedOut()
|
||||
try super.tearDownWithError()
|
||||
}
|
||||
|
||||
// MARK: - Strict Helper Methods
|
||||
|
||||
private func ensureLoggedOut() {
|
||||
UITestHelpers.ensureLoggedOut(app: app)
|
||||
}
|
||||
|
||||
/// Navigate to registration screen with strict verification
|
||||
/// Note: Registration is presented as a sheet, so login screen elements still exist underneath
|
||||
private func navigateToRegistration() {
|
||||
let welcomeText = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
||||
XCTAssertTrue(welcomeText.exists, "PRECONDITION: Must be on login screen to navigate to registration")
|
||||
|
||||
let signUpButton = app.buttons[AccessibilityIdentifiers.Authentication.signUpButton].firstMatch
|
||||
XCTAssertTrue(signUpButton.waitForExistence(timeout: 5), "Sign Up button must exist on login screen")
|
||||
|
||||
// Sign Up button may be offscreen at bottom of ScrollView
|
||||
if !signUpButton.isHittable {
|
||||
let scrollView = app.scrollViews.firstMatch
|
||||
if scrollView.exists {
|
||||
signUpButton.scrollIntoView(in: scrollView)
|
||||
}
|
||||
}
|
||||
|
||||
signUpButton.tap()
|
||||
|
||||
// STRICT: Verify registration screen appeared (shown as sheet)
|
||||
// Note: Login screen still exists underneath the sheet, so we verify registration elements instead
|
||||
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
|
||||
XCTAssertTrue(usernameField.waitForExistence(timeout: 5), "Registration username field must appear")
|
||||
XCTAssertTrue(waitForElementToBeHittable(usernameField, timeout: 5), "Registration username field must be tappable")
|
||||
|
||||
// Keep action buttons visible for strict assertions and interactions.
|
||||
let createAccountButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton]
|
||||
if createAccountButton.exists && !createAccountButton.isHittable {
|
||||
let scrollView = app.scrollViews.firstMatch
|
||||
if scrollView.exists {
|
||||
createAccountButton.scrollIntoView(in: scrollView, maxSwipes: 5)
|
||||
}
|
||||
}
|
||||
|
||||
// STRICT: The Sign Up button should no longer be hittable (covered by sheet)
|
||||
XCTAssertFalse(signUpButton.isHittable, "Login Sign Up button should be covered by registration sheet")
|
||||
}
|
||||
|
||||
/// Dismisses iOS Strong Password suggestion overlay
|
||||
private func dismissStrongPasswordSuggestion() {
|
||||
let chooseOwnPassword = app.buttons["Choose My Own Password"]
|
||||
if chooseOwnPassword.waitForExistence(timeout: 1) {
|
||||
chooseOwnPassword.tap()
|
||||
return
|
||||
}
|
||||
|
||||
let notNowButton = app.buttons["Not Now"]
|
||||
if notNowButton.exists && notNowButton.isHittable {
|
||||
notNowButton.tap()
|
||||
return
|
||||
}
|
||||
|
||||
// Dismiss by tapping elsewhere
|
||||
let strongPasswordText = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Strong Password'")).firstMatch
|
||||
if strongPasswordText.exists {
|
||||
app.tap()
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for element to disappear - CRITICAL for strict testing
|
||||
private func waitForElementToDisappear(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
|
||||
let expectation = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate(format: "exists == false"),
|
||||
object: element
|
||||
)
|
||||
let result = XCTWaiter.wait(for: [expectation], timeout: timeout)
|
||||
return result == .completed
|
||||
}
|
||||
|
||||
/// Wait for element to become hittable (visible AND interactive)
|
||||
private func waitForElementToBeHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
|
||||
let expectation = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate(format: "isHittable == true"),
|
||||
object: element
|
||||
)
|
||||
let result = XCTWaiter.wait(for: [expectation], timeout: timeout)
|
||||
return result == .completed
|
||||
}
|
||||
|
||||
/// Verification screen readiness check based on stable accessibility IDs.
|
||||
private func waitForVerificationScreen(timeout: TimeInterval) -> Bool {
|
||||
let authCodeField = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField]
|
||||
let onboardingCodeField = app.textFields[AccessibilityIdentifiers.Onboarding.verificationCodeField]
|
||||
let authVerifyButton = app.buttons[AccessibilityIdentifiers.Authentication.verifyButton]
|
||||
let onboardingVerifyButton = app.buttons[AccessibilityIdentifiers.Onboarding.verifyButton]
|
||||
return authCodeField.waitForExistence(timeout: timeout)
|
||||
|| onboardingCodeField.waitForExistence(timeout: timeout)
|
||||
|| authVerifyButton.waitForExistence(timeout: timeout)
|
||||
|| onboardingVerifyButton.waitForExistence(timeout: timeout)
|
||||
}
|
||||
|
||||
private func verificationCodeField() -> XCUIElement {
|
||||
let authCodeField = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField]
|
||||
if authCodeField.exists {
|
||||
return authCodeField
|
||||
}
|
||||
return app.textFields[AccessibilityIdentifiers.Onboarding.verificationCodeField]
|
||||
}
|
||||
|
||||
private func verificationButton() -> XCUIElement {
|
||||
let authVerifyButton = app.buttons[AccessibilityIdentifiers.Authentication.verifyButton]
|
||||
if authVerifyButton.exists {
|
||||
return authVerifyButton
|
||||
}
|
||||
let onboardingVerifyButton = app.buttons[AccessibilityIdentifiers.Onboarding.verifyButton]
|
||||
if onboardingVerifyButton.exists {
|
||||
return onboardingVerifyButton
|
||||
}
|
||||
return app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Verify'")).firstMatch
|
||||
}
|
||||
|
||||
/// Submit the registration form after filling it. Uses keyboard "Go" button
|
||||
/// or falls back to dismissing keyboard and tapping the register button.
|
||||
private func submitRegistrationForm() {
|
||||
let goButton = app.keyboards.buttons["Go"]
|
||||
if goButton.waitForExistence(timeout: 2) && goButton.isHittable {
|
||||
goButton.tap()
|
||||
return
|
||||
}
|
||||
// Fallback: dismiss keyboard, then tap register button
|
||||
dismissKeyboard()
|
||||
let registerButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton]
|
||||
registerButton.waitForExistenceOrFail(timeout: 5)
|
||||
if !registerButton.isHittable {
|
||||
let scrollView = app.scrollViews.firstMatch
|
||||
if scrollView.exists { registerButton.scrollIntoView(in: scrollView) }
|
||||
}
|
||||
registerButton.forceTap()
|
||||
}
|
||||
|
||||
/// Dismiss keyboard safely by tapping a neutral area.
|
||||
private func dismissKeyboard() {
|
||||
guard app.keyboards.firstMatch.exists else { return }
|
||||
// Try toolbar Done button first
|
||||
let doneButton = app.toolbars.buttons["Done"]
|
||||
if doneButton.exists && doneButton.isHittable {
|
||||
doneButton.tap()
|
||||
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 2)
|
||||
return
|
||||
}
|
||||
// Try navigation bar (works on most screens)
|
||||
let navBar = app.navigationBars.firstMatch
|
||||
if navBar.exists && navBar.isHittable {
|
||||
navBar.tap()
|
||||
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 2)
|
||||
return
|
||||
}
|
||||
// Fallback: tap top-center of the app
|
||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)).tap()
|
||||
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 2)
|
||||
}
|
||||
|
||||
/// Fill registration form with given credentials.
|
||||
/// Uses Return key (\n) to trigger SwiftUI's .onSubmit / @FocusState field
|
||||
/// transitions. Direct field taps fail on iOS 26 when transitioning from
|
||||
/// TextField to SecureTextField (keyboard never appears).
|
||||
private func fillRegistrationForm(username: String, email: String, password: String, confirmPassword: String) {
|
||||
// iOS 26 bug: SecureTextField won't gain keyboard focus when tapped directly.
|
||||
// Workaround: toggle password visibility first to convert SecureField → TextField.
|
||||
let scrollView = app.scrollViews.firstMatch
|
||||
if scrollView.exists { scrollView.swipeUp() }
|
||||
|
||||
let toggleButtons = app.buttons.matching(NSPredicate(format: "label == 'Toggle password visibility'"))
|
||||
for i in 0..<toggleButtons.count {
|
||||
let toggle = toggleButtons.element(boundBy: i)
|
||||
if toggle.exists && toggle.isHittable { toggle.tap() }
|
||||
}
|
||||
|
||||
// Don't swipeDown — it dismisses the sheet. focusAndType() auto-scrolls via tap().
|
||||
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
|
||||
let emailField = app.textFields[AccessibilityIdentifiers.Authentication.registerEmailField]
|
||||
let passwordField = app.textFields[AccessibilityIdentifiers.Authentication.registerPasswordField]
|
||||
let confirmPasswordField = app.textFields[AccessibilityIdentifiers.Authentication.registerConfirmPasswordField]
|
||||
|
||||
usernameField.focusAndType(username, app: app)
|
||||
emailField.focusAndType(email, app: app)
|
||||
passwordField.focusAndType(password, app: app)
|
||||
confirmPasswordField.focusAndType(confirmPassword, app: app)
|
||||
}
|
||||
|
||||
// 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[AccessibilityIdentifiers.Authentication.signUpButton].firstMatch
|
||||
// Note: The button might still exist but should not be hittable due to sheet coverage
|
||||
if loginSignUpButton.exists {
|
||||
XCTAssertFalse(loginSignUpButton.isHittable, "Login screen's Sign Up button should be covered by registration sheet")
|
||||
}
|
||||
}
|
||||
|
||||
func test02_cancelRegistration() {
|
||||
navigateToRegistration()
|
||||
|
||||
// Capture that we're on registration screen
|
||||
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
|
||||
XCTAssertTrue(usernameField.isHittable, "PRECONDITION: Must be on registration screen")
|
||||
|
||||
let cancelButton = app.buttons[AccessibilityIdentifiers.Authentication.registerCancelButton]
|
||||
XCTAssertTrue(cancelButton.isHittable, "Cancel button must be tappable")
|
||||
dismissKeyboard()
|
||||
cancelButton.tap()
|
||||
|
||||
// STRICT: Registration sheet must dismiss - username field should no longer be hittable
|
||||
XCTAssertTrue(waitForElementToDisappear(usernameField, timeout: 5), "Registration form must disappear after cancel")
|
||||
|
||||
// STRICT: Login screen must now be interactive again
|
||||
let welcomeText = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
||||
XCTAssertTrue(welcomeText.waitForExistence(timeout: 5), "Login screen must be visible after cancel")
|
||||
|
||||
// STRICT: Sign Up button should be hittable again (sheet dismissed)
|
||||
let signUpButton = app.buttons[AccessibilityIdentifiers.Authentication.signUpButton].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() throws {
|
||||
// This test reads the REAL Kratos verification code from Mailpit, which
|
||||
// requires the local stack (backend + Kratos + Mailpit) to be running.
|
||||
try XCTSkipUnless(
|
||||
TestAccountAPIClient.isBackendReachable(),
|
||||
"Local backend not reachable at \(TestAccountAPIClient.baseURL) — Kratos/Mailpit required for live verification code"
|
||||
)
|
||||
|
||||
// Capture the timestamp-based email ONCE: the `testEmail` computed property
|
||||
// regenerates a new value on every access (uses Date().timeIntervalSince1970),
|
||||
// so the same local `let` MUST be used for both registration and the Mailpit
|
||||
// lookup, otherwise the lookup address won't match what was registered.
|
||||
let username = testUsername
|
||||
let email = testEmail
|
||||
|
||||
// Use the same proven flow as tests 09-12
|
||||
navigateToRegistration()
|
||||
fillRegistrationForm(
|
||||
username: username,
|
||||
email: email,
|
||||
password: testPassword,
|
||||
confirmPassword: testPassword
|
||||
)
|
||||
|
||||
submitRegistrationForm()
|
||||
|
||||
// Wait for verification screen to appear (registration form may still exist underneath)
|
||||
XCTAssertTrue(waitForVerificationScreen(timeout: 15), "Verification screen must appear after registration")
|
||||
|
||||
// NEGATIVE CHECK: Tab bar should NOT be hittable while on verification
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
if tabBar.exists {
|
||||
XCTAssertFalse(tabBar.isHittable, "Tab bar should NOT be interactive while verification is required")
|
||||
}
|
||||
|
||||
// Enter verification code — the verification screen auto-submits when 6 digits are typed.
|
||||
// IMPORTANT: Do NOT use focusAndType() here — it taps the nav bar to dismiss the keyboard,
|
||||
// which can accidentally hit the logout button in the toolbar.
|
||||
let codeField = verificationCodeField()
|
||||
XCTAssertTrue(codeField.waitForExistence(timeout: 5), "Verification code field must exist")
|
||||
// The app's registration uses Kratos's real email verification flow (NOT the
|
||||
// API DEBUG fixed code), so read the live code from Mailpit for the exact
|
||||
// address we registered with above (the captured `email` local).
|
||||
// The verify screen's onAppear sends the code asynchronously, so settle first.
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(2.0))
|
||||
let realCode = TestAccountAPIClient.latestVerificationCode(for: email) ?? ""
|
||||
XCTAssertFalse(realCode.isEmpty, "No Kratos verification code arrived in Mailpit for \(email)")
|
||||
codeField.tap()
|
||||
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
|
||||
codeField.typeText(realCode)
|
||||
|
||||
// Auto-submit: typing 6 digits triggers verifyEmail() and navigates to main app.
|
||||
// Wait for the main app to appear (RootView sets ui.root.mainTabs when showing MainTabView).
|
||||
let mainTabs = app.otherElements["ui.root.mainTabs"]
|
||||
let mainAppAppeared = mainTabs.waitForExistence(timeout: 15)
|
||||
|
||||
if !mainAppAppeared {
|
||||
// Diagnostic: capture what's on screen
|
||||
let screenshot = XCTAttachment(screenshot: app.screenshot())
|
||||
screenshot.name = "post-verification-no-main-tabs"
|
||||
screenshot.lifetime = .keepAlways
|
||||
add(screenshot)
|
||||
|
||||
// Check if we're stuck on verification screen or login
|
||||
let stillOnVerify = codeField.exists
|
||||
let onLogin = app.textFields[AccessibilityIdentifiers.Authentication.usernameField].exists
|
||||
XCTFail("Main app did not appear after verification. StillOnVerify=\(stillOnVerify), OnLogin=\(onLogin)")
|
||||
return
|
||||
}
|
||||
|
||||
// STRICT: Tab bar must exist and be interactive
|
||||
XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 5), "Tab bar must exist in main app")
|
||||
|
||||
// NEGATIVE CHECK: Verification screen should be completely gone
|
||||
XCTAssertFalse(codeField.exists, "Verification code field must NOT exist after successful verification")
|
||||
|
||||
// Cleanup: Logout via profile tab → settings → logout
|
||||
dismissKeyboard()
|
||||
ensureLoggedOut()
|
||||
}
|
||||
|
||||
// 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
|
||||
)
|
||||
|
||||
submitRegistrationForm()
|
||||
|
||||
// Wait for verification screen
|
||||
XCTAssertTrue(waitForVerificationScreen(timeout: 10), "Must navigate to verification screen")
|
||||
|
||||
// Enter INVALID code — auto-submits at 6 digits
|
||||
// Don't use focusAndType() — it taps nav bar which can hit the logout button
|
||||
let codeField = verificationCodeField()
|
||||
XCTAssertTrue(codeField.waitForExistence(timeout: 5))
|
||||
codeField.tap()
|
||||
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
|
||||
codeField.typeText("000000") // Wrong code → auto-submit → API error
|
||||
|
||||
// STRICT: Error message must appear (auto-submit verifies with wrong code)
|
||||
let errorPredicate = NSPredicate(format: "label CONTAINS[c] 'invalid' OR label CONTAINS[c] 'error' OR label CONTAINS[c] 'incorrect' OR label CONTAINS[c] 'wrong' OR label CONTAINS[c] 'expired'")
|
||||
let errorMessage = app.staticTexts.containing(errorPredicate).firstMatch
|
||||
XCTAssertTrue(errorMessage.waitForExistence(timeout: 10), "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
|
||||
)
|
||||
|
||||
submitRegistrationForm()
|
||||
|
||||
XCTAssertTrue(waitForVerificationScreen(timeout: 10))
|
||||
|
||||
// Enter incomplete code (only 3 digits — won't trigger auto-submit)
|
||||
// Don't use focusAndType() — it taps nav bar which can hit the logout button
|
||||
let codeField = verificationCodeField()
|
||||
XCTAssertTrue(codeField.waitForExistence(timeout: 5))
|
||||
codeField.tap()
|
||||
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
|
||||
codeField.typeText("123") // Incomplete
|
||||
|
||||
// STRICT: Must still be on verification screen (3 digits won't auto-submit)
|
||||
XCTAssertTrue(codeField.exists, "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: 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
|
||||
)
|
||||
|
||||
submitRegistrationForm()
|
||||
|
||||
// Wait for verification screen
|
||||
XCTAssertTrue(waitForVerificationScreen(timeout: 20), "Must reach verification screen")
|
||||
|
||||
// Relaunch WITHOUT --reset-state so the unverified session persists.
|
||||
// Keep --ui-testing and --disable-animations but remove --reset-state and --complete-onboarding.
|
||||
app.terminate()
|
||||
app.launchArguments = ["--ui-testing", "--disable-animations"]
|
||||
app.launch()
|
||||
|
||||
// Wait for app to fully initialize
|
||||
app.otherElements["ui.app.ready"].waitForExistenceOrFail(timeout: 15)
|
||||
|
||||
// STRICT: After relaunch, unverified user MUST see verification screen, NOT main app
|
||||
let authCodeFieldAfterRelaunch = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField]
|
||||
let onboardingCodeFieldAfterRelaunch = app.textFields[AccessibilityIdentifiers.Onboarding.verificationCodeField]
|
||||
let loginScreen = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
|
||||
// Wait for one of the expected screens to appear
|
||||
_ = authCodeFieldAfterRelaunch.waitForExistence(timeout: 10)
|
||||
|| onboardingCodeFieldAfterRelaunch.waitForExistence(timeout: 5)
|
||||
|| loginScreen.waitForExistence(timeout: 5)
|
||||
|
||||
// User should NEVER be on main app with unverified email
|
||||
if tabBar.exists && tabBar.isHittable {
|
||||
XCTFail("CRITICAL: Unverified user should NOT have access to main app after relaunch. Tab bar is hittable!")
|
||||
}
|
||||
|
||||
// Acceptable states: verification screen OR login screen (if token expired)
|
||||
let onVerificationScreen =
|
||||
(authCodeFieldAfterRelaunch.exists && authCodeFieldAfterRelaunch.isHittable)
|
||||
|| (onboardingCodeFieldAfterRelaunch.exists && onboardingCodeFieldAfterRelaunch.isHittable)
|
||||
let onLoginScreen = loginScreen.exists && loginScreen.isHittable
|
||||
|
||||
XCTAssertTrue(onVerificationScreen || onLoginScreen,
|
||||
"After relaunch, unverified user must be on verification screen or login screen, NOT main app")
|
||||
|
||||
// Cleanup: logout from whatever screen we're on
|
||||
if onVerificationScreen {
|
||||
let logoutButton = app.buttons[AccessibilityIdentifiers.Authentication.verificationLogoutButton]
|
||||
if logoutButton.waitForExistence(timeout: 3) && logoutButton.isHittable {
|
||||
dismissKeyboard()
|
||||
logoutButton.tap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func test12_logoutFromVerificationScreen() {
|
||||
let username = testUsername
|
||||
let email = testEmail
|
||||
|
||||
navigateToRegistration()
|
||||
fillRegistrationForm(
|
||||
username: username,
|
||||
email: email,
|
||||
password: testPassword,
|
||||
confirmPassword: testPassword
|
||||
)
|
||||
|
||||
submitRegistrationForm()
|
||||
|
||||
// Wait for verification screen
|
||||
XCTAssertTrue(waitForVerificationScreen(timeout: 10), "Must navigate to verification screen")
|
||||
|
||||
// STRICT: Logout button must exist and be tappable (uses dedicated verify screen ID)
|
||||
let logoutButton = app.buttons[AccessibilityIdentifiers.Authentication.verificationLogoutButton]
|
||||
XCTAssertTrue(logoutButton.waitForExistence(timeout: 5), "Logout button MUST exist on verification screen")
|
||||
logoutButton.waitUntilHittable(timeout: 5)
|
||||
logoutButton.tap()
|
||||
|
||||
// STRICT: Verification screen must disappear
|
||||
let codeField = verificationCodeField()
|
||||
XCTAssertTrue(waitForElementToDisappear(codeField, timeout: 5), "Verification screen must disappear after logout")
|
||||
|
||||
// STRICT: Must return to login screen
|
||||
let welcomeText = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
||||
XCTAssertTrue(welcomeText.waitForExistence(timeout: 5), "Must return to login screen after logout")
|
||||
XCTAssertTrue(welcomeText.isHittable, "Login screen must be interactive")
|
||||
|
||||
// NEGATIVE CHECK: Verification screen elements should be gone
|
||||
XCTAssertFalse(codeField.exists, "Verification code field should NOT exist after logout")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - XCUIElement Extension
|
||||
|
||||
extension XCUIElement {
|
||||
var hasKeyboardFocus: Bool {
|
||||
return (value(forKey: "hasKeyboardFocus") as? Bool) ?? false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user