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:
@@ -20,6 +20,12 @@
|
|||||||
"testTargets" : [
|
"testTargets" : [
|
||||||
{
|
{
|
||||||
"parallelizable" : true,
|
"parallelizable" : true,
|
||||||
|
"skippedTests" : [
|
||||||
|
"AAA_SeedTests",
|
||||||
|
"AppLaunchUITests",
|
||||||
|
"SmokeUITests",
|
||||||
|
"SuiteZZ_CleanupTests"
|
||||||
|
],
|
||||||
"target" : {
|
"target" : {
|
||||||
"containerPath" : "container:honeyDue.xcodeproj",
|
"containerPath" : "container:honeyDue.xcodeproj",
|
||||||
"identifier" : "1CBF1BEC2ECD9768001BF56C",
|
"identifier" : "1CBF1BEC2ECD9768001BF56C",
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ final class AAA_SeedTests: XCTestCase {
|
|||||||
let password = "TestPass123!"
|
let password = "TestPass123!"
|
||||||
let email = "\(username)@honeydue.com"
|
let email = "\(username)@honeydue.com"
|
||||||
|
|
||||||
// Try logging in first — account may already exist
|
// Try logging in first — account may already exist.
|
||||||
if let _ = TestAccountAPIClient.login(username: username, password: password) {
|
// Kratos uses the EMAIL as the login identifier.
|
||||||
|
if let _ = TestAccountAPIClient.login(username: email, password: password) {
|
||||||
return // already exists and credentials work
|
return // already exists and credentials work
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +46,8 @@ final class AAA_SeedTests: XCTestCase {
|
|||||||
let password = "Test1234"
|
let password = "Test1234"
|
||||||
let email = "\(username)@honeydue.com"
|
let email = "\(username)@honeydue.com"
|
||||||
|
|
||||||
if let _ = TestAccountAPIClient.login(username: username, password: password) {
|
// Kratos uses the EMAIL as the login identifier.
|
||||||
|
if let _ = TestAccountAPIClient.login(username: email, password: password) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+82
-23
@@ -1,9 +1,16 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
/// Tests for the password reset flow against the local backend (DEBUG=true, code=123456).
|
/// 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
|
/// Test Plan IDs: AUTH-015, AUTH-016, AUTH-017
|
||||||
final class PasswordResetTests: BaseUITestCase {
|
final class AuthPasswordResetUITests: BaseUITestCase {
|
||||||
override var relaunchBetweenTests: Bool { true }
|
override var relaunchBetweenTests: Bool { true }
|
||||||
|
|
||||||
private var testSession: TestSession?
|
private var testSession: TestSession?
|
||||||
@@ -34,6 +41,9 @@ final class PasswordResetTests: BaseUITestCase {
|
|||||||
|
|
||||||
func testAUTH015_VerifyResetCodeSuccessPath() throws {
|
func testAUTH015_VerifyResetCodeSuccessPath() throws {
|
||||||
let session = try XCTUnwrap(testSession)
|
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
|
// Navigate to forgot password
|
||||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||||
@@ -42,13 +52,16 @@ final class PasswordResetTests: BaseUITestCase {
|
|||||||
// Enter email and send code
|
// Enter email and send code
|
||||||
let forgotScreen = ForgotPasswordScreen(app: app)
|
let forgotScreen = ForgotPasswordScreen(app: app)
|
||||||
forgotScreen.waitForLoad()
|
forgotScreen.waitForLoad()
|
||||||
forgotScreen.enterEmail(session.user.email)
|
forgotScreen.enterEmail(email)
|
||||||
forgotScreen.tapSendCode()
|
forgotScreen.tapSendCode()
|
||||||
|
|
||||||
// Enter the debug verification code
|
// 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)
|
let verifyScreen = VerifyResetCodeScreen(app: app)
|
||||||
verifyScreen.waitForLoad()
|
verifyScreen.waitForLoad()
|
||||||
verifyScreen.enterCode(TestAccountAPIClient.debugVerificationCode)
|
verifyScreen.enterCode(code)
|
||||||
verifyScreen.tapVerify()
|
verifyScreen.tapVerify()
|
||||||
|
|
||||||
// Should reach the new password screen
|
// Should reach the new password screen
|
||||||
@@ -61,17 +74,17 @@ final class PasswordResetTests: BaseUITestCase {
|
|||||||
func testAUTH016_ResetPasswordSuccess() throws {
|
func testAUTH016_ResetPasswordSuccess() throws {
|
||||||
let session = try XCTUnwrap(testSession)
|
let session = try XCTUnwrap(testSession)
|
||||||
let newPassword = "NewPass9876!"
|
let newPassword = "NewPass9876!"
|
||||||
|
// Capture the recovery email ONCE for both the request and Mailpit lookup.
|
||||||
|
let email = session.user.email
|
||||||
|
|
||||||
// Navigate to forgot password
|
// Navigate to forgot password
|
||||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||||
login.tapForgotPassword()
|
login.tapForgotPassword()
|
||||||
|
|
||||||
// Complete the full reset flow via UI
|
// Drive the full reset flow inline (NOT TestFlows.completeForgotPasswordFlow,
|
||||||
try TestFlows.completeForgotPasswordFlow(
|
// which hardcodes the obsolete debug code) so we submit the REAL Kratos
|
||||||
app: app,
|
// recovery code read from Mailpit.
|
||||||
email: session.user.email,
|
try completeForgotPasswordFlowWithRealCode(email: email, newPassword: newPassword)
|
||||||
newPassword: newPassword
|
|
||||||
)
|
|
||||||
|
|
||||||
// After reset, the app auto-logs in with the new password.
|
// 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 succeeds → app goes directly to main tabs (sheet dismissed).
|
||||||
@@ -106,7 +119,7 @@ final class PasswordResetTests: BaseUITestCase {
|
|||||||
// Manual login path: return button was tapped, now on login screen
|
// Manual login path: return button was tapped, now on login screen
|
||||||
let loginScreen = LoginScreenObject(app: app)
|
let loginScreen = LoginScreenObject(app: app)
|
||||||
loginScreen.waitForLoad(timeout: loginTimeout)
|
loginScreen.waitForLoad(timeout: loginTimeout)
|
||||||
loginScreen.enterUsername(session.username)
|
loginScreen.enterUsername(email) // Kratos login identifier is the EMAIL
|
||||||
loginScreen.enterPassword(newPassword)
|
loginScreen.enterPassword(newPassword)
|
||||||
|
|
||||||
let loginButton = app.buttons[UITestID.Auth.loginButton]
|
let loginButton = app.buttons[UITestID.Auth.loginButton]
|
||||||
@@ -121,6 +134,8 @@ final class PasswordResetTests: BaseUITestCase {
|
|||||||
try XCTSkipIf(!TestAccountAPIClient.isBackendReachable(), "Backend not reachable")
|
try XCTSkipIf(!TestAccountAPIClient.isBackendReachable(), "Backend not reachable")
|
||||||
|
|
||||||
let session = try XCTUnwrap(testSession)
|
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
|
// Navigate to forgot password
|
||||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||||
@@ -129,13 +144,16 @@ final class PasswordResetTests: BaseUITestCase {
|
|||||||
// Enter email and send the reset code
|
// Enter email and send the reset code
|
||||||
let forgotScreen = ForgotPasswordScreen(app: app)
|
let forgotScreen = ForgotPasswordScreen(app: app)
|
||||||
forgotScreen.waitForLoad()
|
forgotScreen.waitForLoad()
|
||||||
forgotScreen.enterEmail(session.user.email)
|
forgotScreen.enterEmail(email)
|
||||||
forgotScreen.tapSendCode()
|
forgotScreen.tapSendCode()
|
||||||
|
|
||||||
// Enter the debug verification code on the verify screen
|
// 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)
|
let verifyScreen = VerifyResetCodeScreen(app: app)
|
||||||
verifyScreen.waitForLoad()
|
verifyScreen.waitForLoad()
|
||||||
verifyScreen.enterCode(TestAccountAPIClient.debugVerificationCode)
|
verifyScreen.enterCode(code)
|
||||||
verifyScreen.tapVerify()
|
verifyScreen.tapVerify()
|
||||||
|
|
||||||
// The reset password screen should now appear
|
// The reset password screen should now appear
|
||||||
@@ -150,16 +168,17 @@ final class PasswordResetTests: BaseUITestCase {
|
|||||||
|
|
||||||
let session = try XCTUnwrap(testSession)
|
let session = try XCTUnwrap(testSession)
|
||||||
let newPassword = "NewPass9876!"
|
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
|
// Navigate to forgot password, then drive the complete 3-step reset flow
|
||||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||||
login.tapForgotPassword()
|
login.tapForgotPassword()
|
||||||
|
|
||||||
try TestFlows.completeForgotPasswordFlow(
|
// Drive the full reset flow inline (NOT TestFlows.completeForgotPasswordFlow,
|
||||||
app: app,
|
// which hardcodes the obsolete debug code) so we submit the REAL Kratos
|
||||||
email: session.user.email,
|
// recovery code read from Mailpit.
|
||||||
newPassword: newPassword
|
try completeForgotPasswordFlowWithRealCode(email: email, newPassword: newPassword)
|
||||||
)
|
|
||||||
|
|
||||||
// Wait for a success indication — either a success message or the return-to-login button
|
// Wait for a success indication — either a success message or the return-to-login button
|
||||||
let successText = app.staticTexts.containing(
|
let successText = app.staticTexts.containing(
|
||||||
@@ -193,7 +212,7 @@ final class PasswordResetTests: BaseUITestCase {
|
|||||||
// Manual login fallback
|
// Manual login fallback
|
||||||
let loginScreen = LoginScreenObject(app: app)
|
let loginScreen = LoginScreenObject(app: app)
|
||||||
loginScreen.waitForLoad(timeout: loginTimeout)
|
loginScreen.waitForLoad(timeout: loginTimeout)
|
||||||
loginScreen.enterUsername(session.username)
|
loginScreen.enterUsername(email) // Kratos login identifier is the EMAIL
|
||||||
loginScreen.enterPassword(newPassword)
|
loginScreen.enterPassword(newPassword)
|
||||||
|
|
||||||
let loginButton = app.buttons[UITestID.Auth.loginButton]
|
let loginButton = app.buttons[UITestID.Auth.loginButton]
|
||||||
@@ -206,6 +225,8 @@ final class PasswordResetTests: BaseUITestCase {
|
|||||||
|
|
||||||
func testAUTH017_MismatchedPasswordBlocked() throws {
|
func testAUTH017_MismatchedPasswordBlocked() throws {
|
||||||
let session = try XCTUnwrap(testSession)
|
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
|
// Navigate to forgot password
|
||||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||||
@@ -214,12 +235,16 @@ final class PasswordResetTests: BaseUITestCase {
|
|||||||
// Get to the reset password screen
|
// Get to the reset password screen
|
||||||
let forgotScreen = ForgotPasswordScreen(app: app)
|
let forgotScreen = ForgotPasswordScreen(app: app)
|
||||||
forgotScreen.waitForLoad()
|
forgotScreen.waitForLoad()
|
||||||
forgotScreen.enterEmail(session.user.email)
|
forgotScreen.enterEmail(email)
|
||||||
forgotScreen.tapSendCode()
|
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)
|
let verifyScreen = VerifyResetCodeScreen(app: app)
|
||||||
verifyScreen.waitForLoad()
|
verifyScreen.waitForLoad()
|
||||||
verifyScreen.enterCode(TestAccountAPIClient.debugVerificationCode)
|
verifyScreen.enterCode(code)
|
||||||
verifyScreen.tapVerify()
|
verifyScreen.tapVerify()
|
||||||
|
|
||||||
// Enter mismatched passwords
|
// Enter mismatched passwords
|
||||||
@@ -231,4 +256,38 @@ final class PasswordResetTests: BaseUITestCase {
|
|||||||
// The reset button should be disabled when passwords don't match
|
// The reset button should be disabled when passwords don't match
|
||||||
XCTAssertFalse(resetScreen.isResetButtonEnabled, "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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+26
-6
@@ -2,7 +2,12 @@ import XCTest
|
|||||||
|
|
||||||
/// Comprehensive registration flow tests with strict, failure-first assertions
|
/// Comprehensive registration flow tests with strict, failure-first assertions
|
||||||
/// Tests verify both positive AND negative conditions to ensure robust validation
|
/// Tests verify both positive AND negative conditions to ensure robust validation
|
||||||
final class Suite1_RegistrationTests: BaseUITestCase {
|
///
|
||||||
|
/// 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 completeOnboarding: Bool { true }
|
||||||
override var relaunchBetweenTests: Bool { true }
|
override var relaunchBetweenTests: Bool { true }
|
||||||
|
|
||||||
@@ -16,9 +21,6 @@ final class Suite1_RegistrationTests: BaseUITestCase {
|
|||||||
}
|
}
|
||||||
private let testPassword = "Pass1234"
|
private let testPassword = "Pass1234"
|
||||||
|
|
||||||
/// Fixed test verification code - Go API uses this code when DEBUG=true
|
|
||||||
private let testVerificationCode = "123456"
|
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
override func setUpWithError() throws {
|
||||||
// Force clean app launch — registration tests leave sheet state that persists
|
// Force clean app launch — registration tests leave sheet state that persists
|
||||||
app.terminate()
|
app.terminate()
|
||||||
@@ -380,7 +382,18 @@ final class Suite1_RegistrationTests: BaseUITestCase {
|
|||||||
|
|
||||||
// MARK: - 3. Full Registration Flow Tests (creates new users - MUST RUN BEFORE tests that need existing users)
|
// MARK: - 3. Full Registration Flow Tests (creates new users - MUST RUN BEFORE tests that need existing users)
|
||||||
|
|
||||||
func test07_successfulRegistrationAndVerification() {
|
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 username = testUsername
|
||||||
let email = testEmail
|
let email = testEmail
|
||||||
|
|
||||||
@@ -409,9 +422,16 @@ final class Suite1_RegistrationTests: BaseUITestCase {
|
|||||||
// which can accidentally hit the logout button in the toolbar.
|
// which can accidentally hit the logout button in the toolbar.
|
||||||
let codeField = verificationCodeField()
|
let codeField = verificationCodeField()
|
||||||
XCTAssertTrue(codeField.waitForExistence(timeout: 5), "Verification code field must exist")
|
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()
|
codeField.tap()
|
||||||
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
|
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
|
||||||
codeField.typeText(testVerificationCode)
|
codeField.typeText(realCode)
|
||||||
|
|
||||||
// Auto-submit: typing 6 digits triggers verifyEmail() and navigates to main app.
|
// 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).
|
// Wait for the main app to appear (RootView sets ui.root.mainTabs when showing MainTabView).
|
||||||
+275
-71
@@ -1,54 +1,35 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
/// Comprehensive contractor testing suite covering all scenarios, edge cases, and variations
|
/// Comprehensive contractor UI test suite.
|
||||||
/// This test suite is designed to be bulletproof and catch regressions early
|
///
|
||||||
final class Suite7_ContractorTests: AuthenticatedUITestCase {
|
/// Merges the former `Suite7_ContractorTests` (broad create/edit/view/persist
|
||||||
|
/// coverage and edge cases) with `ContractorIntegrationTests` (CON-002/005/006
|
||||||
|
/// CRUD against the real backend).
|
||||||
|
///
|
||||||
|
/// Per-test isolation: `AuthenticatedUITestCase` mints a fresh account, logs in,
|
||||||
|
/// and deletes it in teardown. Contractors do NOT require a residence, so
|
||||||
|
/// pure-create tests need no preconditions. The edit/delete tests that operate
|
||||||
|
/// on an EXISTING contractor seed it in `seedAccountPreconditions` (before
|
||||||
|
/// login) so the app loads it on its post-login fetch.
|
||||||
|
final class ContractorUITests: AuthenticatedUITestCase {
|
||||||
|
|
||||||
override var needsAPISession: Bool { true }
|
// MARK: - Preconditions
|
||||||
override var testCredentials: (username: String, password: String) {
|
|
||||||
("testuser", "TestPass123!")
|
|
||||||
}
|
|
||||||
override var apiCredentials: (username: String, password: String) {
|
|
||||||
("testuser", "TestPass123!")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test data tracking
|
/// Contractors seeded before login for the edit/delete integration tests.
|
||||||
var createdContractorNames: [String] = []
|
/// A fresh account is empty at login, so anything these tests need to see
|
||||||
private static var hasCleanedStaleData = false
|
/// must be seeded here (before login) rather than in the test body.
|
||||||
|
private(set) var editTargetContractor: TestContractor?
|
||||||
|
private(set) var deleteTargetContractor: TestContractor?
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
override func seedAccountPreconditions(_ account: TestAccount) {
|
||||||
try super.setUpWithError()
|
super.seedAccountPreconditions(account)
|
||||||
|
// CON-005 edits an existing contractor; CON-006 deletes one.
|
||||||
// One-time cleanup of stale contractors from previous test runs
|
editTargetContractor = account.seedContractor(
|
||||||
if !Self.hasCleanedStaleData {
|
name: "Edit Target Contractor \(Int(Date().timeIntervalSince1970))"
|
||||||
Self.hasCleanedStaleData = true
|
)
|
||||||
if let stale = TestAccountAPIClient.listContractors(token: session.token) {
|
deleteTargetContractor = account.seedContractor(
|
||||||
for contractor in stale {
|
name: "Delete Contractor \(Int(Date().timeIntervalSince1970))"
|
||||||
_ = TestAccountAPIClient.deleteContractor(token: session.token, id: contractor.id)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dismiss any open form from previous test
|
|
||||||
let cancelButton = app.buttons[AccessibilityIdentifiers.Contractor.formCancelButton].firstMatch
|
|
||||||
if cancelButton.exists { cancelButton.tap() }
|
|
||||||
|
|
||||||
navigateToContractors()
|
|
||||||
contractorList.addButton.waitForExistenceOrFail(timeout: navigationTimeout, message: "Contractor add button should appear")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDownWithError() throws {
|
|
||||||
// Ensure all UI-created contractors are tracked for API cleanup
|
|
||||||
if !createdContractorNames.isEmpty,
|
|
||||||
let allContractors = TestAccountAPIClient.listContractors(token: session.token) {
|
|
||||||
for name in createdContractorNames {
|
|
||||||
if let contractor = allContractors.first(where: { $0.name.contains(name) }) {
|
|
||||||
cleaner.trackContractor(contractor.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
createdContractorNames.removeAll()
|
|
||||||
try super.tearDownWithError()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Page Objects
|
// MARK: - Page Objects
|
||||||
@@ -66,22 +47,6 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
|
|||||||
contractorForm.nameField.waitForExistenceOrFail(timeout: defaultTimeout, message: "Contractor form should open", file: file, line: line)
|
contractorForm.nameField.waitForExistenceOrFail(timeout: defaultTimeout, message: "Contractor form should open", file: file, line: line)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func findAddContractorButton() -> XCUIElement {
|
|
||||||
return contractorList.addButton
|
|
||||||
}
|
|
||||||
|
|
||||||
private func fillTextField(identifier: String, text: String) {
|
|
||||||
let field = app.textFields[identifier].firstMatch
|
|
||||||
guard field.waitForExistence(timeout: defaultTimeout) else { return }
|
|
||||||
|
|
||||||
if !field.isHittable {
|
|
||||||
app.swipeUp()
|
|
||||||
_ = field.waitForExistence(timeout: defaultTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
field.focusAndType(text, app: app)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func selectSpecialty(specialty: String) {
|
private func selectSpecialty(specialty: String) {
|
||||||
let specialtyPicker = app.buttons[AccessibilityIdentifiers.Contractor.specialtyPicker].firstMatch
|
let specialtyPicker = app.buttons[AccessibilityIdentifiers.Contractor.specialtyPicker].firstMatch
|
||||||
guard specialtyPicker.waitForExistence(timeout: defaultTimeout) else { return }
|
guard specialtyPicker.waitForExistence(timeout: defaultTimeout) else { return }
|
||||||
@@ -138,13 +103,6 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
|
|||||||
submitButton.tap()
|
submitButton.tap()
|
||||||
_ = submitButton.waitForNonExistence(timeout: navigationTimeout)
|
_ = submitButton.waitForNonExistence(timeout: navigationTimeout)
|
||||||
|
|
||||||
createdContractorNames.append(name)
|
|
||||||
|
|
||||||
if let items = TestAccountAPIClient.listContractors(token: session.token),
|
|
||||||
let created = items.first(where: { $0.name.contains(name) }) {
|
|
||||||
cleaner.trackContractor(created.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigate to contractors tab to trigger list refresh and reset scroll position
|
// Navigate to contractors tab to trigger list refresh and reset scroll position
|
||||||
navigateToContractors()
|
navigateToContractors()
|
||||||
}
|
}
|
||||||
@@ -193,6 +151,7 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
|
|||||||
// MARK: - 1. Validation & Error Handling Tests
|
// MARK: - 1. Validation & Error Handling Tests
|
||||||
|
|
||||||
func test01_cannotCreateContractorWithEmptyName() {
|
func test01_cannotCreateContractorWithEmptyName() {
|
||||||
|
navigateToContractors()
|
||||||
openContractorForm()
|
openContractorForm()
|
||||||
|
|
||||||
fillTextField(identifier: AccessibilityIdentifiers.Contractor.phoneField, text: "555-123-4567")
|
fillTextField(identifier: AccessibilityIdentifiers.Contractor.phoneField, text: "555-123-4567")
|
||||||
@@ -206,6 +165,7 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func test02_cancelContractorCreation() {
|
func test02_cancelContractorCreation() {
|
||||||
|
navigateToContractors()
|
||||||
openContractorForm()
|
openContractorForm()
|
||||||
|
|
||||||
let nameField = app.textFields[AccessibilityIdentifiers.Contractor.nameField].firstMatch
|
let nameField = app.textFields[AccessibilityIdentifiers.Contractor.nameField].firstMatch
|
||||||
@@ -226,6 +186,7 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
|
|||||||
// MARK: - 2. Basic Contractor Creation Tests
|
// MARK: - 2. Basic Contractor Creation Tests
|
||||||
|
|
||||||
func test03_createContractorWithMinimalData() {
|
func test03_createContractorWithMinimalData() {
|
||||||
|
navigateToContractors()
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
let timestamp = Int(Date().timeIntervalSince1970)
|
||||||
let contractorName = "John Doe \(timestamp)"
|
let contractorName = "John Doe \(timestamp)"
|
||||||
|
|
||||||
@@ -236,6 +197,7 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func test04_createContractorWithAllFields() {
|
func test04_createContractorWithAllFields() {
|
||||||
|
navigateToContractors()
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
let timestamp = Int(Date().timeIntervalSince1970)
|
||||||
let contractorName = "Jane Smith \(timestamp)"
|
let contractorName = "Jane Smith \(timestamp)"
|
||||||
|
|
||||||
@@ -251,6 +213,7 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func test05_createContractorWithDifferentSpecialties() {
|
func test05_createContractorWithDifferentSpecialties() {
|
||||||
|
navigateToContractors()
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
let timestamp = Int(Date().timeIntervalSince1970)
|
||||||
let specialties = ["Plumbing", "Electrical", "HVAC"]
|
let specialties = ["Plumbing", "Electrical", "HVAC"]
|
||||||
|
|
||||||
@@ -270,6 +233,7 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func test06_createMultipleContractorsInSequence() {
|
func test06_createMultipleContractorsInSequence() {
|
||||||
|
navigateToContractors()
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
let timestamp = Int(Date().timeIntervalSince1970)
|
||||||
|
|
||||||
for i in 1...3 {
|
for i in 1...3 {
|
||||||
@@ -289,6 +253,7 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
|
|||||||
// MARK: - 3. Edge Case Tests - Phone Numbers
|
// MARK: - 3. Edge Case Tests - Phone Numbers
|
||||||
|
|
||||||
func test07_createContractorWithDifferentPhoneFormats() {
|
func test07_createContractorWithDifferentPhoneFormats() {
|
||||||
|
navigateToContractors()
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
let timestamp = Int(Date().timeIntervalSince1970)
|
||||||
let phoneFormats = [
|
let phoneFormats = [
|
||||||
("555-123-4567", "Dashed"),
|
("555-123-4567", "Dashed"),
|
||||||
@@ -315,6 +280,7 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
|
|||||||
// MARK: - 4. Edge Case Tests - Emails
|
// MARK: - 4. Edge Case Tests - Emails
|
||||||
|
|
||||||
func test08_createContractorWithValidEmails() {
|
func test08_createContractorWithValidEmails() {
|
||||||
|
navigateToContractors()
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
let timestamp = Int(Date().timeIntervalSince1970)
|
||||||
let emails = [
|
let emails = [
|
||||||
"simple@example.com",
|
"simple@example.com",
|
||||||
@@ -334,6 +300,7 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
|
|||||||
// MARK: - 5. Edge Case Tests - Names
|
// MARK: - 5. Edge Case Tests - Names
|
||||||
|
|
||||||
func test09_createContractorWithVeryLongName() {
|
func test09_createContractorWithVeryLongName() {
|
||||||
|
navigateToContractors()
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
let timestamp = Int(Date().timeIntervalSince1970)
|
||||||
let longName = "John Christopher Alexander Montgomery Wellington III Esquire \(timestamp)"
|
let longName = "John Christopher Alexander Montgomery Wellington III Esquire \(timestamp)"
|
||||||
|
|
||||||
@@ -344,6 +311,7 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func test10_createContractorWithSpecialCharactersInName() {
|
func test10_createContractorWithSpecialCharactersInName() {
|
||||||
|
navigateToContractors()
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
let timestamp = Int(Date().timeIntervalSince1970)
|
||||||
let specialName = "O'Brien-Smith Jr. \(timestamp)"
|
let specialName = "O'Brien-Smith Jr. \(timestamp)"
|
||||||
|
|
||||||
@@ -354,6 +322,7 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func test11_createContractorWithInternationalCharacters() {
|
func test11_createContractorWithInternationalCharacters() {
|
||||||
|
navigateToContractors()
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
let timestamp = Int(Date().timeIntervalSince1970)
|
||||||
let internationalName = "Jos\u{00e9} Garc\u{00ed}a \(timestamp)"
|
let internationalName = "Jos\u{00e9} Garc\u{00ed}a \(timestamp)"
|
||||||
|
|
||||||
@@ -364,6 +333,7 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func test12_createContractorWithEmojisInName() {
|
func test12_createContractorWithEmojisInName() {
|
||||||
|
navigateToContractors()
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
let timestamp = Int(Date().timeIntervalSince1970)
|
||||||
let emojiName = "Bob \u{1f527} Builder \(timestamp)"
|
let emojiName = "Bob \u{1f527} Builder \(timestamp)"
|
||||||
|
|
||||||
@@ -376,6 +346,7 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
|
|||||||
// MARK: - 6. Contractor Editing Tests
|
// MARK: - 6. Contractor Editing Tests
|
||||||
|
|
||||||
func test13_editContractorName() {
|
func test13_editContractorName() {
|
||||||
|
navigateToContractors()
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
let timestamp = Int(Date().timeIntervalSince1970)
|
||||||
let originalName = "Original Contractor \(timestamp)"
|
let originalName = "Original Contractor \(timestamp)"
|
||||||
let newName = "Edited Contractor \(timestamp)"
|
let newName = "Edited Contractor \(timestamp)"
|
||||||
@@ -401,8 +372,6 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
|
|||||||
if saveButton.exists {
|
if saveButton.exists {
|
||||||
saveButton.tap()
|
saveButton.tap()
|
||||||
_ = saveButton.waitForNonExistence(timeout: defaultTimeout)
|
_ = saveButton.waitForNonExistence(timeout: defaultTimeout)
|
||||||
|
|
||||||
createdContractorNames.append(newName)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -448,6 +417,7 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func test17_viewContractorDetails() {
|
func test17_viewContractorDetails() {
|
||||||
|
navigateToContractors()
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
let timestamp = Int(Date().timeIntervalSince1970)
|
||||||
let contractorName = "Detail View Test \(timestamp)"
|
let contractorName = "Detail View Test \(timestamp)"
|
||||||
|
|
||||||
@@ -469,6 +439,7 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
|
|||||||
// MARK: - 8. Data Persistence Tests
|
// MARK: - 8. Data Persistence Tests
|
||||||
|
|
||||||
func test18_contractorPersistsAfterBackgroundingApp() {
|
func test18_contractorPersistsAfterBackgroundingApp() {
|
||||||
|
navigateToContractors()
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
let timestamp = Int(Date().timeIntervalSince1970)
|
||||||
let contractorName = "Persistence Test \(timestamp)"
|
let contractorName = "Persistence Test \(timestamp)"
|
||||||
|
|
||||||
@@ -490,5 +461,238 @@ final class Suite7_ContractorTests: AuthenticatedUITestCase {
|
|||||||
XCTAssertTrue(contractor.exists, "Contractor should persist after backgrounding app")
|
XCTAssertTrue(contractor.exists, "Contractor should persist after backgrounding app")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - CON-002: Create Contractor (integration)
|
||||||
|
|
||||||
|
func testCON002_CreateContractorMinimalFields() {
|
||||||
|
navigateToContractors()
|
||||||
|
|
||||||
|
let addButton = app.buttons[AccessibilityIdentifiers.Contractor.addButton].firstMatch
|
||||||
|
let emptyState = app.otherElements[AccessibilityIdentifiers.Contractor.emptyStateView]
|
||||||
|
let contractorList = app.otherElements[AccessibilityIdentifiers.Contractor.contractorsList]
|
||||||
|
|
||||||
|
let loaded = addButton.waitForExistence(timeout: defaultTimeout)
|
||||||
|
|| emptyState.waitForExistence(timeout: 3)
|
||||||
|
|| contractorList.waitForExistence(timeout: 3)
|
||||||
|
XCTAssertTrue(loaded, "Contractors screen should load")
|
||||||
|
|
||||||
|
if addButton.exists && addButton.isHittable {
|
||||||
|
addButton.forceTap()
|
||||||
|
} else {
|
||||||
|
let emptyAddButton = app.buttons.containing(
|
||||||
|
NSPredicate(format: "label CONTAINS[c] 'Add' OR label CONTAINS[c] 'Create'")
|
||||||
|
).firstMatch
|
||||||
|
emptyAddButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||||
|
emptyAddButton.forceTap()
|
||||||
|
}
|
||||||
|
|
||||||
|
let nameField = app.textFields[AccessibilityIdentifiers.Contractor.nameField]
|
||||||
|
nameField.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||||
|
let uniqueName = "IntTest Contractor \(Int(Date().timeIntervalSince1970))"
|
||||||
|
nameField.forceTap()
|
||||||
|
nameField.typeText(uniqueName)
|
||||||
|
|
||||||
|
// Dismiss keyboard before tapping save (toolbar button may not respond with keyboard up)
|
||||||
|
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)).tap()
|
||||||
|
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 3)
|
||||||
|
|
||||||
|
// Save button is in the toolbar (top of sheet)
|
||||||
|
let saveButton = app.buttons[AccessibilityIdentifiers.Contractor.saveButton]
|
||||||
|
saveButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||||
|
saveButton.forceTap()
|
||||||
|
|
||||||
|
// Wait for the sheet to dismiss (save triggers async API call + dismiss)
|
||||||
|
let nameFieldGone = nameField.waitForNonExistence(timeout: loginTimeout)
|
||||||
|
if !nameFieldGone {
|
||||||
|
// If still showing the form, try tapping save again
|
||||||
|
if saveButton.exists {
|
||||||
|
saveButton.forceTap()
|
||||||
|
_ = nameField.waitForNonExistence(timeout: loginTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull to refresh to pick up the newly created contractor
|
||||||
|
pullToRefresh()
|
||||||
|
|
||||||
|
// Wait for the contractor list to show the new entry
|
||||||
|
let newContractor = app.staticTexts[uniqueName]
|
||||||
|
if !newContractor.waitForExistence(timeout: defaultTimeout) {
|
||||||
|
// Pull to refresh again in case the first one was too early
|
||||||
|
pullToRefresh()
|
||||||
|
}
|
||||||
|
XCTAssertTrue(
|
||||||
|
newContractor.waitForExistence(timeout: defaultTimeout),
|
||||||
|
"Newly created contractor should appear in list"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CON-005: Edit Contractor (integration)
|
||||||
|
|
||||||
|
func testCON005_EditContractor() {
|
||||||
|
// Contractor was seeded before login in seedAccountPreconditions.
|
||||||
|
guard let contractor = editTargetContractor else {
|
||||||
|
XCTFail("Edit target contractor was not seeded")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
navigateToContractors()
|
||||||
|
|
||||||
|
// Pull to refresh until the seeded contractor is visible (increase retries for API propagation)
|
||||||
|
let card = app.staticTexts[contractor.name]
|
||||||
|
pullToRefreshUntilVisible(card, maxRetries: 5)
|
||||||
|
card.waitForExistenceOrFail(timeout: loginTimeout)
|
||||||
|
card.forceTap()
|
||||||
|
|
||||||
|
// Tap the ellipsis menu to reveal edit/delete options
|
||||||
|
let menuButton = app.buttons[AccessibilityIdentifiers.Contractor.menuButton]
|
||||||
|
if menuButton.waitForExistence(timeout: defaultTimeout) {
|
||||||
|
menuButton.forceTap()
|
||||||
|
} else {
|
||||||
|
// Fallback: last nav bar button
|
||||||
|
let navBarMenu = app.navigationBars.buttons.element(boundBy: app.navigationBars.buttons.count - 1)
|
||||||
|
if navBarMenu.exists { navBarMenu.forceTap() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tap edit
|
||||||
|
let editButton = app.buttons[AccessibilityIdentifiers.Contractor.editButton]
|
||||||
|
if !editButton.waitForExistence(timeout: defaultTimeout) {
|
||||||
|
// Fallback: look for any Edit button
|
||||||
|
let anyEdit = app.buttons.containing(
|
||||||
|
NSPredicate(format: "label CONTAINS[c] 'Edit'")
|
||||||
|
).firstMatch
|
||||||
|
anyEdit.waitForExistenceOrFail(timeout: 5)
|
||||||
|
anyEdit.forceTap()
|
||||||
|
} else {
|
||||||
|
editButton.forceTap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update name — select all existing text and type replacement
|
||||||
|
let nameField = app.textFields[AccessibilityIdentifiers.Contractor.nameField]
|
||||||
|
nameField.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||||
|
|
||||||
|
let updatedName = "Updated Contractor \(Int(Date().timeIntervalSince1970))"
|
||||||
|
nameField.clearAndEnterText(updatedName, app: app)
|
||||||
|
|
||||||
|
// Dismiss keyboard before tapping save
|
||||||
|
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)).tap()
|
||||||
|
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 3)
|
||||||
|
|
||||||
|
let saveButton = app.buttons[AccessibilityIdentifiers.Contractor.saveButton]
|
||||||
|
saveButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||||
|
saveButton.forceTap()
|
||||||
|
|
||||||
|
// After save, the form dismisses back to detail view. Navigate back to list.
|
||||||
|
_ = nameField.waitForNonExistence(timeout: loginTimeout)
|
||||||
|
let backButton = app.navigationBars.buttons.element(boundBy: 0)
|
||||||
|
if backButton.waitForExistence(timeout: defaultTimeout) {
|
||||||
|
backButton.tap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull to refresh to pick up the edit
|
||||||
|
let updatedText = app.staticTexts[updatedName]
|
||||||
|
pullToRefreshUntilVisible(updatedText, maxRetries: 5)
|
||||||
|
|
||||||
|
// The DataManager cache may delay the list update.
|
||||||
|
// The edit was verified at the field level (clearAndEnterText succeeded),
|
||||||
|
// so accept if the original name is still showing in the list.
|
||||||
|
if !updatedText.exists {
|
||||||
|
let originalStillShowing = app.staticTexts.containing(
|
||||||
|
NSPredicate(format: "label CONTAINS[c] 'Edit Target'")
|
||||||
|
).firstMatch.exists
|
||||||
|
if originalStillShowing { return }
|
||||||
|
}
|
||||||
|
XCTAssertTrue(updatedText.exists, "Updated contractor name should appear after edit")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CON-006: Delete Contractor (integration)
|
||||||
|
|
||||||
|
func testCON006_DeleteContractor() {
|
||||||
|
// Contractor was seeded before login in seedAccountPreconditions.
|
||||||
|
guard let contractor = deleteTargetContractor else {
|
||||||
|
XCTFail("Delete target contractor was not seeded")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let deleteName = contractor.name
|
||||||
|
|
||||||
|
navigateToContractors()
|
||||||
|
|
||||||
|
// Pull to refresh until the seeded contractor is visible (increase retries for API propagation)
|
||||||
|
let target = app.staticTexts[deleteName]
|
||||||
|
pullToRefreshUntilVisible(target, maxRetries: 5)
|
||||||
|
target.waitForExistenceOrFail(timeout: loginTimeout)
|
||||||
|
|
||||||
|
// Open the contractor's detail view
|
||||||
|
target.forceTap()
|
||||||
|
|
||||||
|
// Wait for detail view to load
|
||||||
|
let detailView = app.otherElements[AccessibilityIdentifiers.Contractor.detailView]
|
||||||
|
_ = detailView.waitForExistence(timeout: defaultTimeout)
|
||||||
|
|
||||||
|
// Tap the ellipsis menu button
|
||||||
|
// SwiftUI Menu can be a button, popUpButton, or image
|
||||||
|
let menuButton = app.buttons[AccessibilityIdentifiers.Contractor.menuButton]
|
||||||
|
let menuImage = app.images[AccessibilityIdentifiers.Contractor.menuButton]
|
||||||
|
let menuPopUp = app.popUpButtons.firstMatch
|
||||||
|
|
||||||
|
if menuButton.waitForExistence(timeout: 5) {
|
||||||
|
menuButton.forceTap()
|
||||||
|
} else if menuImage.waitForExistence(timeout: 3) {
|
||||||
|
menuImage.forceTap()
|
||||||
|
} else if menuPopUp.waitForExistence(timeout: 3) {
|
||||||
|
menuPopUp.forceTap()
|
||||||
|
} else {
|
||||||
|
// Debug: dump nav bar buttons to understand what's available
|
||||||
|
let navButtons = app.navigationBars.buttons.allElementsBoundByIndex
|
||||||
|
let navButtonInfo = navButtons.prefix(10).map { "[\($0.identifier)|\($0.label)]" }
|
||||||
|
let allButtons = app.buttons.allElementsBoundByIndex
|
||||||
|
let buttonInfo = allButtons.prefix(15).map { "[\($0.identifier)|\($0.label)]" }
|
||||||
|
XCTFail("Could not find menu button. Nav bar buttons: \(navButtonInfo). All buttons: \(buttonInfo)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find and tap "Delete" in the menu popup
|
||||||
|
let deleteButton = app.buttons[AccessibilityIdentifiers.Contractor.deleteButton]
|
||||||
|
if deleteButton.waitForExistence(timeout: defaultTimeout) {
|
||||||
|
deleteButton.forceTap()
|
||||||
|
} else {
|
||||||
|
let anyDelete = app.buttons.containing(
|
||||||
|
NSPredicate(format: "label CONTAINS[c] 'Delete'")
|
||||||
|
).firstMatch
|
||||||
|
anyDelete.waitForExistenceOrFail(timeout: 5)
|
||||||
|
anyDelete.forceTap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm the delete in the alert
|
||||||
|
let alert = app.alerts.firstMatch
|
||||||
|
alert.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||||
|
|
||||||
|
let deleteLabel = alert.buttons["Delete"]
|
||||||
|
if deleteLabel.waitForExistence(timeout: 3) {
|
||||||
|
deleteLabel.tap()
|
||||||
|
} else {
|
||||||
|
// Fallback: tap any button containing "Delete"
|
||||||
|
let anyDeleteBtn = alert.buttons.containing(
|
||||||
|
NSPredicate(format: "label CONTAINS[c] 'Delete'")
|
||||||
|
).firstMatch
|
||||||
|
if anyDeleteBtn.exists {
|
||||||
|
anyDeleteBtn.tap()
|
||||||
|
} else {
|
||||||
|
// Last resort: tap the last button (destructive buttons are last)
|
||||||
|
let count = alert.buttons.count
|
||||||
|
alert.buttons.element(boundBy: count > 0 ? count - 1 : 0).tap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the detail view to dismiss and return to list
|
||||||
|
_ = detailView.waitForNonExistence(timeout: loginTimeout)
|
||||||
|
|
||||||
|
// Pull to refresh in case the list didn't auto-update
|
||||||
|
pullToRefresh()
|
||||||
|
|
||||||
|
// Verify the contractor is no longer visible
|
||||||
|
let deletedContractor = app.staticTexts[deleteName]
|
||||||
|
XCTAssertTrue(
|
||||||
|
deletedContractor.waitForNonExistence(timeout: loginTimeout),
|
||||||
|
"Deleted contractor should no longer appear"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// A throwaway, fully-isolated test account.
|
||||||
|
///
|
||||||
|
/// The unit of isolation that lets suites run in parallel without sharing
|
||||||
|
/// state: each test mints its own unique, pre-verified Kratos identity, drives
|
||||||
|
/// the app's login UI as that identity, seeds data under its own token, and
|
||||||
|
/// deletes the identity in teardown — which cascades all of its data and
|
||||||
|
/// clears the Kratos identity in one call.
|
||||||
|
///
|
||||||
|
/// Email format is collision-proof so parallel workers never overlap, and
|
||||||
|
/// carries a recognizable prefix so `SweepFixture` can find leaked accounts:
|
||||||
|
/// uit_<domain>_<uuid>@test.honeydue.local
|
||||||
|
struct TestAccount {
|
||||||
|
let username: String
|
||||||
|
let email: String
|
||||||
|
let password: String
|
||||||
|
let session: TestSession
|
||||||
|
|
||||||
|
var token: String { session.token }
|
||||||
|
|
||||||
|
// MARK: - Identity generation
|
||||||
|
|
||||||
|
/// Recognizable prefix for every generated account, so leaks are findable.
|
||||||
|
static let emailPrefix = "uit_"
|
||||||
|
/// Domain used for all generated test accounts (never a real mailbox).
|
||||||
|
static let emailDomain = "test.honeydue.local"
|
||||||
|
|
||||||
|
static func uniqueEmail(domain: String) -> String {
|
||||||
|
let slug = domain.lowercased().replacingOccurrences(of: " ", with: "-")
|
||||||
|
let unique = UUID().uuidString.prefix(12).lowercased()
|
||||||
|
return "\(emailPrefix)\(slug)_\(unique)@\(emailDomain)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if an email belongs to the generated test-account namespace.
|
||||||
|
static func isGenerated(_ email: String) -> Bool {
|
||||||
|
email.hasPrefix(emailPrefix) && email.hasSuffix("@\(emailDomain)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
|
/// Create a pre-verified, ready-to-use account via the Kratos admin API.
|
||||||
|
/// The identity is verified up front so login lands straight on the main
|
||||||
|
/// tabs (no email-verification gate). Fails the test if creation fails.
|
||||||
|
@discardableResult
|
||||||
|
static func create(
|
||||||
|
domain: String,
|
||||||
|
verified: Bool = true,
|
||||||
|
file: StaticString = #filePath,
|
||||||
|
line: UInt = #line
|
||||||
|
) -> TestAccount {
|
||||||
|
let email = uniqueEmail(domain: domain)
|
||||||
|
let username = String(email.split(separator: "@").first ?? "uituser")
|
||||||
|
let password = "UitPass123!"
|
||||||
|
|
||||||
|
let session: TestSession?
|
||||||
|
if verified {
|
||||||
|
session = TestAccountAPIClient.createVerifiedAccount(
|
||||||
|
username: username, email: email, password: password
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
session = TestAccountAPIClient.createUnverifiedAccount(
|
||||||
|
username: username, email: email, password: password
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let session else {
|
||||||
|
XCTFail("Failed to create isolated test account \(email)", file: file, line: line)
|
||||||
|
preconditionFailure("account creation failed — see XCTFail above")
|
||||||
|
}
|
||||||
|
return TestAccount(username: username, email: email, password: password, session: session)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the Kratos identity (cascades all app data). Best-effort —
|
||||||
|
/// never fails a test, since teardown cleanup should not mask the result.
|
||||||
|
func delete() {
|
||||||
|
_ = TestAccountAPIClient.deleteKratosIdentity(email: email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UI login
|
||||||
|
|
||||||
|
/// Drive the app's login screen as this account and wait for the main tabs.
|
||||||
|
/// Assumes the app is on (or can reach) the standalone login screen.
|
||||||
|
func login(
|
||||||
|
into app: XCUIApplication,
|
||||||
|
timeout: TimeInterval,
|
||||||
|
file: StaticString = #filePath,
|
||||||
|
line: UInt = #line
|
||||||
|
) {
|
||||||
|
UITestHelpers.ensureOnLoginScreen(app: app)
|
||||||
|
|
||||||
|
let login = LoginScreenObject(app: app)
|
||||||
|
login.waitForLoad(timeout: timeout)
|
||||||
|
login.enterUsername(email) // Kratos identifier is the email
|
||||||
|
login.enterPassword(password)
|
||||||
|
|
||||||
|
let loginButton = app.buttons[AccessibilityIdentifiers.Authentication.loginButton]
|
||||||
|
loginButton.waitForExistenceOrFail(timeout: timeout, file: file, line: line)
|
||||||
|
loginButton.tap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Seeding (under this account's own token)
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func seedResidence(name: String? = nil) -> TestResidence {
|
||||||
|
TestDataSeeder.createResidence(token: token, name: name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func seedResidenceWithAddress(name: String? = nil) -> TestResidence {
|
||||||
|
TestDataSeeder.createResidenceWithAddress(token: token, name: name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func seedTask(residenceId: Int, title: String? = nil, fields: [String: Any] = [:]) -> TestTask {
|
||||||
|
TestDataSeeder.createTask(token: token, residenceId: residenceId, title: title, fields: fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func seedContractor(name: String? = nil, fields: [String: Any] = [:]) -> TestContractor {
|
||||||
|
TestDataSeeder.createContractor(token: token, name: name, fields: fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func seedDocument(residenceId: Int, title: String? = nil, documentType: String = "general") -> TestDocument {
|
||||||
|
TestDataSeeder.createDocument(token: token, residenceId: residenceId, title: title, documentType: documentType)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
|
|
||||||
/// Critical path tests for authentication flows.
|
|
||||||
/// Tests login, logout, registration entry, forgot password entry.
|
|
||||||
final class AuthCriticalPathTests: BaseUITestCase {
|
|
||||||
override var relaunchBetweenTests: Bool { true }
|
|
||||||
|
|
||||||
// MARK: - Helpers
|
|
||||||
|
|
||||||
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)
|
|
||||||
login.enterUsername("testuser")
|
|
||||||
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: - 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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
-1
@@ -1,6 +1,8 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
final class AccessibilityTests: BaseUITestCase {
|
/// Accessibility coverage for the logged-OUT onboarding + login surface.
|
||||||
|
/// Runs on BaseUITestCase (no auth) — these flows navigate from onboarding.
|
||||||
|
final class AccessibilityUITests: BaseUITestCase {
|
||||||
override var relaunchBetweenTests: Bool { true }
|
override var relaunchBetweenTests: Bool { true }
|
||||||
func testA001_OnboardingPrimaryControlsAreReachable() {
|
func testA001_OnboardingPrimaryControlsAreReachable() {
|
||||||
let welcome = OnboardingWelcomeScreen(app: app)
|
let welcome = OnboardingWelcomeScreen(app: app)
|
||||||
+3
-2
@@ -1,6 +1,8 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
final class StabilityTests: BaseUITestCase {
|
/// Stability coverage: repeated/rapid navigation through the logged-OUT
|
||||||
|
/// onboarding + login flows should not crash or corrupt app state.
|
||||||
|
final class StabilityUITests: BaseUITestCase {
|
||||||
func testP001_RapidOnboardingNavigationDoesNotCrash() {
|
func testP001_RapidOnboardingNavigationDoesNotCrash() {
|
||||||
for _ in 0..<3 {
|
for _ in 0..<3 {
|
||||||
let welcome = OnboardingWelcomeScreen(app: app)
|
let welcome = OnboardingWelcomeScreen(app: app)
|
||||||
@@ -95,5 +97,4 @@ final class StabilityTests: BaseUITestCase {
|
|||||||
welcome.waitForLoad(timeout: defaultTimeout)
|
welcome.waitForLoad(timeout: defaultTimeout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+41
-451
@@ -1,25 +1,32 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
/// Comprehensive documents and warranties testing suite covering all scenarios, edge cases, and variations
|
/// Document warranty UI test suite (warranty-specific lifecycle and filters).
|
||||||
/// Tests both document types (permits, receipts, etc.) and warranties with filtering, searching, and CRUD operations
|
///
|
||||||
final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
|
/// Holds the warranty-aspect tests split out from the former
|
||||||
|
/// `Suite8_DocumentWarrantyTests`: warranty creation (all fields / future /
|
||||||
|
/// expired / special chars), warranty detail with dates, warranty edit, warranty
|
||||||
|
/// delete, category + active-only filters, the empty-warranties state, and the
|
||||||
|
/// combined-filters scenario. The generic document CRUD tests live in
|
||||||
|
/// `DocumentCRUDUITests`.
|
||||||
|
///
|
||||||
|
/// Warranties are created through the document form which gates on a residence,
|
||||||
|
/// so `requiresResidence = true` seeds one "Precondition Home" residence before
|
||||||
|
/// login (the app loads it on its post-login fetch). All tests here create their
|
||||||
|
/// warranty through the UI, so no pre-seeded document is needed.
|
||||||
|
final class DocumentWarrantyUITests: AuthenticatedUITestCase {
|
||||||
|
|
||||||
override var needsAPISession: Bool { true }
|
override var requiresResidence: Bool { true }
|
||||||
|
|
||||||
// Test data tracking
|
// MARK: - Page Objects
|
||||||
var createdDocumentTitles: [String] = []
|
|
||||||
var currentResidenceId: Int32?
|
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
private var docList: DocumentListScreen { DocumentListScreen(app: app) }
|
||||||
try super.setUpWithError()
|
private var docForm: DocumentFormScreen { DocumentFormScreen(app: app) }
|
||||||
|
|
||||||
// Ensure at least one residence exists via API (required for property picker)
|
// MARK: - Helpers
|
||||||
ensureResidenceExists()
|
|
||||||
|
|
||||||
// Dismiss any form left open by a previous test
|
|
||||||
let cancelBtn = app.buttons[AccessibilityIdentifiers.Document.formCancelButton]
|
|
||||||
if cancelBtn.exists { cancelBtn.tap() }
|
|
||||||
|
|
||||||
|
/// Navigate to the Documents tab, load residence data into the DataManager
|
||||||
|
/// cache (so the property picker is populated), and prime the form once.
|
||||||
|
private func prepareDocumentsScreen() {
|
||||||
// Visit Residences tab to load residence data into DataManager cache
|
// Visit Residences tab to load residence data into DataManager cache
|
||||||
navigateToResidences()
|
navigateToResidences()
|
||||||
pullToRefresh()
|
pullToRefresh()
|
||||||
@@ -37,28 +44,6 @@ final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tearDownWithError() throws {
|
|
||||||
// Track all created documents for API cleanup before super.tearDown runs cleaner.cleanAll()
|
|
||||||
if !createdDocumentTitles.isEmpty,
|
|
||||||
let allDocs = TestAccountAPIClient.listDocuments(token: session.token) {
|
|
||||||
for title in createdDocumentTitles {
|
|
||||||
if let doc = allDocs.first(where: { $0.title.contains(title) }) {
|
|
||||||
cleaner.trackDocument(doc.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
createdDocumentTitles.removeAll()
|
|
||||||
currentResidenceId = nil
|
|
||||||
try super.tearDownWithError()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Page Objects
|
|
||||||
|
|
||||||
private var docList: DocumentListScreen { DocumentListScreen(app: app) }
|
|
||||||
private var docForm: DocumentFormScreen { DocumentFormScreen(app: app) }
|
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
|
||||||
|
|
||||||
private func openDocumentForm(file: StaticString = #filePath, line: UInt = #line) {
|
private func openDocumentForm(file: StaticString = #filePath, line: UInt = #line) {
|
||||||
let addButton = docList.addButton
|
let addButton = docList.addButton
|
||||||
XCTAssertTrue(addButton.exists && addButton.isEnabled, "Add button should exist and be enabled", file: file, line: line)
|
XCTAssertTrue(addButton.exists && addButton.isEnabled, "Add button should exist and be enabled", file: file, line: line)
|
||||||
@@ -86,8 +71,7 @@ final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
|
|||||||
pickerButton.tap()
|
pickerButton.tap()
|
||||||
|
|
||||||
// Fast path: the residence option is often rendered as a plain Button
|
// Fast path: the residence option is often rendered as a plain Button
|
||||||
// or StaticText whose label is the residence name itself. Finding it
|
// or StaticText whose label is the residence name itself.
|
||||||
// by text works across menu, list, and wheel picker variants.
|
|
||||||
if let name = residenceName {
|
if let name = residenceName {
|
||||||
let byButton = app.buttons[name].firstMatch
|
let byButton = app.buttons[name].firstMatch
|
||||||
if byButton.waitForExistence(timeout: 3) && byButton.isHittable {
|
if byButton.waitForExistence(timeout: 3) && byButton.isHittable {
|
||||||
@@ -104,16 +88,9 @@ final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SwiftUI Picker in Form renders either a menu (iOS 18+ default) or a
|
// SwiftUI Picker in Form renders either a menu (iOS 18+ default) or a
|
||||||
// pushed selection list. Detecting the menu requires a slightly longer
|
// pushed selection list.
|
||||||
// wait because the dropdown animates in after the tap. Also: the form
|
|
||||||
// rows themselves are `cells`, so we can't use `cells.firstMatch` to
|
|
||||||
// detect list mode — we must wait longer for a real menu before
|
|
||||||
// falling back.
|
|
||||||
let menuItem = app.menuItems.firstMatch
|
let menuItem = app.menuItems.firstMatch
|
||||||
// Give the menu a bit longer to animate; 5s covers the usual case.
|
|
||||||
if menuItem.waitForExistence(timeout: 5) {
|
if menuItem.waitForExistence(timeout: 5) {
|
||||||
// Tap the last menu item (the residence option; the placeholder is
|
|
||||||
// index 0 and carries the "Select a Property" label).
|
|
||||||
let allItems = app.menuItems.allElementsBoundByIndex
|
let allItems = app.menuItems.allElementsBoundByIndex
|
||||||
let target = allItems.last ?? menuItem
|
let target = allItems.last ?? menuItem
|
||||||
if target.isHittable {
|
if target.isHittable {
|
||||||
@@ -121,15 +98,10 @@ final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
|
|||||||
} else {
|
} else {
|
||||||
target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||||
}
|
}
|
||||||
// Ensure the menu actually dismissed; a lingering overlay blocks
|
|
||||||
// hit-testing on the form below.
|
|
||||||
_ = app.menuItems.firstMatch.waitForNonExistence(timeout: 2)
|
_ = app.menuItems.firstMatch.waitForNonExistence(timeout: 2)
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
// List-style picker — find a cell/row with a residence name.
|
// List-style picker — find a cell/row with a residence name.
|
||||||
// Cells can take a moment to become hittable during the push
|
|
||||||
// animation; retry the tap until the picker dismisses (titleField
|
|
||||||
// reappears on the form) or the attempt budget runs out.
|
|
||||||
let cells = app.cells
|
let cells = app.cells
|
||||||
guard cells.firstMatch.waitForExistence(timeout: navigationTimeout) else {
|
guard cells.firstMatch.waitForExistence(timeout: navigationTimeout) else {
|
||||||
XCTFail("No residence options appeared in picker", file: file, line: line)
|
XCTFail("No residence options appeared in picker", file: file, line: line)
|
||||||
@@ -151,7 +123,6 @@ final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
|
|||||||
targetCell.tap()
|
targetCell.tap()
|
||||||
if docForm.titleField.waitForExistence(timeout: 2) { break }
|
if docForm.titleField.waitForExistence(timeout: 2) { break }
|
||||||
}
|
}
|
||||||
// Reopen picker if it dismissed without selection.
|
|
||||||
if docForm.titleField.exists, attempt < 4, pickerButton.exists, pickerButton.isHittable {
|
if docForm.titleField.exists, attempt < 4, pickerButton.exists, pickerButton.isHittable {
|
||||||
pickerButton.tap()
|
pickerButton.tap()
|
||||||
_ = cells.firstMatch.waitForExistence(timeout: 3)
|
_ = cells.firstMatch.waitForExistence(timeout: 3)
|
||||||
@@ -163,28 +134,6 @@ final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
|
|||||||
_ = docForm.titleField.waitForExistence(timeout: navigationTimeout)
|
_ = docForm.titleField.waitForExistence(timeout: navigationTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func selectDocumentType(type: String) {
|
|
||||||
let typePicker = app.buttons[AccessibilityIdentifiers.Document.typePicker].firstMatch
|
|
||||||
if typePicker.exists {
|
|
||||||
typePicker.tap()
|
|
||||||
|
|
||||||
let typeButton = app.buttons[type]
|
|
||||||
if typeButton.waitForExistence(timeout: defaultTimeout) {
|
|
||||||
typeButton.tap()
|
|
||||||
} 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()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func submitForm(file: StaticString = #filePath, line: UInt = #line) {
|
private func submitForm(file: StaticString = #filePath, line: UInt = #line) {
|
||||||
// Dismiss keyboard by tapping outside form fields
|
// Dismiss keyboard by tapping outside form fields
|
||||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15)).tap()
|
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15)).tap()
|
||||||
@@ -203,14 +152,12 @@ final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
|
|||||||
}
|
}
|
||||||
XCTAssertTrue(submitButton.exists && submitButton.isEnabled, "Submit button should exist and be enabled", file: file, line: line)
|
XCTAssertTrue(submitButton.exists && submitButton.isEnabled, "Submit button should exist and be enabled", file: file, line: line)
|
||||||
|
|
||||||
// First tap attempt
|
|
||||||
if submitButton.isHittable {
|
if submitButton.isHittable {
|
||||||
submitButton.tap()
|
submitButton.tap()
|
||||||
} else {
|
} else {
|
||||||
submitButton.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
submitButton.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for form to dismiss — retry tap if button doesn't disappear
|
|
||||||
if !submitButton.waitForNonExistence(timeout: loginTimeout) && submitButton.exists {
|
if !submitButton.waitForNonExistence(timeout: loginTimeout) && submitButton.exists {
|
||||||
submitButton.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
submitButton.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||||
_ = submitButton.waitForNonExistence(timeout: loginTimeout)
|
_ = submitButton.waitForNonExistence(timeout: loginTimeout)
|
||||||
@@ -243,16 +190,6 @@ final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
|
|||||||
app.segmentedControls.buttons["Warranties"].firstMatch.tap()
|
app.segmentedControls.buttons["Warranties"].firstMatch.tap()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func switchToDocumentsTab() {
|
|
||||||
let documentsButton = app.buttons["Documents"].firstMatch
|
|
||||||
if documentsButton.waitForExistence(timeout: navigationTimeout) {
|
|
||||||
documentsButton.tap()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Fallback: segmented control button
|
|
||||||
app.segmentedControls.buttons["Documents"].firstMatch.tap()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func searchFor(text: String) {
|
private func searchFor(text: String) {
|
||||||
let searchField = app.searchFields.firstMatch
|
let searchField = app.searchFields.firstMatch
|
||||||
if searchField.exists {
|
if searchField.exists {
|
||||||
@@ -311,114 +248,15 @@ final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
|
|||||||
_ = app.cells.firstMatch.waitForExistence(timeout: defaultTimeout)
|
_ = app.cells.firstMatch.waitForExistence(timeout: defaultTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Test Cases
|
// MARK: - Warranty Creation Tests
|
||||||
|
|
||||||
// MARK: Navigation Tests
|
|
||||||
|
|
||||||
func test01_NavigateToDocumentsScreen() {
|
|
||||||
navigateToDocuments()
|
|
||||||
|
|
||||||
// Verify we're on documents screen by checking for the segmented control tabs
|
|
||||||
let warrantiesTab = app.buttons["Warranties"]
|
|
||||||
let documentsTab = app.buttons["Documents"]
|
|
||||||
let warrantiesExists = warrantiesTab.waitForExistence(timeout: navigationTimeout)
|
|
||||||
let documentsExists = documentsTab.waitForExistence(timeout: defaultTimeout)
|
|
||||||
XCTAssertTrue(warrantiesExists || documentsExists, "Should see tab switcher on Documents screen")
|
|
||||||
}
|
|
||||||
|
|
||||||
func test02_SwitchBetweenWarrantiesAndDocuments() {
|
|
||||||
navigateToDocuments()
|
|
||||||
|
|
||||||
// Start on warranties tab
|
|
||||||
switchToWarrantiesTab()
|
|
||||||
|
|
||||||
// Switch to documents tab
|
|
||||||
switchToDocumentsTab()
|
|
||||||
|
|
||||||
// Switch back to warranties
|
|
||||||
switchToWarrantiesTab()
|
|
||||||
|
|
||||||
// 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() {
|
|
||||||
navigateToDocuments()
|
|
||||||
switchToDocumentsTab()
|
|
||||||
|
|
||||||
openDocumentForm()
|
|
||||||
|
|
||||||
let testTitle = "Test Permit \(UUID().uuidString.prefix(8))"
|
|
||||||
createdDocumentTitles.append(testTitle)
|
|
||||||
|
|
||||||
// Fill required fields (document type — no warranty fields needed)
|
|
||||||
selectProperty()
|
|
||||||
docForm.titleField.focusAndType(testTitle, app: app)
|
|
||||||
|
|
||||||
submitForm()
|
|
||||||
|
|
||||||
// Verify document appears in list
|
|
||||||
let documentCard = app.staticTexts[testTitle]
|
|
||||||
XCTAssertTrue(documentCard.waitForExistence(timeout: navigationTimeout), "Created document should appear in list")
|
|
||||||
}
|
|
||||||
|
|
||||||
func test04_CreateDocumentWithMinimalFields() {
|
|
||||||
navigateToDocuments()
|
|
||||||
switchToDocumentsTab()
|
|
||||||
|
|
||||||
openDocumentForm()
|
|
||||||
|
|
||||||
let testTitle = "Min Doc \(UUID().uuidString.prefix(8))"
|
|
||||||
createdDocumentTitles.append(testTitle)
|
|
||||||
|
|
||||||
// Fill required fields (document type — title + property)
|
|
||||||
selectProperty()
|
|
||||||
docForm.titleField.focusAndType(testTitle, app: app)
|
|
||||||
|
|
||||||
submitForm()
|
|
||||||
|
|
||||||
// Verify document appears
|
|
||||||
let documentCard = app.staticTexts[testTitle]
|
|
||||||
XCTAssertTrue(documentCard.waitForExistence(timeout: navigationTimeout), "Document with minimal fields should appear")
|
|
||||||
}
|
|
||||||
|
|
||||||
func test05_CreateDocumentWithEmptyTitle_ShouldFail() {
|
|
||||||
navigateToDocuments()
|
|
||||||
switchToDocumentsTab()
|
|
||||||
|
|
||||||
openDocumentForm()
|
|
||||||
|
|
||||||
// Try to submit without title
|
|
||||||
selectProperty()
|
|
||||||
selectDocumentType(type: "Insurance")
|
|
||||||
|
|
||||||
let submitButton = app.buttons[AccessibilityIdentifiers.Document.saveButton].firstMatch
|
|
||||||
|
|
||||||
// Submit button should be disabled or show error
|
|
||||||
if submitButton.exists && submitButton.isEnabled {
|
|
||||||
submitButton.tap()
|
|
||||||
|
|
||||||
// Should show error message
|
|
||||||
let errorMessage = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'required' OR label CONTAINS[c] 'title'")).firstMatch
|
|
||||||
XCTAssertTrue(errorMessage.waitForExistence(timeout: defaultTimeout), "Should show validation error for missing title")
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelForm()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Warranty Creation Tests
|
|
||||||
|
|
||||||
func test06_CreateWarrantyWithAllFields() {
|
func test06_CreateWarrantyWithAllFields() {
|
||||||
navigateToDocuments()
|
prepareDocumentsScreen()
|
||||||
switchToWarrantiesTab()
|
switchToWarrantiesTab()
|
||||||
|
|
||||||
openDocumentForm()
|
openDocumentForm()
|
||||||
|
|
||||||
let testTitle = "Test Warranty \(UUID().uuidString.prefix(8))"
|
let testTitle = "Test Warranty \(UUID().uuidString.prefix(8))"
|
||||||
createdDocumentTitles.append(testTitle)
|
|
||||||
|
|
||||||
// Fill all warranty fields (including required fields)
|
// Fill all warranty fields (including required fields)
|
||||||
selectProperty()
|
selectProperty()
|
||||||
@@ -440,13 +278,12 @@ final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func test07_CreateWarrantyWithFutureDates() {
|
func test07_CreateWarrantyWithFutureDates() {
|
||||||
navigateToDocuments()
|
prepareDocumentsScreen()
|
||||||
switchToWarrantiesTab()
|
switchToWarrantiesTab()
|
||||||
|
|
||||||
openDocumentForm()
|
openDocumentForm()
|
||||||
|
|
||||||
let testTitle = "Future Warranty \(UUID().uuidString.prefix(8))"
|
let testTitle = "Future Warranty \(UUID().uuidString.prefix(8))"
|
||||||
createdDocumentTitles.append(testTitle)
|
|
||||||
|
|
||||||
selectProperty()
|
selectProperty()
|
||||||
docForm.titleField.focusAndType(testTitle, app: app)
|
docForm.titleField.focusAndType(testTitle, app: app)
|
||||||
@@ -462,13 +299,12 @@ final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func test08_CreateExpiredWarranty() {
|
func test08_CreateExpiredWarranty() {
|
||||||
navigateToDocuments()
|
prepareDocumentsScreen()
|
||||||
switchToWarrantiesTab()
|
switchToWarrantiesTab()
|
||||||
|
|
||||||
openDocumentForm()
|
openDocumentForm()
|
||||||
|
|
||||||
let testTitle = "Expired Warranty \(UUID().uuidString.prefix(8))"
|
let testTitle = "Expired Warranty \(UUID().uuidString.prefix(8))"
|
||||||
createdDocumentTitles.append(testTitle)
|
|
||||||
|
|
||||||
selectProperty()
|
selectProperty()
|
||||||
docForm.titleField.focusAndType(testTitle, app: app)
|
docForm.titleField.focusAndType(testTitle, app: app)
|
||||||
@@ -487,33 +323,10 @@ final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
|
|||||||
XCTAssertTrue(warrantyCard.exists, "Expired warranty should be created and visible when filter is off")
|
XCTAssertTrue(warrantyCard.exists, "Expired warranty should be created and visible when filter is off")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Search and Filter Tests
|
// MARK: - Search and Filter Tests (warranty-side)
|
||||||
|
|
||||||
func test09_SearchDocumentsByTitle() {
|
|
||||||
navigateToDocuments()
|
|
||||||
switchToDocumentsTab()
|
|
||||||
|
|
||||||
// Create a test document first
|
|
||||||
openDocumentForm()
|
|
||||||
let searchableTitle = "Searchable Doc \(UUID().uuidString.prefix(8))"
|
|
||||||
createdDocumentTitles.append(searchableTitle)
|
|
||||||
selectProperty()
|
|
||||||
docForm.titleField.focusAndType(searchableTitle, app: app)
|
|
||||||
selectDocumentType(type: "Insurance")
|
|
||||||
submitForm()
|
|
||||||
|
|
||||||
// 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() {
|
func test10_FilterWarrantiesByCategory() {
|
||||||
navigateToDocuments()
|
prepareDocumentsScreen()
|
||||||
switchToWarrantiesTab()
|
switchToWarrantiesTab()
|
||||||
|
|
||||||
// Apply category filter — if filter button is not found, the test
|
// Apply category filter — if filter button is not found, the test
|
||||||
@@ -531,27 +344,8 @@ final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
|
|||||||
// If filter was not applied (button not found), test passes — no crash happened
|
// If filter was not applied (button not found), test passes — no crash happened
|
||||||
}
|
}
|
||||||
|
|
||||||
func test11_FilterDocumentsByType() {
|
|
||||||
navigateToDocuments()
|
|
||||||
switchToDocumentsTab()
|
|
||||||
|
|
||||||
// Apply type filter — if filter button is not found, the test
|
|
||||||
// still passes (verifies no crash). Only assert when the filter was applied.
|
|
||||||
let filterApplied = applyFilter(filterName: "Permit")
|
|
||||||
|
|
||||||
if filterApplied {
|
|
||||||
// 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")
|
|
||||||
}
|
|
||||||
// If filter was not applied (button not found), test passes — no crash happened
|
|
||||||
}
|
|
||||||
|
|
||||||
func test12_ToggleActiveWarrantiesFilter() {
|
func test12_ToggleActiveWarrantiesFilter() {
|
||||||
navigateToDocuments()
|
prepareDocumentsScreen()
|
||||||
switchToWarrantiesTab()
|
switchToWarrantiesTab()
|
||||||
|
|
||||||
// Toggle active filter off
|
// Toggle active filter off
|
||||||
@@ -565,44 +359,15 @@ final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
|
|||||||
XCTAssertTrue(warrantiesTab.exists, "Active filter toggle should work without crashing")
|
XCTAssertTrue(warrantiesTab.exists, "Active filter toggle should work without crashing")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Document Detail Tests
|
// MARK: - Warranty Detail Tests
|
||||||
|
|
||||||
func test13_ViewDocumentDetail() {
|
|
||||||
navigateToDocuments()
|
|
||||||
switchToDocumentsTab()
|
|
||||||
|
|
||||||
// Create a document
|
|
||||||
openDocumentForm()
|
|
||||||
let testTitle = "Detail Test Doc \(UUID().uuidString.prefix(8))"
|
|
||||||
createdDocumentTitles.append(testTitle)
|
|
||||||
selectProperty()
|
|
||||||
docForm.titleField.focusAndType(testTitle, app: app)
|
|
||||||
selectDocumentType(type: "Insurance")
|
|
||||||
fillTextEditor(text: "This is a test receipt with details")
|
|
||||||
submitForm()
|
|
||||||
|
|
||||||
// Tap on the document card
|
|
||||||
let documentCard = app.staticTexts[testTitle]
|
|
||||||
XCTAssertTrue(documentCard.waitForExistence(timeout: navigationTimeout), "Document should exist in list")
|
|
||||||
documentCard.tap()
|
|
||||||
|
|
||||||
// Should show detail screen
|
|
||||||
let detailTitle = app.staticTexts[testTitle]
|
|
||||||
XCTAssertTrue(detailTitle.waitForExistence(timeout: defaultTimeout), "Should show document detail screen")
|
|
||||||
|
|
||||||
// Go back
|
|
||||||
let backButton = app.navigationBars.buttons.firstMatch
|
|
||||||
backButton.tap()
|
|
||||||
}
|
|
||||||
|
|
||||||
func test14_ViewWarrantyDetailWithDates() {
|
func test14_ViewWarrantyDetailWithDates() {
|
||||||
navigateToDocuments()
|
prepareDocumentsScreen()
|
||||||
switchToWarrantiesTab()
|
switchToWarrantiesTab()
|
||||||
|
|
||||||
// Create a warranty
|
// Create a warranty
|
||||||
openDocumentForm()
|
openDocumentForm()
|
||||||
let testTitle = "Warranty Detail Test \(UUID().uuidString.prefix(8))"
|
let testTitle = "Warranty Detail Test \(UUID().uuidString.prefix(8))"
|
||||||
createdDocumentTitles.append(testTitle)
|
|
||||||
selectProperty()
|
selectProperty()
|
||||||
docForm.titleField.focusAndType(testTitle, app: app)
|
docForm.titleField.focusAndType(testTitle, app: app)
|
||||||
fillTextField(identifier: AccessibilityIdentifiers.Document.itemNameField, text: "Test Appliance") // REQUIRED
|
fillTextField(identifier: AccessibilityIdentifiers.Document.itemNameField, text: "Test Appliance") // REQUIRED
|
||||||
@@ -624,58 +389,15 @@ final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
|
|||||||
app.navigationBars.buttons.firstMatch.tap()
|
app.navigationBars.buttons.firstMatch.tap()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Edit Tests
|
// MARK: - Edit Tests (warranty-side)
|
||||||
|
|
||||||
func test15_EditDocumentTitle() {
|
|
||||||
navigateToDocuments()
|
|
||||||
switchToDocumentsTab()
|
|
||||||
|
|
||||||
// Create document
|
|
||||||
openDocumentForm()
|
|
||||||
let originalTitle = "Edit Test \(UUID().uuidString.prefix(8))"
|
|
||||||
createdDocumentTitles.append(originalTitle)
|
|
||||||
selectProperty()
|
|
||||||
docForm.titleField.focusAndType(originalTitle, app: app)
|
|
||||||
selectDocumentType(type: "Insurance")
|
|
||||||
submitForm()
|
|
||||||
|
|
||||||
// Open detail
|
|
||||||
let documentCard = app.staticTexts[originalTitle]
|
|
||||||
XCTAssertTrue(documentCard.waitForExistence(timeout: navigationTimeout), "Document should exist")
|
|
||||||
documentCard.tap()
|
|
||||||
|
|
||||||
// Tap edit button
|
|
||||||
let editButton = app.buttons[AccessibilityIdentifiers.Document.editButton].firstMatch
|
|
||||||
if editButton.waitForExistence(timeout: defaultTimeout) {
|
|
||||||
editButton.tap()
|
|
||||||
|
|
||||||
// Change title using the accessibility identifier
|
|
||||||
let titleField = app.textFields[AccessibilityIdentifiers.Document.titleField].firstMatch
|
|
||||||
if titleField.waitForExistence(timeout: defaultTimeout) {
|
|
||||||
let newTitle = "Edited \(originalTitle)"
|
|
||||||
titleField.clearAndEnterText(newTitle, app: app)
|
|
||||||
createdDocumentTitles.append(newTitle)
|
|
||||||
|
|
||||||
submitForm()
|
|
||||||
|
|
||||||
// Verify new title appears
|
|
||||||
let updatedTitle = app.staticTexts[newTitle]
|
|
||||||
XCTAssertTrue(updatedTitle.waitForExistence(timeout: navigationTimeout), "Updated title should appear")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Go back to list
|
|
||||||
app.navigationBars.buttons.element(boundBy: 0).tap()
|
|
||||||
}
|
|
||||||
|
|
||||||
func test16_EditWarrantyDates() {
|
func test16_EditWarrantyDates() {
|
||||||
navigateToDocuments()
|
prepareDocumentsScreen()
|
||||||
switchToWarrantiesTab()
|
switchToWarrantiesTab()
|
||||||
|
|
||||||
// Create warranty
|
// Create warranty
|
||||||
openDocumentForm()
|
openDocumentForm()
|
||||||
let testTitle = "Edit Dates Warranty \(UUID().uuidString.prefix(8))"
|
let testTitle = "Edit Dates Warranty \(UUID().uuidString.prefix(8))"
|
||||||
createdDocumentTitles.append(testTitle)
|
|
||||||
selectProperty()
|
selectProperty()
|
||||||
docForm.titleField.focusAndType(testTitle, app: app)
|
docForm.titleField.focusAndType(testTitle, app: app)
|
||||||
fillTextField(identifier: AccessibilityIdentifiers.Document.itemNameField, text: "TV") // REQUIRED
|
fillTextField(identifier: AccessibilityIdentifiers.Document.itemNameField, text: "TV") // REQUIRED
|
||||||
@@ -703,47 +425,10 @@ final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
|
|||||||
app.navigationBars.buttons.element(boundBy: 0).tap()
|
app.navigationBars.buttons.element(boundBy: 0).tap()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Delete Tests
|
// MARK: - Delete Tests (warranty-side)
|
||||||
|
|
||||||
func test17_DeleteDocument() {
|
|
||||||
navigateToDocuments()
|
|
||||||
switchToDocumentsTab()
|
|
||||||
|
|
||||||
// Create document to delete
|
|
||||||
openDocumentForm()
|
|
||||||
let deleteTitle = "To Delete \(UUID().uuidString.prefix(8))"
|
|
||||||
selectProperty()
|
|
||||||
docForm.titleField.focusAndType(deleteTitle, app: app)
|
|
||||||
selectDocumentType(type: "Insurance")
|
|
||||||
submitForm()
|
|
||||||
|
|
||||||
// Open detail
|
|
||||||
let documentCard = app.staticTexts[deleteTitle]
|
|
||||||
XCTAssertTrue(documentCard.waitForExistence(timeout: navigationTimeout), "Document should exist")
|
|
||||||
documentCard.tap()
|
|
||||||
|
|
||||||
// Find and tap delete button
|
|
||||||
let deleteButton = app.buttons[AccessibilityIdentifiers.Document.deleteButton].firstMatch
|
|
||||||
if deleteButton.waitForExistence(timeout: defaultTimeout) {
|
|
||||||
deleteButton.tap()
|
|
||||||
|
|
||||||
// Confirm deletion
|
|
||||||
let confirmButton = app.alerts.buttons["Delete"].firstMatch
|
|
||||||
if confirmButton.waitForExistence(timeout: defaultTimeout) {
|
|
||||||
confirmButton.tap()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for navigation back to list
|
|
||||||
_ = app.navigationBars.firstMatch.waitForExistence(timeout: defaultTimeout)
|
|
||||||
|
|
||||||
// Verify document no longer exists
|
|
||||||
let deletedCard = app.staticTexts[deleteTitle]
|
|
||||||
XCTAssertTrue(deletedCard.waitForNonExistence(timeout: defaultTimeout), "Deleted document should not appear in list")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func test18_DeleteWarranty() {
|
func test18_DeleteWarranty() {
|
||||||
navigateToDocuments()
|
prepareDocumentsScreen()
|
||||||
switchToWarrantiesTab()
|
switchToWarrantiesTab()
|
||||||
|
|
||||||
// Create warranty to delete
|
// Create warranty to delete
|
||||||
@@ -777,46 +462,10 @@ final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Edge Cases and Error Handling
|
// MARK: - Edge Cases and Error Handling (warranty-side)
|
||||||
|
|
||||||
func test19_CancelDocumentCreation() {
|
|
||||||
navigateToDocuments()
|
|
||||||
switchToDocumentsTab()
|
|
||||||
|
|
||||||
openDocumentForm()
|
|
||||||
|
|
||||||
// Fill some fields
|
|
||||||
selectProperty()
|
|
||||||
docForm.titleField.focusAndType("Cancelled Document", app: app)
|
|
||||||
selectDocumentType(type: "Insurance")
|
|
||||||
|
|
||||||
// Cancel instead of save
|
|
||||||
cancelForm()
|
|
||||||
|
|
||||||
// Should not appear in list
|
|
||||||
let cancelledDoc = app.staticTexts["Cancelled Document"]
|
|
||||||
XCTAssertFalse(cancelledDoc.exists, "Cancelled document should not be created")
|
|
||||||
}
|
|
||||||
|
|
||||||
func test20_HandleEmptyDocumentsList() {
|
|
||||||
navigateToDocuments()
|
|
||||||
switchToDocumentsTab()
|
|
||||||
|
|
||||||
// Apply very specific filter to get empty list
|
|
||||||
searchFor(text: "NONEXISTENT_DOCUMENT_12345")
|
|
||||||
|
|
||||||
// Should show empty state or no items
|
|
||||||
let emptyState = app.otherElements[AccessibilityIdentifiers.Document.emptyStateView]
|
|
||||||
_ = emptyState.waitForExistence(timeout: defaultTimeout)
|
|
||||||
|
|
||||||
let hasNoItems = app.cells.count == 0
|
|
||||||
XCTAssertTrue(emptyState.exists || hasNoItems, "Should handle empty documents list gracefully")
|
|
||||||
|
|
||||||
clearSearch()
|
|
||||||
}
|
|
||||||
|
|
||||||
func test21_HandleEmptyWarrantiesList() {
|
func test21_HandleEmptyWarrantiesList() {
|
||||||
navigateToDocuments()
|
prepareDocumentsScreen()
|
||||||
switchToWarrantiesTab()
|
switchToWarrantiesTab()
|
||||||
|
|
||||||
// Search for non-existent warranty
|
// Search for non-existent warranty
|
||||||
@@ -830,42 +479,13 @@ final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
|
|||||||
clearSearch()
|
clearSearch()
|
||||||
}
|
}
|
||||||
|
|
||||||
func test22_CreateDocumentWithLongTitle() {
|
|
||||||
navigateToDocuments()
|
|
||||||
switchToDocumentsTab()
|
|
||||||
|
|
||||||
openDocumentForm()
|
|
||||||
|
|
||||||
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()
|
|
||||||
docForm.titleField.focusAndType(longTitle, app: app)
|
|
||||||
selectDocumentType(type: "Insurance")
|
|
||||||
|
|
||||||
submitForm()
|
|
||||||
|
|
||||||
// Track via API (also gives server time to process)
|
|
||||||
trackDocumentForCleanup(title: longTitle)
|
|
||||||
|
|
||||||
// Re-navigate to refresh the list after creation
|
|
||||||
navigateToDocuments()
|
|
||||||
switchToDocumentsTab()
|
|
||||||
|
|
||||||
// Verify it was created (partial match with wait)
|
|
||||||
let partialTitle = String(longTitle.prefix(30))
|
|
||||||
let documentCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] '\(partialTitle)'")).firstMatch
|
|
||||||
XCTAssertTrue(documentCard.waitForExistence(timeout: loginTimeout), "Document with long title should be created")
|
|
||||||
}
|
|
||||||
|
|
||||||
func test23_CreateWarrantyWithSpecialCharacters() {
|
func test23_CreateWarrantyWithSpecialCharacters() {
|
||||||
navigateToDocuments()
|
prepareDocumentsScreen()
|
||||||
switchToWarrantiesTab()
|
switchToWarrantiesTab()
|
||||||
|
|
||||||
openDocumentForm()
|
openDocumentForm()
|
||||||
|
|
||||||
let specialTitle = "Warranty w/ Special #Chars: @ & $ % \(UUID().uuidString.prefix(8))"
|
let specialTitle = "Warranty w/ Special #Chars: @ & $ % \(UUID().uuidString.prefix(8))"
|
||||||
createdDocumentTitles.append(specialTitle)
|
|
||||||
|
|
||||||
selectProperty()
|
selectProperty()
|
||||||
docForm.titleField.focusAndType(specialTitle, app: app)
|
docForm.titleField.focusAndType(specialTitle, app: app)
|
||||||
@@ -887,23 +507,8 @@ final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
|
|||||||
XCTAssertTrue(warrantyCard.waitForExistence(timeout: loginTimeout), "Warranty with special characters should be created")
|
XCTAssertTrue(warrantyCard.waitForExistence(timeout: loginTimeout), "Warranty with special characters should be created")
|
||||||
}
|
}
|
||||||
|
|
||||||
func test24_RapidTabSwitching() {
|
|
||||||
navigateToDocuments()
|
|
||||||
|
|
||||||
// Rapidly switch between tabs
|
|
||||||
for _ in 0..<5 {
|
|
||||||
switchToWarrantiesTab()
|
|
||||||
switchToDocumentsTab()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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() {
|
func test25_MultipleFiltersCombined() {
|
||||||
navigateToDocuments()
|
prepareDocumentsScreen()
|
||||||
switchToWarrantiesTab()
|
switchToWarrantiesTab()
|
||||||
|
|
||||||
// Apply multiple filters
|
// Apply multiple filters
|
||||||
@@ -929,18 +534,3 @@ final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
|
|||||||
toggleActiveFilter() // Turn active filter back on
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+7
-30
@@ -12,40 +12,17 @@ import XCTest
|
|||||||
///
|
///
|
||||||
/// IMPORTANT: These are integration tests requiring network connectivity.
|
/// IMPORTANT: These are integration tests requiring network connectivity.
|
||||||
/// Run against a test/dev server, NOT production.
|
/// Run against a test/dev server, NOT production.
|
||||||
final class Suite10_ComprehensiveE2ETests: AuthenticatedUITestCase {
|
///
|
||||||
|
/// Per-test isolation is provided by `AuthenticatedUITestCase`: setUp mints a
|
||||||
|
/// fresh, pre-verified Kratos account, logs in, and exposes `session`/`cleaner`/
|
||||||
|
/// `account`; tearDown deletes the account (cascading all its data). Every test
|
||||||
|
/// here creates its own residences and tasks through the UI (immediately visible),
|
||||||
|
/// so no API-seeded preconditions are needed.
|
||||||
|
final class E2EComprehensiveUITests: AuthenticatedUITestCase {
|
||||||
|
|
||||||
// Test run identifier for unique data
|
// Test run identifier for unique data
|
||||||
private let testRunId = Int(Date().timeIntervalSince1970)
|
private let testRunId = Int(Date().timeIntervalSince1970)
|
||||||
|
|
||||||
// API-created user — no UI registration needed
|
|
||||||
private var _overrideCredentials: (String, String)?
|
|
||||||
private var userToken: String?
|
|
||||||
|
|
||||||
override var testCredentials: (username: String, password: String) {
|
|
||||||
_overrideCredentials ?? ("testuser", "TestPass123!")
|
|
||||||
}
|
|
||||||
|
|
||||||
override var needsAPISession: Bool { true }
|
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
|
||||||
// Create a unique test user via API (no keyboard issues)
|
|
||||||
guard TestAccountAPIClient.isBackendReachable() else {
|
|
||||||
throw XCTSkip("Backend not reachable")
|
|
||||||
}
|
|
||||||
guard let user = TestAccountManager.createVerifiedAccount() else {
|
|
||||||
throw XCTSkip("Could not create test user via API")
|
|
||||||
}
|
|
||||||
_overrideCredentials = (user.username, user.password)
|
|
||||||
|
|
||||||
try super.setUpWithError()
|
|
||||||
|
|
||||||
// Re-login via API after UI login to get a valid token
|
|
||||||
// (UI login may invalidate the original API token)
|
|
||||||
if let freshSession = TestAccountManager.loginSeededAccount(username: user.username, password: user.password) {
|
|
||||||
userToken = freshSession.token
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
// MARK: - Helper Methods
|
||||||
|
|
||||||
/// Creates a residence with the given name
|
/// Creates a residence with the given name
|
||||||
+16
-33
@@ -12,38 +12,22 @@ import XCTest
|
|||||||
///
|
///
|
||||||
/// IMPORTANT: These tests create real data and require network connectivity.
|
/// IMPORTANT: These tests create real data and require network connectivity.
|
||||||
/// Run with a test server or dev environment (not production).
|
/// Run with a test server or dev environment (not production).
|
||||||
final class Suite9_IntegrationE2ETests: AuthenticatedUITestCase {
|
///
|
||||||
|
/// Per-test isolation is provided by `AuthenticatedUITestCase`: setUp mints a
|
||||||
|
/// fresh, pre-verified Kratos account, logs in, and exposes `session`/`cleaner`/
|
||||||
|
/// `account`; tearDown deletes the account (cascading all its data). Tests that
|
||||||
|
/// gate on a residence existing set `requiresResidence` so one is seeded BEFORE
|
||||||
|
/// login (a fresh account is otherwise empty until a manual refresh).
|
||||||
|
final class E2EIntegrationUITests: AuthenticatedUITestCase {
|
||||||
|
|
||||||
override var needsAPISession: Bool { true }
|
/// test03 creates a task through the UI, which requires at least one
|
||||||
|
/// residence to already exist (the Add Task button is disabled otherwise).
|
||||||
|
/// Seed a residence before login so the app loads it on its post-login fetch.
|
||||||
|
override var requiresResidence: Bool { true }
|
||||||
|
|
||||||
// Unique ID for test data names
|
// Unique ID for test data names
|
||||||
private let testRunId = Int(Date().timeIntervalSince1970)
|
private let testRunId = Int(Date().timeIntervalSince1970)
|
||||||
|
|
||||||
// API-created test user for tests 02-07
|
|
||||||
private var apiUser: TestSession!
|
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
|
||||||
// Create a unique test user via API (fast, reliable, no keyboard issues)
|
|
||||||
guard TestAccountAPIClient.isBackendReachable() else {
|
|
||||||
throw XCTSkip("Backend not reachable")
|
|
||||||
}
|
|
||||||
guard let user = TestAccountManager.createVerifiedAccount() else {
|
|
||||||
throw XCTSkip("Could not create test user via API")
|
|
||||||
}
|
|
||||||
apiUser = user
|
|
||||||
|
|
||||||
// Use the API-created user for UI login
|
|
||||||
_overrideCredentials = (user.username, user.password)
|
|
||||||
|
|
||||||
try super.setUpWithError()
|
|
||||||
}
|
|
||||||
|
|
||||||
private var _overrideCredentials: (String, String)?
|
|
||||||
|
|
||||||
override var testCredentials: (username: String, password: String) {
|
|
||||||
_overrideCredentials ?? ("testuser", "TestPass123!")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
// MARK: - Helper Methods
|
||||||
|
|
||||||
/// Dismiss strong password suggestion if shown
|
/// Dismiss strong password suggestion if shown
|
||||||
@@ -82,7 +66,7 @@ final class Suite9_IntegrationE2ETests: AuthenticatedUITestCase {
|
|||||||
UITestHelpers.ensureLoggedOut(app: app)
|
UITestHelpers.ensureLoggedOut(app: app)
|
||||||
let welcomeText = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
let welcomeText = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
||||||
XCTAssertTrue(welcomeText.waitForExistence(timeout: 10), "Should be on login screen")
|
XCTAssertTrue(welcomeText.waitForExistence(timeout: 10), "Should be on login screen")
|
||||||
UITestHelpers.login(app: app, username: testUser, password: testPassword)
|
UITestHelpers.login(app: app, username: testEmail, password: testPassword)
|
||||||
|
|
||||||
// Phase 3: Verify logged in
|
// Phase 3: Verify logged in
|
||||||
let tabBar = app.tabBars.firstMatch
|
let tabBar = app.tabBars.firstMatch
|
||||||
@@ -93,7 +77,7 @@ final class Suite9_IntegrationE2ETests: AuthenticatedUITestCase {
|
|||||||
XCTAssertTrue(welcomeText.waitForExistence(timeout: 10), "Should be on login screen after logout")
|
XCTAssertTrue(welcomeText.waitForExistence(timeout: 10), "Should be on login screen after logout")
|
||||||
|
|
||||||
// Phase 5: Login again to verify re-login works
|
// Phase 5: Login again to verify re-login works
|
||||||
UITestHelpers.login(app: app, username: testUser, password: testPassword)
|
UITestHelpers.login(app: app, username: testEmail, password: testPassword)
|
||||||
XCTAssertTrue(tabBar.waitForExistence(timeout: 10), "Should be logged in after re-login")
|
XCTAssertTrue(tabBar.waitForExistence(timeout: 10), "Should be logged in after re-login")
|
||||||
|
|
||||||
// Phase 6: Final logout
|
// Phase 6: Final logout
|
||||||
@@ -185,10 +169,9 @@ final class Suite9_IntegrationE2ETests: AuthenticatedUITestCase {
|
|||||||
// Already logged in via setUp — verify tab bar exists
|
// Already logged in via setUp — verify tab bar exists
|
||||||
XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: defaultTimeout), "Should be logged in")
|
XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: defaultTimeout), "Should be logged in")
|
||||||
|
|
||||||
// Ensure residence exists (precondition for task creation)
|
// Residence precondition is seeded before login (requiresResidence), so
|
||||||
if let residences = TestAccountAPIClient.listResidences(token: apiUser.token), residences.isEmpty {
|
// the Add Task button is enabled. Refresh the residences list to be sure
|
||||||
TestDataSeeder.createResidence(token: apiUser.token, name: "Task Test Home \(testRunId)")
|
// the seeded residence is loaded.
|
||||||
}
|
|
||||||
navigateToResidences()
|
navigateToResidences()
|
||||||
pullToRefresh()
|
pullToRefresh()
|
||||||
|
|
||||||
@@ -11,12 +11,64 @@ class AuthenticatedUITestCase: BaseUITestCase {
|
|||||||
|
|
||||||
var needsAPISession: Bool { false }
|
var needsAPISession: Bool { false }
|
||||||
|
|
||||||
|
/// Authenticated suites test the post-onboarding app. A freshly-seeded user
|
||||||
|
/// has no residence, so without this the app routes to the onboarding flow
|
||||||
|
/// after login instead of the main tabs. Launch with --complete-onboarding
|
||||||
|
/// (sets OnboardingState.hasCompletedOnboarding) so login lands on main tabs.
|
||||||
|
override var completeOnboarding: Bool { true }
|
||||||
|
|
||||||
|
/// Credentials for the Kratos APP identity used to seed data over the API.
|
||||||
|
///
|
||||||
|
/// ⚠️ TWO DIFFERENT "admin@honeydue.com" EXIST — do NOT "fix" Test1234 to password123:
|
||||||
|
/// (a) Kratos APP identity — admin@honeydue.com / Test1234. Created by this class's
|
||||||
|
/// `setUp` (and re-seeded by SuiteZZ). Used here for API data-seeding and login.
|
||||||
|
/// (b) Admin-PANEL SQL super-admin — admin@honeydue.com / password123. A separate
|
||||||
|
/// system, used ONLY by SuiteZZ_CleanupTests to call /admin/settings/clear-all-data.
|
||||||
|
/// They happen to share an email but are unrelated. Changing Test1234 here would break
|
||||||
|
/// all API seeding; changing password123 in SuiteZZ would break the data wipe.
|
||||||
var apiCredentials: (username: String, password: String) {
|
var apiCredentials: (username: String, password: String) {
|
||||||
("admin", "Test1234")
|
("admin", "Test1234")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Account isolation
|
||||||
|
|
||||||
|
/// When `true` (default), each test mints its OWN unique, pre-verified
|
||||||
|
/// Kratos account, logs in as it, seeds under its token, and deletes it in
|
||||||
|
/// teardown — so suites are fully independent and parallel-safe. Override to
|
||||||
|
/// `false` only in suites that must log in as a SPECIFIC seeded account
|
||||||
|
/// (then also override `testCredentials`).
|
||||||
|
var usesFreshAccount: Bool { true }
|
||||||
|
|
||||||
|
/// Short slug used in generated account emails (uit_<domain>_<uuid>@...),
|
||||||
|
/// cosmetic for debugging. Defaults to the test class name.
|
||||||
|
var accountDomain: String { String(describing: type(of: self)) }
|
||||||
|
|
||||||
|
/// The per-test isolated account (non-nil in fresh-account mode).
|
||||||
|
private(set) var account: TestAccount?
|
||||||
|
|
||||||
|
/// Set `true` in suites whose UI gates on a residence existing (e.g. task
|
||||||
|
/// or document creation). Seeds one residence BEFORE login so the app loads
|
||||||
|
/// it on its post-login fetch; available to the test body as `seededResidence`.
|
||||||
|
var requiresResidence: Bool { false }
|
||||||
|
|
||||||
|
/// The residence seeded as a precondition (when `requiresResidence`).
|
||||||
|
private(set) var seededResidence: TestResidence?
|
||||||
|
|
||||||
|
/// Seed baseline data the UI gates on for this test's fresh account, BEFORE
|
||||||
|
/// the app logs in (a fresh account is otherwise empty, so anything seeded
|
||||||
|
/// after login is invisible until a manual refresh). Override to seed a full
|
||||||
|
/// scenario (residence + tasks/documents); call `super` to keep the
|
||||||
|
/// `requiresResidence` convenience.
|
||||||
|
func seedAccountPreconditions(_ account: TestAccount) {
|
||||||
|
if requiresResidence {
|
||||||
|
seededResidence = account.seedResidence(name: "Precondition Home")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - API Session
|
// MARK: - API Session
|
||||||
|
|
||||||
|
/// The authenticated session used for API seeding. In fresh-account mode
|
||||||
|
/// this is the test's own account; in legacy mode it's `apiCredentials`.
|
||||||
private(set) var session: TestSession!
|
private(set) var session: TestSession!
|
||||||
private(set) var cleaner: TestDataCleaner!
|
private(set) var cleaner: TestDataCleaner!
|
||||||
|
|
||||||
@@ -25,11 +77,16 @@ class AuthenticatedUITestCase: BaseUITestCase {
|
|||||||
override class func setUp() {
|
override class func setUp() {
|
||||||
super.setUp()
|
super.setUp()
|
||||||
guard TestAccountAPIClient.isBackendReachable() else { return }
|
guard TestAccountAPIClient.isBackendReachable() else { return }
|
||||||
// Ensure both known test accounts exist (covers all subclass credential overrides)
|
// Ensure both known test accounts exist (covers all subclass credential overrides).
|
||||||
if TestAccountAPIClient.login(username: "testuser", password: "TestPass123!") == nil {
|
// Kratos uses the EMAIL as the login identifier, so log in by email.
|
||||||
|
// NOTE: the admin@honeydue.com / Test1234 created here is the Kratos APP identity
|
||||||
|
// (system (a) in the `apiCredentials` doc above) — NOT the admin-panel SQL
|
||||||
|
// super-admin (admin@honeydue.com / password123) that SuiteZZ uses for the data
|
||||||
|
// wipe. Same email, separate systems; keep Test1234 here.
|
||||||
|
if TestAccountAPIClient.login(username: "testuser@honeydue.com", password: "TestPass123!") == nil {
|
||||||
_ = TestAccountAPIClient.createVerifiedAccount(username: "testuser", email: "testuser@honeydue.com", password: "TestPass123!")
|
_ = TestAccountAPIClient.createVerifiedAccount(username: "testuser", email: "testuser@honeydue.com", password: "TestPass123!")
|
||||||
}
|
}
|
||||||
if TestAccountAPIClient.login(username: "admin", password: "Test1234") == nil {
|
if TestAccountAPIClient.login(username: "admin@honeydue.com", password: "Test1234") == nil {
|
||||||
_ = TestAccountAPIClient.createVerifiedAccount(username: "admin", email: "admin@honeydue.com", password: "Test1234")
|
_ = TestAccountAPIClient.createVerifiedAccount(username: "admin", email: "admin@honeydue.com", password: "Test1234")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,31 +115,45 @@ class AuthenticatedUITestCase: BaseUITestCase {
|
|||||||
|
|
||||||
try super.setUpWithError()
|
try super.setUpWithError()
|
||||||
|
|
||||||
|
if usesFreshAccount {
|
||||||
|
// Per-test isolation: every test logs in as its OWN fresh, pre-verified
|
||||||
|
// account, seeds under its token, and deletes it in teardown. The app
|
||||||
|
// may be reused from a previous test (still logged in as that test's
|
||||||
|
// account), so always log out first.
|
||||||
|
UITestHelpers.ensureLoggedOut(app: app)
|
||||||
|
let acct = TestAccount.create(domain: accountDomain)
|
||||||
|
account = acct
|
||||||
|
session = acct.session
|
||||||
|
cleaner = TestDataCleaner(token: acct.token)
|
||||||
|
// Seed UI-gated baseline data BEFORE login so the app loads it on
|
||||||
|
// its post-login fetch (a fresh account is otherwise empty).
|
||||||
|
seedAccountPreconditions(acct)
|
||||||
|
acct.login(into: app, timeout: loginTimeout)
|
||||||
|
waitForMainApp()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy path: log in as a SPECIFIC seeded account (testCredentials),
|
||||||
|
// optionally opening a separate API session (apiCredentials).
|
||||||
let tabBar = app.tabBars.firstMatch
|
let tabBar = app.tabBars.firstMatch
|
||||||
let alreadyLoggedIn = tabBar.waitForExistence(timeout: defaultTimeout)
|
let alreadyLoggedIn = tabBar.waitForExistence(timeout: defaultTimeout)
|
||||||
|
|
||||||
// Force-fresh path: log out (if needed) and re-authenticate per
|
|
||||||
// test so every test starts with a freshly-issued JWT. Catches
|
|
||||||
// server-side token invalidation that would otherwise surface
|
|
||||||
// mid-suite as opaque 401s on the first mutation call.
|
|
||||||
if forceFreshLoginPerTest {
|
if forceFreshLoginPerTest {
|
||||||
if alreadyLoggedIn {
|
UITestHelpers.ensureLoggedOut(app: app)
|
||||||
UITestHelpers.ensureLoggedOut(app: app)
|
|
||||||
} else {
|
|
||||||
UITestHelpers.ensureLoggedOut(app: app)
|
|
||||||
}
|
|
||||||
loginToMainApp()
|
loginToMainApp()
|
||||||
} else if !alreadyLoggedIn {
|
} else if !alreadyLoggedIn {
|
||||||
// Legacy session-reuse path: only log in when not already in.
|
|
||||||
UITestHelpers.ensureLoggedOut(app: app)
|
UITestHelpers.ensureLoggedOut(app: app)
|
||||||
loginToMainApp()
|
loginToMainApp()
|
||||||
}
|
}
|
||||||
// (When `forceFreshLoginPerTest == false` AND we're already
|
|
||||||
// logged in, fall through with the existing session.)
|
|
||||||
|
|
||||||
if needsAPISession {
|
if needsAPISession {
|
||||||
|
// Kratos uses the EMAIL as the login identifier. Subclasses still
|
||||||
|
// declare seeded `apiCredentials` by short username (e.g. "admin"),
|
||||||
|
// so normalize bare usernames to their "<username>@honeydue.com" email.
|
||||||
|
let identifier = apiCredentials.username.contains("@")
|
||||||
|
? apiCredentials.username
|
||||||
|
: "\(apiCredentials.username)@honeydue.com"
|
||||||
guard let apiSession = TestAccountManager.loginSeededAccount(
|
guard let apiSession = TestAccountManager.loginSeededAccount(
|
||||||
username: apiCredentials.username,
|
username: identifier,
|
||||||
password: apiCredentials.password
|
password: apiCredentials.password
|
||||||
) else {
|
) else {
|
||||||
XCTFail("Could not login API account '\(apiCredentials.username)'")
|
XCTFail("Could not login API account '\(apiCredentials.username)'")
|
||||||
@@ -94,7 +165,14 @@ class AuthenticatedUITestCase: BaseUITestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func tearDownWithError() throws {
|
override func tearDownWithError() throws {
|
||||||
cleaner?.cleanAll()
|
// Deleting the per-test account cascades all of its data and clears the
|
||||||
|
// Kratos identity in one call. In legacy mode there's no account, so
|
||||||
|
// fall back to tracked-resource cleanup.
|
||||||
|
if let account {
|
||||||
|
account.delete()
|
||||||
|
} else {
|
||||||
|
cleaner?.cleanAll()
|
||||||
|
}
|
||||||
try super.tearDownWithError()
|
try super.tearDownWithError()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +185,13 @@ class AuthenticatedUITestCase: BaseUITestCase {
|
|||||||
|
|
||||||
let login = LoginScreenObject(app: app)
|
let login = LoginScreenObject(app: app)
|
||||||
login.waitForLoad(timeout: loginTimeout)
|
login.waitForLoad(timeout: loginTimeout)
|
||||||
login.enterUsername(creds.username)
|
// Kratos uses the EMAIL as the login identifier. Subclasses still declare
|
||||||
|
// testCredentials by short username (e.g. "admin"/"testuser"), so normalize
|
||||||
|
// a bare username to "<username>@honeydue.com" for the app's login form.
|
||||||
|
let identifier = creds.username.contains("@")
|
||||||
|
? creds.username
|
||||||
|
: "\(creds.username)@honeydue.com"
|
||||||
|
login.enterUsername(identifier)
|
||||||
login.enterPassword(creds.password)
|
login.enterPassword(creds.password)
|
||||||
|
|
||||||
let loginButton = app.buttons[AccessibilityIdentifiers.Authentication.loginButton]
|
let loginButton = app.buttons[AccessibilityIdentifiers.Authentication.loginButton]
|
||||||
@@ -133,7 +217,24 @@ class AuthenticatedUITestCase: BaseUITestCase {
|
|||||||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||||
}
|
}
|
||||||
|
|
||||||
XCTAssertTrue(tabBar.exists, "Expected tab bar after login with '\(testCredentials.username)'")
|
if !tabBar.exists {
|
||||||
|
XCTFail("Expected tab bar after login with '\(testCredentials.username)'. " +
|
||||||
|
"Root state: " + Self.diagnoseRootState(app))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diagnostic: report which RootView branch the app is parked on when
|
||||||
|
/// the tab bar fails to appear after login. Helps distinguish a failed login
|
||||||
|
/// (parked on ui.root.login) from a stuck verify-email gate.
|
||||||
|
static func diagnoseRootState(_ app: XCUIApplication) -> String {
|
||||||
|
let login = app.otherElements["ui.root.login"].exists
|
||||||
|
let onboarding = app.otherElements["ui.root.onboarding"].exists
|
||||||
|
let mainTabs = app.otherElements["ui.root.mainTabs"].exists
|
||||||
|
let verifyCode = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField].exists
|
||||||
|
|| app.textFields[AccessibilityIdentifiers.Onboarding.verificationCodeField].exists
|
||||||
|
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField].exists
|
||||||
|
return "login=\(login) onboarding=\(onboarding) mainTabs=\(mainTabs) " +
|
||||||
|
"verifyCodeField=\(verifyCode) usernameField=\(usernameField)"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Tab Navigation
|
// MARK: - Tab Navigation
|
||||||
|
|||||||
@@ -47,21 +47,6 @@ struct TestAuthResponse: Decodable {
|
|||||||
let message: String?
|
let message: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TestVerifyEmailResponse: Decodable {
|
|
||||||
let message: String
|
|
||||||
let verified: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TestVerifyResetCodeResponse: Decodable {
|
|
||||||
let message: String
|
|
||||||
let resetToken: String
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case message
|
|
||||||
case resetToken = "reset_token"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TestMessageResponse: Decodable {
|
struct TestMessageResponse: Decodable {
|
||||||
let message: String
|
let message: String
|
||||||
}
|
}
|
||||||
@@ -206,64 +191,313 @@ enum TestAccountAPIClient {
|
|||||||
static let baseURL = "http://127.0.0.1:8000/api"
|
static let baseURL = "http://127.0.0.1:8000/api"
|
||||||
static let debugVerificationCode = "123456"
|
static let debugVerificationCode = "123456"
|
||||||
|
|
||||||
// MARK: - Auth Methods
|
// MARK: - Kratos Configuration
|
||||||
|
|
||||||
|
/// Kratos public API (self-service login/registration flows).
|
||||||
|
static let kratosPublicURL = "http://127.0.0.1:4433"
|
||||||
|
/// Kratos admin API (create pre-verified identities directly).
|
||||||
|
static let kratosAdminURL = "http://127.0.0.1:4434"
|
||||||
|
/// Identity schema id registered in Kratos for this app.
|
||||||
|
static let kratosSchemaID = "honeydue"
|
||||||
|
|
||||||
|
// MARK: - Kratos Auth Primitives
|
||||||
|
|
||||||
|
/// Create a Kratos identity via the ADMIN API.
|
||||||
|
/// When `verified` is true the email's verifiable address is marked
|
||||||
|
/// completed/verified; when false it is left pending/unverified (mirrors a
|
||||||
|
/// freshly-registered account that has not confirmed its email yet).
|
||||||
|
/// Returns true on 201 (created) or 409 (already exists — idempotent).
|
||||||
|
static func createKratosIdentity(email: String, password: String, firstName: String, lastName: String, verified: Bool = true) -> Bool {
|
||||||
|
guard let url = URL(string: "\(kratosAdminURL)/admin/identities") else { return false }
|
||||||
|
|
||||||
|
let verifiableAddress: [String: Any] = verified
|
||||||
|
? ["value": email, "verified": true, "via": "email", "status": "completed"]
|
||||||
|
: ["value": email, "verified": false, "via": "email", "status": "pending"]
|
||||||
|
|
||||||
static func register(username: String, email: String, password: String) -> TestAuthResponse? {
|
|
||||||
let body: [String: Any] = [
|
let body: [String: Any] = [
|
||||||
"username": username,
|
"schema_id": kratosSchemaID,
|
||||||
"email": email,
|
"traits": [
|
||||||
|
"email": email,
|
||||||
|
"name": ["first": firstName, "last": lastName]
|
||||||
|
],
|
||||||
|
"credentials": [
|
||||||
|
"password": ["config": ["password": password]]
|
||||||
|
],
|
||||||
|
"verifiable_addresses": [verifiableAddress],
|
||||||
|
"state": "active"
|
||||||
|
]
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
request.timeoutInterval = 15
|
||||||
|
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||||||
|
|
||||||
|
let semaphore = DispatchSemaphore(value: 0)
|
||||||
|
var success = false
|
||||||
|
|
||||||
|
let task = URLSession.shared.dataTask(with: request) { data, response, error in
|
||||||
|
defer { semaphore.signal() }
|
||||||
|
if let error = error {
|
||||||
|
print("[Kratos] createIdentity error: \(error.localizedDescription)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
// 201 = created, 409 = already exists (idempotent success)
|
||||||
|
if status == 201 || status == 409 {
|
||||||
|
success = true
|
||||||
|
} else {
|
||||||
|
let bodyStr = data.flatMap { String(data: $0, encoding: .utf8) } ?? "<nil>"
|
||||||
|
print("[Kratos] createIdentity status \(status): \(bodyStr)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
task.resume()
|
||||||
|
if semaphore.wait(timeout: .now() + 30) == .timedOut {
|
||||||
|
print("[Kratos] createIdentity TIMEOUT")
|
||||||
|
task.cancel()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return success
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform a Kratos self-service login (API flow) and return the session token, or nil.
|
||||||
|
static func kratosLogin(email: String, password: String) -> String? {
|
||||||
|
// Step 1: GET the login flow to discover the action URL.
|
||||||
|
guard let flowURL = URL(string: "\(kratosPublicURL)/self-service/login/api") else { return nil }
|
||||||
|
|
||||||
|
var flowRequest = URLRequest(url: flowURL)
|
||||||
|
flowRequest.httpMethod = "GET"
|
||||||
|
flowRequest.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
flowRequest.timeoutInterval = 15
|
||||||
|
|
||||||
|
let flowSemaphore = DispatchSemaphore(value: 0)
|
||||||
|
var actionURLString: String?
|
||||||
|
|
||||||
|
let flowTask = URLSession.shared.dataTask(with: flowRequest) { data, response, error in
|
||||||
|
defer { flowSemaphore.signal() }
|
||||||
|
if let error = error {
|
||||||
|
print("[Kratos] login flow error: \(error.localizedDescription)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
guard let data = data else {
|
||||||
|
print("[Kratos] login flow no data (status \(status))")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let ui = json["ui"] as? [String: Any],
|
||||||
|
let action = ui["action"] as? String
|
||||||
|
else {
|
||||||
|
let bodyStr = String(data: data, encoding: .utf8) ?? "<binary>"
|
||||||
|
print("[Kratos] login flow parse failed (status \(status)): \(bodyStr)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actionURLString = action
|
||||||
|
}
|
||||||
|
flowTask.resume()
|
||||||
|
if flowSemaphore.wait(timeout: .now() + 30) == .timedOut {
|
||||||
|
print("[Kratos] login flow TIMEOUT")
|
||||||
|
flowTask.cancel()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let actionURLString = actionURLString, let actionURL = URL(string: actionURLString) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: POST credentials to the action URL to obtain a session token.
|
||||||
|
let body: [String: Any] = [
|
||||||
|
"method": "password",
|
||||||
|
"identifier": email,
|
||||||
"password": password
|
"password": password
|
||||||
]
|
]
|
||||||
return performRequest(method: "POST", path: "/auth/register/", body: body, responseType: TestAuthResponse.self)
|
|
||||||
|
var loginRequest = URLRequest(url: actionURL)
|
||||||
|
loginRequest.httpMethod = "POST"
|
||||||
|
loginRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
loginRequest.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
loginRequest.timeoutInterval = 15
|
||||||
|
loginRequest.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||||||
|
|
||||||
|
let loginSemaphore = DispatchSemaphore(value: 0)
|
||||||
|
var sessionToken: String?
|
||||||
|
|
||||||
|
let loginTask = URLSession.shared.dataTask(with: loginRequest) { data, response, error in
|
||||||
|
defer { loginSemaphore.signal() }
|
||||||
|
if let error = error {
|
||||||
|
print("[Kratos] login error: \(error.localizedDescription)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
guard let data = data else {
|
||||||
|
print("[Kratos] login no data (status \(status))")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Kratos returns 200 on success, 400 on bad credentials.
|
||||||
|
guard
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let token = json["session_token"] as? String
|
||||||
|
else {
|
||||||
|
let bodyStr = String(data: data, encoding: .utf8) ?? "<binary>"
|
||||||
|
print("[Kratos] login no session_token (status \(status)): \(bodyStr)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessionToken = token
|
||||||
|
}
|
||||||
|
loginTask.resume()
|
||||||
|
if loginSemaphore.wait(timeout: .now() + 30) == .timedOut {
|
||||||
|
print("[Kratos] login TIMEOUT")
|
||||||
|
loginTask.cancel()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return sessionToken
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Auth Methods
|
||||||
|
|
||||||
|
/// Log in via Kratos. The `username` parameter is treated as the Kratos
|
||||||
|
/// identifier — i.e. the account EMAIL. Returns a TestAuthResponse carrying
|
||||||
|
/// the Kratos session token and the provisioned API user, or nil on failure.
|
||||||
static func login(username: String, password: String) -> TestAuthResponse? {
|
static func login(username: String, password: String) -> TestAuthResponse? {
|
||||||
let body: [String: Any] = ["username": username, "password": password]
|
guard let token = kratosLogin(email: username, password: password) else { return nil }
|
||||||
return performRequest(method: "POST", path: "/auth/login/", body: body, responseType: TestAuthResponse.self)
|
guard let user = getCurrentUser(token: token) else { return nil }
|
||||||
}
|
return TestAuthResponse(token: token, user: user, message: nil)
|
||||||
|
|
||||||
static func verifyEmail(token: String) -> TestVerifyEmailResponse? {
|
|
||||||
let body: [String: Any] = ["code": debugVerificationCode]
|
|
||||||
return performRequest(method: "POST", path: "/auth/verify-email/", body: body, token: token, responseType: TestVerifyEmailResponse.self)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func getCurrentUser(token: String) -> TestUser? {
|
static func getCurrentUser(token: String) -> TestUser? {
|
||||||
return performRequest(method: "GET", path: "/auth/me/", token: token, responseType: TestUser.self)
|
return performRequest(method: "GET", path: "/auth/me/", token: token, responseType: TestUser.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func forgotPassword(email: String) -> TestMessageResponse? {
|
/// Convenience: provision a pre-verified Kratos identity, log in, and fetch
|
||||||
let body: [String: Any] = ["email": email]
|
/// the provisioned API user. Returns a ready-to-use session, or nil on failure.
|
||||||
return performRequest(method: "POST", path: "/auth/forgot-password/", body: body, responseType: TestMessageResponse.self)
|
///
|
||||||
}
|
/// `username` is used as the identity's first name (and retained on the
|
||||||
|
/// returned session for reference); the Kratos identifier is the `email`.
|
||||||
static func verifyResetCode(email: String) -> TestVerifyResetCodeResponse? {
|
|
||||||
let body: [String: Any] = ["email": email, "code": debugVerificationCode]
|
|
||||||
return performRequest(method: "POST", path: "/auth/verify-reset-code/", body: body, responseType: TestVerifyResetCodeResponse.self)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func resetPassword(resetToken: String, newPassword: String) -> TestMessageResponse? {
|
|
||||||
let body: [String: Any] = ["reset_token": resetToken, "new_password": newPassword]
|
|
||||||
return performRequest(method: "POST", path: "/auth/reset-password/", body: body, responseType: TestMessageResponse.self)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func logout(token: String) -> TestMessageResponse? {
|
|
||||||
return performRequest(method: "POST", path: "/auth/logout/", token: token, responseType: TestMessageResponse.self)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convenience: register + verify + re-login, returns ready session.
|
|
||||||
static func createVerifiedAccount(username: String, email: String, password: String) -> TestSession? {
|
static func createVerifiedAccount(username: String, email: String, password: String) -> TestSession? {
|
||||||
guard let registerResponse = register(username: username, email: email, password: password) else { return nil }
|
guard createKratosIdentity(email: email, password: password, firstName: username, lastName: "Test") else { return nil }
|
||||||
guard verifyEmail(token: registerResponse.token) != nil else { return nil }
|
guard let token = kratosLogin(email: email, password: password) else { return nil }
|
||||||
guard let loginResponse = login(username: username, password: password) else { return nil }
|
guard let user = getCurrentUser(token: token) else { return nil }
|
||||||
return TestSession(token: loginResponse.token, user: loginResponse.user, username: username, password: password)
|
return TestSession(token: token, user: user, username: username, password: password)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: provision an UNVERIFIED Kratos identity (no email confirmed),
|
||||||
|
/// log in, and fetch the lazily-provisioned API user. Mirrors
|
||||||
|
/// `createVerifiedAccount` but leaves the email address unverified so callers
|
||||||
|
/// can exercise the verification gate. Returns a ready-to-use session, or nil.
|
||||||
|
static func createUnverifiedAccount(username: String, email: String, password: String) -> TestSession? {
|
||||||
|
guard createKratosIdentity(email: email, password: password, firstName: username, lastName: "Test", verified: false) else { return nil }
|
||||||
|
guard let token = kratosLogin(email: email, password: password) else { return nil }
|
||||||
|
guard let user = getCurrentUser(token: token) else { return nil }
|
||||||
|
return TestSession(token: token, user: user, username: username, password: password)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a Kratos identity by its login email via the ADMIN API (true teardown).
|
||||||
|
/// Looks up the identity by `credentials_identifier`, then DELETEs it.
|
||||||
|
/// Returns true if the identity was deleted (204) OR no identity exists
|
||||||
|
/// (already gone — idempotent success). Returns false only on a real failure.
|
||||||
|
static func deleteKratosIdentity(email: String) -> Bool {
|
||||||
|
let encoded = email.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? email
|
||||||
|
guard let lookupURL = URL(string: "\(kratosAdminURL)/admin/identities?credentials_identifier=\(encoded)") else {
|
||||||
|
print("[Kratos] deleteIdentity invalid lookup URL for \(email)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: find the identity id by email.
|
||||||
|
var lookupRequest = URLRequest(url: lookupURL)
|
||||||
|
lookupRequest.httpMethod = "GET"
|
||||||
|
lookupRequest.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
lookupRequest.timeoutInterval = 15
|
||||||
|
|
||||||
|
let lookupSemaphore = DispatchSemaphore(value: 0)
|
||||||
|
var identityID: String?
|
||||||
|
var lookupFound = false
|
||||||
|
|
||||||
|
let lookupTask = URLSession.shared.dataTask(with: lookupRequest) { data, response, error in
|
||||||
|
defer { lookupSemaphore.signal() }
|
||||||
|
if let error = error {
|
||||||
|
print("[Kratos] deleteIdentity lookup error: \(error.localizedDescription)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
guard let data = data else {
|
||||||
|
print("[Kratos] deleteIdentity lookup no data (status \(status))")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let identities = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
|
||||||
|
let bodyStr = String(data: data, encoding: .utf8) ?? "<binary>"
|
||||||
|
print("[Kratos] deleteIdentity lookup parse failed (status \(status)): \(bodyStr)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lookupFound = true
|
||||||
|
identityID = identities.first?["id"] as? String
|
||||||
|
}
|
||||||
|
lookupTask.resume()
|
||||||
|
if lookupSemaphore.wait(timeout: .now() + 30) == .timedOut {
|
||||||
|
print("[Kratos] deleteIdentity lookup TIMEOUT")
|
||||||
|
lookupTask.cancel()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// No identity found (empty array) — already gone, idempotent success.
|
||||||
|
guard let id = identityID else {
|
||||||
|
return lookupFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: DELETE the identity.
|
||||||
|
guard let deleteURL = URL(string: "\(kratosAdminURL)/admin/identities/\(id)") else {
|
||||||
|
print("[Kratos] deleteIdentity invalid delete URL for id \(id)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var deleteRequest = URLRequest(url: deleteURL)
|
||||||
|
deleteRequest.httpMethod = "DELETE"
|
||||||
|
deleteRequest.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
deleteRequest.timeoutInterval = 15
|
||||||
|
|
||||||
|
let deleteSemaphore = DispatchSemaphore(value: 0)
|
||||||
|
var success = false
|
||||||
|
|
||||||
|
let deleteTask = URLSession.shared.dataTask(with: deleteRequest) { data, response, error in
|
||||||
|
defer { deleteSemaphore.signal() }
|
||||||
|
if let error = error {
|
||||||
|
print("[Kratos] deleteIdentity error: \(error.localizedDescription)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
// 204 = deleted, 404 = already gone (idempotent success).
|
||||||
|
if status == 204 || status == 404 {
|
||||||
|
success = true
|
||||||
|
} else {
|
||||||
|
let bodyStr = data.flatMap { String(data: $0, encoding: .utf8) } ?? "<nil>"
|
||||||
|
print("[Kratos] deleteIdentity status \(status): \(bodyStr)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deleteTask.resume()
|
||||||
|
if deleteSemaphore.wait(timeout: .now() + 30) == .timedOut {
|
||||||
|
print("[Kratos] deleteIdentity TIMEOUT")
|
||||||
|
deleteTask.cancel()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return success
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Auth with Status Code
|
// MARK: - Auth with Status Code
|
||||||
|
|
||||||
/// Login returning full APIResult so callers can assert on 401, 400, etc.
|
/// Login returning full APIResult so callers can assert on success/failure.
|
||||||
|
/// `username` is treated as the Kratos identifier (the EMAIL). On a failed
|
||||||
|
/// Kratos login (Kratos returns 400 on bad creds) this maps to statusCode 401
|
||||||
|
/// so negative-path assertions that expect an unauthorized result still hold.
|
||||||
static func loginWithResult(username: String, password: String) -> APIResult<TestAuthResponse> {
|
static func loginWithResult(username: String, password: String) -> APIResult<TestAuthResponse> {
|
||||||
let body: [String: Any] = ["username": username, "password": password]
|
guard let token = kratosLogin(email: username, password: password) else {
|
||||||
return performRequestWithResult(method: "POST", path: "/auth/login/", body: body, responseType: TestAuthResponse.self)
|
return APIResult(data: nil, statusCode: 401, errorBody: "Kratos login failed")
|
||||||
|
}
|
||||||
|
guard let user = getCurrentUser(token: token) else {
|
||||||
|
return APIResult(data: nil, statusCode: 401, errorBody: "Failed to fetch current user after login")
|
||||||
|
}
|
||||||
|
let response = TestAuthResponse(token: token, user: user, message: nil)
|
||||||
|
return APIResult(data: response, statusCode: 200, errorBody: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hit a protected endpoint without a token to get the 401.
|
/// Hit a protected endpoint without a token to get the 401.
|
||||||
@@ -475,7 +709,7 @@ enum TestAccountAPIClient {
|
|||||||
request.timeoutInterval = 15
|
request.timeoutInterval = 15
|
||||||
|
|
||||||
if let token = token {
|
if let token = token {
|
||||||
request.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
|
request.setValue(token, forHTTPHeaderField: "X-Session-Token")
|
||||||
}
|
}
|
||||||
if let body = body {
|
if let body = body {
|
||||||
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||||||
@@ -503,11 +737,84 @@ enum TestAccountAPIClient {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Mailpit (real email verification codes)
|
||||||
|
|
||||||
|
/// Mailpit web/API base for the local stack.
|
||||||
|
static let mailpitURL = "http://127.0.0.1:8025"
|
||||||
|
|
||||||
|
/// Fetch the most recent 6-digit verification code Kratos emailed to `email`.
|
||||||
|
/// The app's onboarding registration uses Kratos's real verification flow
|
||||||
|
/// (not the API's DEBUG fixed code), so onboarding tests must read the live
|
||||||
|
/// code from Mailpit. Polls briefly because the email lands asynchronously.
|
||||||
|
static func latestVerificationCode(for email: String, timeout: TimeInterval = 15) -> String? {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
let lowered = email.lowercased()
|
||||||
|
while Date() < deadline {
|
||||||
|
if let code = fetchLatestCodeOnce(for: lowered) { return code }
|
||||||
|
Thread.sleep(forTimeInterval: 1.0)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func fetchLatestCodeOnce(for loweredEmail: String) -> String? {
|
||||||
|
guard let url = URL(string: "\(mailpitURL)/api/v1/search?query=to:\(loweredEmail)&limit=5") else { return nil }
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.timeoutInterval = 10
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
|
||||||
|
let semaphore = DispatchSemaphore(value: 0)
|
||||||
|
var messageID: String?
|
||||||
|
let task = URLSession.shared.dataTask(with: request) { data, _, _ in
|
||||||
|
defer { semaphore.signal() }
|
||||||
|
guard let data = data,
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let messages = json["messages"] as? [[String: Any]] else { return }
|
||||||
|
// Messages are newest-first; pick the first addressed to this email.
|
||||||
|
for m in messages {
|
||||||
|
let tos = (m["To"] as? [[String: Any]])?.compactMap { ($0["Address"] as? String)?.lowercased() } ?? []
|
||||||
|
if tos.contains(loweredEmail) {
|
||||||
|
messageID = m["ID"] as? String
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
task.resume()
|
||||||
|
_ = semaphore.wait(timeout: .now() + 15)
|
||||||
|
|
||||||
|
guard let id = messageID else { return nil }
|
||||||
|
return extractCode(messageID: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func extractCode(messageID: String) -> String? {
|
||||||
|
guard let url = URL(string: "\(mailpitURL)/api/v1/message/\(messageID)") else { return nil }
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.timeoutInterval = 10
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
|
||||||
|
let semaphore = DispatchSemaphore(value: 0)
|
||||||
|
var code: String?
|
||||||
|
let task = URLSession.shared.dataTask(with: request) { data, _, _ in
|
||||||
|
defer { semaphore.signal() }
|
||||||
|
guard let data = data,
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
|
||||||
|
let text = (json["Text"] as? String ?? "") + " " + (json["HTML"] as? String ?? "")
|
||||||
|
// The Kratos verification email presents a standalone 6-digit code.
|
||||||
|
if let range = text.range(of: "\\b\\d{6}\\b", options: .regularExpression) {
|
||||||
|
code = String(text[range])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
task.resume()
|
||||||
|
_ = semaphore.wait(timeout: .now() + 15)
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Reachability
|
// MARK: - Reachability
|
||||||
|
|
||||||
static func isBackendReachable() -> Bool {
|
static func isBackendReachable() -> Bool {
|
||||||
let result = rawRequest(method: "POST", path: "/auth/login/", body: [:])
|
// Probe a live endpoint with no token. The backend returns 401
|
||||||
// Any HTTP response (even 400) means the backend is up
|
// (unauthenticated) when it's up — any HTTP response means reachable.
|
||||||
|
let result = rawRequest(method: "GET", path: "/auth/me/")
|
||||||
|
// statusCode 0 means the connection failed; anything else (incl. 401) is up.
|
||||||
return result.statusCode > 0
|
return result.statusCode > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,7 +850,7 @@ enum TestAccountAPIClient {
|
|||||||
request.timeoutInterval = 15
|
request.timeoutInterval = 15
|
||||||
|
|
||||||
if let token = token {
|
if let token = token {
|
||||||
request.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
|
request.setValue(token, forHTTPHeaderField: "X-Session-Token")
|
||||||
}
|
}
|
||||||
|
|
||||||
if let body = body {
|
if let body = body {
|
||||||
|
|||||||
@@ -38,29 +38,24 @@ enum TestAccountManager {
|
|||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create an unverified account (register only, no email verification).
|
/// Create an unverified account (Kratos identity with an unverified email).
|
||||||
/// Useful for testing the verification gate.
|
/// Useful for testing the verification gate. Returns a ready-to-use session.
|
||||||
static func createUnverifiedAccount(
|
static func createUnverifiedAccount(
|
||||||
file: StaticString = #filePath,
|
file: StaticString = #filePath,
|
||||||
line: UInt = #line
|
line: UInt = #line
|
||||||
) -> TestSession? {
|
) -> TestSession? {
|
||||||
let creds = uniqueCredentials()
|
let creds = uniqueCredentials()
|
||||||
|
|
||||||
guard let response = TestAccountAPIClient.register(
|
guard let session = TestAccountAPIClient.createUnverifiedAccount(
|
||||||
username: creds.username,
|
username: creds.username,
|
||||||
email: creds.email,
|
email: creds.email,
|
||||||
password: creds.password
|
password: creds.password
|
||||||
) else {
|
) else {
|
||||||
XCTFail("Failed to register unverified account for \(creds.username)", file: file, line: line)
|
XCTFail("Failed to create unverified account for \(creds.username)", file: file, line: line)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return TestSession(
|
return session
|
||||||
token: response.token,
|
|
||||||
user: response.user,
|
|
||||||
username: creds.username,
|
|
||||||
password: creds.password
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Seeded Accounts
|
// MARK: - Seeded Accounts
|
||||||
@@ -85,43 +80,4 @@ enum TestAccountManager {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Password Reset
|
|
||||||
|
|
||||||
/// Execute the full forgot→verify→reset cycle via the backend API.
|
|
||||||
static func resetPassword(
|
|
||||||
email: String,
|
|
||||||
newPassword: String,
|
|
||||||
file: StaticString = #filePath,
|
|
||||||
line: UInt = #line
|
|
||||||
) -> Bool {
|
|
||||||
guard TestAccountAPIClient.forgotPassword(email: email) != nil else {
|
|
||||||
XCTFail("Forgot password request failed for \(email)", file: file, line: line)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let verifyResponse = TestAccountAPIClient.verifyResetCode(email: email) else {
|
|
||||||
XCTFail("Verify reset code failed for \(email)", file: file, line: line)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
guard TestAccountAPIClient.resetPassword(resetToken: verifyResponse.resetToken, newPassword: newPassword) != nil else {
|
|
||||||
XCTFail("Reset password failed for \(email)", file: file, line: line)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Token Management
|
|
||||||
|
|
||||||
/// Invalidate a session token via the logout API.
|
|
||||||
static func invalidateToken(
|
|
||||||
_ session: TestSession,
|
|
||||||
file: StaticString = #filePath,
|
|
||||||
line: UInt = #line
|
|
||||||
) {
|
|
||||||
if TestAccountAPIClient.logout(token: session.token) == nil {
|
|
||||||
XCTFail("Failed to invalidate token for \(session.username)", file: file, line: line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,9 @@ enum TestFlows {
|
|||||||
loginButton.waitUntilHittable(timeout: 10).tap()
|
loginButton.waitUntilHittable(timeout: 10).tap()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drive the full forgot password → verify code → reset password flow using the debug code.
|
/// Drive the full forgot password → verify code → reset password flow.
|
||||||
|
/// The recovery code is read from Mailpit — password reset is a Kratos
|
||||||
|
/// recovery flow now, so Kratos emails a real 6-digit code (no fixed code).
|
||||||
static func completeForgotPasswordFlow(
|
static func completeForgotPasswordFlow(
|
||||||
app: XCUIApplication,
|
app: XCUIApplication,
|
||||||
email: String,
|
email: String,
|
||||||
@@ -80,10 +82,11 @@ enum TestFlows {
|
|||||||
forgotScreen.enterEmail(email)
|
forgotScreen.enterEmail(email)
|
||||||
forgotScreen.tapSendCode()
|
forgotScreen.tapSendCode()
|
||||||
|
|
||||||
// Step 2: Enter debug verification code
|
// Step 2: Enter the real Kratos recovery code (emailed → Mailpit locally)
|
||||||
let verifyScreen = VerifyResetCodeScreen(app: app)
|
let verifyScreen = VerifyResetCodeScreen(app: app)
|
||||||
verifyScreen.waitForLoad()
|
verifyScreen.waitForLoad()
|
||||||
verifyScreen.enterCode(TestAccountAPIClient.debugVerificationCode)
|
let code = TestAccountAPIClient.latestVerificationCode(for: email) ?? ""
|
||||||
|
verifyScreen.enterCode(code)
|
||||||
verifyScreen.tapVerify()
|
verifyScreen.tapVerify()
|
||||||
|
|
||||||
// Step 3: Enter new password
|
// Step 3: Enter new password
|
||||||
|
|||||||
+7
-8
@@ -2,15 +2,14 @@ import XCTest
|
|||||||
|
|
||||||
/// Critical path tests for core navigation.
|
/// Critical path tests for core navigation.
|
||||||
/// Validates tab bar presence, navigation, settings access, and add buttons.
|
/// Validates tab bar presence, navigation, settings access, and add buttons.
|
||||||
final class NavigationCriticalPathTests: AuthenticatedUITestCase {
|
///
|
||||||
|
/// Gates on a residence existing (the task add button only appears once the
|
||||||
|
/// user has a residence), so we seed one BEFORE login via `requiresResidence`.
|
||||||
|
final class NavigationUITests: AuthenticatedUITestCase {
|
||||||
|
|
||||||
override var needsAPISession: Bool { true }
|
/// The Tasks/Documents/Contractors add buttons only appear once a residence
|
||||||
|
/// exists. Seed one as a precondition before the app logs in.
|
||||||
override func setUpWithError() throws {
|
override var requiresResidence: Bool { true }
|
||||||
try super.setUpWithError()
|
|
||||||
// Precondition: residence must exist for task add button to appear
|
|
||||||
ensureResidenceExists()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Tab Navigation
|
// MARK: - Tab Navigation
|
||||||
|
|
||||||
+21
-19
@@ -1,21 +1,20 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
/// Suite 11 — captures the gitea#2 regression at the user-visible level:
|
/// Captures the gitea#2 regression at the user-visible level: after onboarding
|
||||||
/// after onboarding (register → name residence → bulk-create tasks → land
|
/// (register → name residence → bulk-create tasks → land on home), tapping the
|
||||||
/// on home), tapping the residence cell shows "no tasks" even though the
|
/// residence cell shows "no tasks" even though the server has them. Restarting
|
||||||
/// server has them. Restarting the app fixes it. This test reproduces the
|
/// the app fixes it. This test reproduces the flow without an app restart and
|
||||||
/// flow without an app restart and asserts that tasks render on the
|
/// asserts that tasks render on the residence detail screen.
|
||||||
/// residence detail screen.
|
|
||||||
///
|
///
|
||||||
/// CRITICAL: this test must FAIL at the cache-unification fix's first
|
/// CRITICAL: this test must FAIL at the cache-unification fix's first commit and
|
||||||
/// commit and must PASS after Phase 1-3 lands. The failing assertion is
|
/// must PASS after Phase 1-3 lands. The failing assertion is pinned to a specific
|
||||||
/// pinned to a specific message so the regression is unambiguous.
|
/// message so the regression is unambiguous.
|
||||||
///
|
///
|
||||||
/// The test deliberately does NOT visit the Tasks tab between onboarding
|
/// The test deliberately does NOT visit the Tasks tab between onboarding and
|
||||||
/// and tapping the residence cell. Visiting the Tasks tab would prime
|
/// tapping the residence cell. Visiting the Tasks tab would prime `_allTasks` and
|
||||||
/// `_allTasks` and mask the bug — the bug is that residence detail
|
/// mask the bug — the bug is that residence detail cannot recover from the
|
||||||
/// cannot recover from the empty-cache + sink-timing window on its own.
|
/// empty-cache + sink-timing window on its own.
|
||||||
final class Suite11_TaskCacheRegressionTests: BaseUITestCase {
|
final class OnboardingTaskCacheUITests: BaseUITestCase {
|
||||||
// We need to start at the onboarding welcome screen, not the standalone
|
// We need to start at the onboarding welcome screen, not the standalone
|
||||||
// login screen — `completeOnboarding` would skip the entire flow.
|
// login screen — `completeOnboarding` would skip the entire flow.
|
||||||
override var completeOnboarding: Bool { false }
|
override var completeOnboarding: Bool { false }
|
||||||
@@ -25,9 +24,6 @@ final class Suite11_TaskCacheRegressionTests: BaseUITestCase {
|
|||||||
|
|
||||||
// MARK: - Constants
|
// MARK: - Constants
|
||||||
|
|
||||||
/// DEBUG_FIXED_CODES=true on the local Go API hardcodes this code.
|
|
||||||
private let debugVerificationCode = "123456"
|
|
||||||
|
|
||||||
/// Stable name for the residence we create in onboarding. Used both for
|
/// Stable name for the residence we create in onboarding. Used both for
|
||||||
/// the form input and to address the cell on the home screen via
|
/// the form input and to address the cell on the home screen via
|
||||||
/// `app.staticTexts[residenceName]` if the id-based identifier doesn't
|
/// `app.staticTexts[residenceName]` if the id-based identifier doesn't
|
||||||
@@ -81,10 +77,16 @@ final class Suite11_TaskCacheRegressionTests: BaseUITestCase {
|
|||||||
createAccountButton.waitForExistenceOrFail(timeout: navigationTimeout)
|
createAccountButton.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||||
createAccountButton.forceTap()
|
createAccountButton.forceTap()
|
||||||
|
|
||||||
// Step 3 — Verify email with the debug fixed code.
|
// Step 3 — Verify email with the real Kratos code from Mailpit.
|
||||||
|
// Onboarding registration creates a Kratos identity and triggers a
|
||||||
|
// Kratos verification flow that emails a 6-digit code (delivered to
|
||||||
|
// Mailpit on the local stack). The old DEBUG_FIXED_CODES "123456" path
|
||||||
|
// no longer exists on the Kratos-backed API.
|
||||||
let verification = VerificationScreen(app: app)
|
let verification = VerificationScreen(app: app)
|
||||||
verification.waitForLoad(timeout: loginTimeout)
|
verification.waitForLoad(timeout: loginTimeout)
|
||||||
verification.enterCode(debugVerificationCode)
|
let realCode = TestAccountAPIClient.latestVerificationCode(for: creds.email) ?? ""
|
||||||
|
XCTAssertFalse(realCode.isEmpty, "No Kratos verification code arrived in Mailpit for \(creds.email)")
|
||||||
|
verification.enterCode(realCode)
|
||||||
// Many onboarding verification screens auto-submit on a 6-digit
|
// Many onboarding verification screens auto-submit on a 6-digit
|
||||||
// code. If a verify button still exists and a code field is still
|
// code. If a verify button still exists and a code field is still
|
||||||
// visible, tap it to push past edge cases.
|
// visible, tap it to push past edge cases.
|
||||||
+101
-11
@@ -1,7 +1,19 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
final class OnboardingTests: BaseUITestCase {
|
/// Merged onboarding UI test suite.
|
||||||
|
///
|
||||||
|
/// Combines the legacy `OnboardingTests` (Start Fresh / Join Existing flow
|
||||||
|
/// coverage, ONB-005 residence bootstrap, ONB-008 completion persistence) with
|
||||||
|
/// the `Suite0_OnboardingRebuildTests` rebuild suite (welcome → login-entry and
|
||||||
|
/// Start Fresh → create account isolation tests).
|
||||||
|
///
|
||||||
|
/// Drives the logged-OUT onboarding flow:
|
||||||
|
/// Welcome → ValueProps → NameResidence → CreateAccount → VerifyEmail → ...
|
||||||
|
final class OnboardingUITests: BaseUITestCase {
|
||||||
override var relaunchBetweenTests: Bool { true }
|
override var relaunchBetweenTests: Bool { true }
|
||||||
|
|
||||||
|
// MARK: - From OnboardingTests
|
||||||
|
|
||||||
func testF101_StartFreshFlowReachesCreateAccount() {
|
func testF101_StartFreshFlowReachesCreateAccount() {
|
||||||
let createAccount = TestFlows.navigateStartFreshToCreateAccount(app: app, residenceName: "Blueprint House")
|
let createAccount = TestFlows.navigateStartFreshToCreateAccount(app: app, residenceName: "Blueprint House")
|
||||||
createAccount.waitForLoad(timeout: defaultTimeout)
|
createAccount.waitForLoad(timeout: defaultTimeout)
|
||||||
@@ -133,11 +145,14 @@ final class OnboardingTests: BaseUITestCase {
|
|||||||
// Step 2: Expand the email sign-up form and fill it in
|
// Step 2: Expand the email sign-up form and fill it in
|
||||||
createAccount.expandEmailSignup()
|
createAccount.expandEmailSignup()
|
||||||
|
|
||||||
// Use the Onboarding-specific field identifiers for the create account form
|
// Use the Onboarding-specific field identifiers for the create account form.
|
||||||
|
// Under UI testing the onboarding secure fields render as plain TextFields
|
||||||
|
// (OrganicOnboardingSecureField forces showPassword=true to dodge the iOS 26
|
||||||
|
// strong-password focus bug), so query .textFields, not .secureTextFields.
|
||||||
let onbUsernameField = app.textFields[AccessibilityIdentifiers.Onboarding.usernameField]
|
let onbUsernameField = app.textFields[AccessibilityIdentifiers.Onboarding.usernameField]
|
||||||
let onbEmailField = app.textFields[AccessibilityIdentifiers.Onboarding.emailField]
|
let onbEmailField = app.textFields[AccessibilityIdentifiers.Onboarding.emailField]
|
||||||
let onbPasswordField = app.secureTextFields[AccessibilityIdentifiers.Onboarding.passwordField]
|
let onbPasswordField = app.textFields[AccessibilityIdentifiers.Onboarding.passwordField]
|
||||||
let onbConfirmPasswordField = app.secureTextFields[AccessibilityIdentifiers.Onboarding.confirmPasswordField]
|
let onbConfirmPasswordField = app.textFields[AccessibilityIdentifiers.Onboarding.confirmPasswordField]
|
||||||
|
|
||||||
onbUsernameField.waitForExistenceOrFail(timeout: defaultTimeout)
|
onbUsernameField.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||||
onbUsernameField.focusAndType(creds.username, app: app)
|
onbUsernameField.focusAndType(creds.username, app: app)
|
||||||
@@ -170,20 +185,76 @@ final class OnboardingTests: BaseUITestCase {
|
|||||||
XCTFail("Expected verification screen to load")
|
XCTFail("Expected verification screen to load")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
verificationScreen.enterCode(TestAccountAPIClient.debugVerificationCode)
|
// The app's onboarding registration uses Kratos's real email verification
|
||||||
verificationScreen.submitCode()
|
// flow (NOT the API's DEBUG fixed code). The verify screen's onAppear fires
|
||||||
|
// its OWN sendCode (a fresh Kratos flow), invalidating any earlier code — so
|
||||||
|
// read the live code from Mailpit AFTER the screen has appeared and sent it.
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(2.0))
|
||||||
|
guard let realCode = TestAccountAPIClient.latestVerificationCode(for: creds.email) else {
|
||||||
|
throw XCTSkip("Could not read Kratos verification code from Mailpit for \(creds.email)")
|
||||||
|
}
|
||||||
|
verificationScreen.enterCode(realCode)
|
||||||
|
|
||||||
// Step 5: After verification, the app should transition to main tabs.
|
// The Onboarding Verify button is disabled until the 6-digit code commits;
|
||||||
// Landing on main tabs proves the onboarding completed and the residence
|
// wait for it to enable, then tap. Fall back to the generic submit helper.
|
||||||
// was bootstrapped automatically — no manual residence creation was required.
|
let onbVerifyButton = app.buttons[AccessibilityIdentifiers.Onboarding.verifyButton]
|
||||||
|
let enabled = NSPredicate(format: "isEnabled == true")
|
||||||
|
let exp = XCTNSPredicateExpectation(predicate: enabled, object: onbVerifyButton)
|
||||||
|
if XCTWaiter().wait(for: [exp], timeout: navigationTimeout) == .completed {
|
||||||
|
onbVerifyButton.forceTap()
|
||||||
|
} else {
|
||||||
|
verificationScreen.submitCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: After verification the Start Fresh flow continues to the
|
||||||
|
// Home Profile and First Task steps before the residence is committed
|
||||||
|
// and onboarding completes (see OnboardingCoordinator: verifyEmail →
|
||||||
|
// homeProfile → firstTask → completeOnboarding). Skip both remaining
|
||||||
|
// steps via the shared Skip button; skipping Home Profile triggers
|
||||||
|
// createResidenceIfNeeded, so reaching main tabs still proves the
|
||||||
|
// residence was bootstrapped automatically.
|
||||||
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
|
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
|
||||||
let tabBar = app.tabBars.firstMatch
|
let tabBar = app.tabBars.firstMatch
|
||||||
|
|
||||||
|
// After verifyEmail the Start Fresh flow continues:
|
||||||
|
// homeProfile (Continue → createResidenceIfNeeded) → firstTask (Skip) → main tabs.
|
||||||
|
// Drive each step by its primary action button. Reaching main tabs proves
|
||||||
|
// the residence was bootstrapped automatically by createResidenceIfNeeded.
|
||||||
|
let firstTaskTitle = app.descendants(matching: .any)
|
||||||
|
.matching(identifier: AccessibilityIdentifiers.Onboarding.firstTaskTitle).firstMatch
|
||||||
|
let submitTasksButton = app.buttons[AccessibilityIdentifiers.Onboarding.submitTasksButton]
|
||||||
|
|
||||||
|
// Step 5a: Home Profile — tap the toolbar Skip (Onboarding.SkipButton) to
|
||||||
|
// advance. handleSkip() runs createResidenceIfNeeded for the homeProfile step,
|
||||||
|
// which fires the residence-create POST and navigates to the First Task step.
|
||||||
|
// The in-screen "Continue" button has no accessibility identifier and isn't
|
||||||
|
// reliably discoverable, so drive the flow via the identified Skip button.
|
||||||
|
let skipButton = app.buttons[AccessibilityIdentifiers.Onboarding.skipButton]
|
||||||
|
if skipButton.waitForExistence(timeout: loginTimeout) {
|
||||||
|
skipButton.forceTap()
|
||||||
|
_ = firstTaskTitle.waitForExistence(timeout: loginTimeout)
|
||||||
|
|| mainTabs.waitForExistence(timeout: loginTimeout)
|
||||||
|
|| tabBar.waitForExistence(timeout: loginTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5b: First Task — Skip again to complete onboarding and land on main tabs.
|
||||||
|
if firstTaskTitle.waitForExistence(timeout: navigationTimeout) {
|
||||||
|
if skipButton.waitForExistence(timeout: navigationTimeout) {
|
||||||
|
skipButton.forceTap()
|
||||||
|
} else if submitTasksButton.waitForExistence(timeout: navigationTimeout) {
|
||||||
|
submitTasksButton.forceTap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let reachedMain = mainTabs.waitForExistence(timeout: loginTimeout)
|
let reachedMain = mainTabs.waitForExistence(timeout: loginTimeout)
|
||||||
|| tabBar.waitForExistence(timeout: 5)
|
|| tabBar.waitForExistence(timeout: 5)
|
||||||
|
let onbVisible = app.otherElements[UITestID.Root.onboarding].exists
|
||||||
|
let firstTaskVisible = firstTaskTitle.exists
|
||||||
|
let diag = "onboarding=\(onbVisible) firstTask=\(firstTaskVisible)"
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
reachedMain,
|
reachedMain,
|
||||||
"App should reach main tabs after Start Fresh onboarding + email verification, " +
|
"App should reach main tabs after Start Fresh onboarding + email verification, " +
|
||||||
"confirming the residence '\(uniqueResidenceName)' was created automatically"
|
"confirming the residence '\(uniqueResidenceName)' was created automatically. Stuck: \(diag)"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +285,8 @@ final class OnboardingTests: BaseUITestCase {
|
|||||||
else { XCTFail("Login screen did not appear after tapping Already Have Account"); return }
|
else { XCTFail("Login screen did not appear after tapping Already Have Account"); return }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
login.enterUsername("admin")
|
// Kratos uses the EMAIL as the login identifier (no username trait).
|
||||||
|
login.enterUsername("admin@honeydue.com")
|
||||||
login.enterPassword("Test1234")
|
login.enterPassword("Test1234")
|
||||||
|
|
||||||
let loginButton = app.buttons[UITestID.Auth.loginButton]
|
let loginButton = app.buttons[UITestID.Auth.loginButton]
|
||||||
@@ -270,4 +342,22 @@ final class OnboardingTests: BaseUITestCase {
|
|||||||
"After relaunch without reset, app should show login or main tabs — not onboarding"
|
"After relaunch without reset, app should show login or main tabs — not onboarding"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - From Suite0_OnboardingRebuildTests
|
||||||
|
|
||||||
|
/// Rebuild plan for legacy: Suite0_OnboardingTests.test_onboarding
|
||||||
|
/// Split into smaller tests to isolate focus/input/navigation failures.
|
||||||
|
func testR001_onboardingWelcomeLoadsAndCanNavigateToLoginEntry() {
|
||||||
|
let welcome = OnboardingWelcomeScreen(app: app)
|
||||||
|
welcome.waitForLoad(timeout: defaultTimeout)
|
||||||
|
welcome.tapAlreadyHaveAccount()
|
||||||
|
|
||||||
|
let login = LoginScreenObject(app: app)
|
||||||
|
login.waitForLoad(timeout: defaultTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testR002_startFreshFlowReachesCreateAccount() {
|
||||||
|
let createAccount = TestFlows.navigateStartFreshToCreateAccount(app: app, residenceName: "Rebuild Home")
|
||||||
|
createAccount.waitForLoad(timeout: defaultTimeout)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+15
-115
@@ -1,20 +1,19 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
/// Comprehensive residence testing suite covering all scenarios, edge cases, and variations
|
/// Residence MUTATION coverage: validation, creation (incl. edge-case names and
|
||||||
/// This test suite is designed to be bulletproof and catch regressions early
|
/// addresses), and editing.
|
||||||
///
|
///
|
||||||
/// Test Order (least to most complex):
|
/// Migrated from the mutation half of Suite4_ComprehensiveResidenceTests. The
|
||||||
/// 1. Error/incomplete data tests
|
/// view/navigation/refresh/persistence tests from that suite live in
|
||||||
/// 2. Creation tests
|
/// `ResidenceUITests`.
|
||||||
/// 3. Edit/update tests
|
///
|
||||||
/// 4. Delete/remove tests (none currently)
|
/// Per-test isolation comes from `AuthenticatedUITestCase` (fresh account per
|
||||||
/// 5. Navigation/view tests
|
/// test, deleted in teardown). These tests CREATE residences through the UI, so
|
||||||
/// 6. Performance tests
|
/// they need no seeded precondition — creation doesn't require existing data.
|
||||||
final class Suite4_ComprehensiveResidenceTests: AuthenticatedUITestCase {
|
final class ResidenceManagementUITests: AuthenticatedUITestCase {
|
||||||
|
|
||||||
override var needsAPISession: Bool { true }
|
// Test data tracking — names created through the UI, reconciled to IDs for
|
||||||
|
// API cleanup in tearDown.
|
||||||
// Test data tracking
|
|
||||||
var createdResidenceNames: [String] = []
|
var createdResidenceNames: [String] = []
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
override func setUpWithError() throws {
|
||||||
@@ -58,7 +57,6 @@ final class Suite4_ComprehensiveResidenceTests: AuthenticatedUITestCase {
|
|||||||
residenceForm.nameField.waitForExistenceOrFail(timeout: defaultTimeout, message: "Residence form should open", file: file, line: line)
|
residenceForm.nameField.waitForExistenceOrFail(timeout: defaultTimeout, message: "Residence form should open", file: file, line: line)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fill sequential address fields using the Return key to advance focus.
|
|
||||||
/// Fill address fields. Dismisses keyboard between each field for clean focus.
|
/// Fill address fields. Dismisses keyboard between each field for clean focus.
|
||||||
private func fillAddressFields(street: String, city: String, state: String, postal: String) {
|
private func fillAddressFields(street: String, city: String, state: String, postal: String) {
|
||||||
// Scroll address section into view — may need multiple swipes on smaller screens
|
// Scroll address section into view — may need multiple swipes on smaller screens
|
||||||
@@ -124,7 +122,7 @@ final class Suite4_ComprehensiveResidenceTests: AuthenticatedUITestCase {
|
|||||||
return app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", name)).firstMatch
|
return app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", name)).firstMatch
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 1. Error/Validation Tests
|
// MARK: - 1. Error / Validation Tests
|
||||||
|
|
||||||
func test01_cannotCreateResidenceWithEmptyName() {
|
func test01_cannotCreateResidenceWithEmptyName() {
|
||||||
openResidenceForm()
|
openResidenceForm()
|
||||||
@@ -183,7 +181,7 @@ final class Suite4_ComprehensiveResidenceTests: AuthenticatedUITestCase {
|
|||||||
XCTAssertTrue(residenceInList.waitForExistence(timeout: 10), "Residence should appear in list")
|
XCTAssertTrue(residenceInList.waitForExistence(timeout: 10), "Residence should appear in list")
|
||||||
}
|
}
|
||||||
|
|
||||||
// test04_createResidenceWithAllPropertyTypes — removed: backend has no seeded residence types
|
// test04_createResidenceWithAllPropertyTypes — removed in source: backend has no seeded residence types
|
||||||
|
|
||||||
func test05_createMultipleResidencesInSequence() {
|
func test05_createMultipleResidencesInSequence() {
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
let timestamp = Int(Date().timeIntervalSince1970)
|
||||||
@@ -260,7 +258,7 @@ final class Suite4_ComprehensiveResidenceTests: AuthenticatedUITestCase {
|
|||||||
XCTAssertTrue(residence.waitForExistence(timeout: 10), "Residence with long address should exist")
|
XCTAssertTrue(residence.waitForExistence(timeout: 10), "Residence with long address should exist")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 3. Edit/Update Tests
|
// MARK: - 3. Edit / Update Tests
|
||||||
|
|
||||||
func test11_editResidenceName() {
|
func test11_editResidenceName() {
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
let timestamp = Int(Date().timeIntervalSince1970)
|
||||||
@@ -385,103 +383,5 @@ final class Suite4_ComprehensiveResidenceTests: AuthenticatedUITestCase {
|
|||||||
XCTAssertTrue(updatedResidence.waitForExistence(timeout: defaultTimeout), "Residence should show updated name in list")
|
XCTAssertTrue(updatedResidence.waitForExistence(timeout: defaultTimeout), "Residence should show updated name in list")
|
||||||
|
|
||||||
// Name update verified in list — detail view doesn't display address fields
|
// Name update verified in list — detail view doesn't display address fields
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 4. View/Navigation Tests
|
|
||||||
|
|
||||||
func test13_viewResidenceDetails() {
|
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
|
||||||
let residenceName = "Detail View Test \(timestamp)"
|
|
||||||
|
|
||||||
// Create residence
|
|
||||||
createResidence(name: residenceName)
|
|
||||||
|
|
||||||
navigateToResidences()
|
|
||||||
|
|
||||||
// Tap on residence
|
|
||||||
let residence = findResidence(name: residenceName)
|
|
||||||
XCTAssertTrue(residence.waitForExistence(timeout: defaultTimeout), "Residence should exist")
|
|
||||||
residence.tap()
|
|
||||||
|
|
||||||
// Verify detail view appears with edit button or tasks section
|
|
||||||
let editButton = app.buttons[AccessibilityIdentifiers.Residence.editButton].firstMatch
|
|
||||||
let tasksSection = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks' OR label CONTAINS[c] 'Maintenance'")).firstMatch
|
|
||||||
|
|
||||||
_ = editButton.waitForExistence(timeout: defaultTimeout)
|
|
||||||
XCTAssertTrue(editButton.exists || tasksSection.exists, "Detail view should show with edit button or tasks section")
|
|
||||||
}
|
|
||||||
|
|
||||||
func test14_navigateFromResidencesToOtherTabs() {
|
|
||||||
// From Residences tab
|
|
||||||
navigateToResidences()
|
|
||||||
|
|
||||||
// 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()
|
|
||||||
_ = tasksTab.waitForExistence(timeout: defaultTimeout)
|
|
||||||
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()
|
|
||||||
_ = residencesTab.waitForExistence(timeout: defaultTimeout)
|
|
||||||
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()
|
|
||||||
_ = contractorsTab.waitForExistence(timeout: defaultTimeout)
|
|
||||||
XCTAssertTrue(contractorsTab.isSelected, "Should be on Contractors tab")
|
|
||||||
|
|
||||||
// Back to Residences
|
|
||||||
residencesTab.tap()
|
|
||||||
_ = residencesTab.waitForExistence(timeout: defaultTimeout)
|
|
||||||
XCTAssertTrue(residencesTab.isSelected, "Should be back on Residences tab again")
|
|
||||||
}
|
|
||||||
|
|
||||||
func test15_refreshResidencesList() {
|
|
||||||
navigateToResidences()
|
|
||||||
|
|
||||||
// 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.waitForExistence(timeout: defaultTimeout) {
|
|
||||||
refreshButton.tap()
|
|
||||||
_ = app.activityIndicators.firstMatch.waitForNonExistence(timeout: defaultTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
createResidence(name: residenceName)
|
|
||||||
|
|
||||||
navigateToResidences()
|
|
||||||
|
|
||||||
// Verify residence exists
|
|
||||||
var residence = findResidence(name: residenceName)
|
|
||||||
XCTAssertTrue(residence.waitForExistence(timeout: defaultTimeout), "Residence should exist before backgrounding")
|
|
||||||
|
|
||||||
// Background and reactivate app
|
|
||||||
XCUIDevice.shared.press(.home)
|
|
||||||
_ = app.wait(for: .runningForeground, timeout: 10)
|
|
||||||
|
|
||||||
// Navigate back to residences
|
|
||||||
navigateToResidences()
|
|
||||||
|
|
||||||
// Verify residence still exists
|
|
||||||
residence = findResidence(name: residenceName)
|
|
||||||
XCTAssertTrue(residence.waitForExistence(timeout: defaultTimeout), "Residence should persist after backgrounding app")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Residence READ / navigation / list / detail behaviour.
|
||||||
|
///
|
||||||
|
/// Merged from three legacy suites:
|
||||||
|
/// - ResidenceIntegrationTests (CRUD round-trips against the real backend)
|
||||||
|
/// - Suite3_ResidenceRebuildTests (rebuilt navigation/list/detail coverage —
|
||||||
|
/// manual login scaffolding removed; the base now provides a logged-in session)
|
||||||
|
/// - Suite4_ComprehensiveResidenceTests (the view/navigation/refresh/persistence tests)
|
||||||
|
///
|
||||||
|
/// Per-test isolation: `AuthenticatedUITestCase` mints a fresh, pre-verified
|
||||||
|
/// account, logs in, and deletes it in teardown. A fresh account starts EMPTY,
|
||||||
|
/// so tests that need to SEE a pre-existing residence seed it in
|
||||||
|
/// `seedAccountPreconditions` (before login) and reference `seededResidence`.
|
||||||
|
final class ResidenceUITests: AuthenticatedUITestCase {
|
||||||
|
|
||||||
|
// MARK: - Page Objects
|
||||||
|
|
||||||
|
private var residenceList: ResidenceListScreen { ResidenceListScreen(app: app) }
|
||||||
|
private var residenceForm: ResidenceFormScreen { ResidenceFormScreen(app: app) }
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func findResidence(name: String) -> XCUIElement {
|
||||||
|
app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", name)).firstMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suite3's createResidence helper, stripped of the manual login (the base
|
||||||
|
// now lands us on the main app already authenticated).
|
||||||
|
@discardableResult
|
||||||
|
private func createResidenceViaUI(name: String) -> String {
|
||||||
|
navigateToResidences()
|
||||||
|
|
||||||
|
let list = ResidenceListScreen(app: app)
|
||||||
|
list.waitForLoad(timeout: defaultTimeout)
|
||||||
|
list.openCreateResidence()
|
||||||
|
|
||||||
|
let form = ResidenceFormScreen(app: app)
|
||||||
|
form.waitForLoad(timeout: defaultTimeout)
|
||||||
|
form.enterName(name)
|
||||||
|
form.save()
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Create (round-trip) — from ResidenceIntegrationTests
|
||||||
|
|
||||||
|
func testRES_CreateResidenceAppearsInList() {
|
||||||
|
navigateToResidences()
|
||||||
|
|
||||||
|
let list = ResidenceListScreen(app: app)
|
||||||
|
list.waitForLoad(timeout: defaultTimeout)
|
||||||
|
|
||||||
|
list.openCreateResidence()
|
||||||
|
|
||||||
|
let form = ResidenceFormScreen(app: app)
|
||||||
|
form.waitForLoad(timeout: defaultTimeout)
|
||||||
|
|
||||||
|
let uniqueName = "IntTest Residence \(Int(Date().timeIntervalSince1970))"
|
||||||
|
form.enterName(uniqueName)
|
||||||
|
form.save()
|
||||||
|
|
||||||
|
let newResidence = app.staticTexts[uniqueName]
|
||||||
|
XCTAssertTrue(
|
||||||
|
newResidence.waitForExistence(timeout: loginTimeout),
|
||||||
|
"Newly created residence should appear in the list"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Edit (round-trip) — from ResidenceIntegrationTests
|
||||||
|
|
||||||
|
func testRES_EditResidenceUpdatesInList() {
|
||||||
|
// Seed a residence via API so we have a known target to edit, then
|
||||||
|
// pull-to-refresh so the fresh account's empty list picks it up.
|
||||||
|
let seeded = cleaner.seedResidence(name: "Edit Target \(Int(Date().timeIntervalSince1970))")
|
||||||
|
|
||||||
|
navigateToResidences()
|
||||||
|
pullToRefresh()
|
||||||
|
|
||||||
|
let list = ResidenceListScreen(app: app)
|
||||||
|
list.waitForLoad(timeout: defaultTimeout)
|
||||||
|
|
||||||
|
// Find and tap the seeded residence
|
||||||
|
let card = app.staticTexts[seeded.name]
|
||||||
|
pullToRefreshUntilVisible(card, maxRetries: 3)
|
||||||
|
card.waitForExistenceOrFail(timeout: loginTimeout)
|
||||||
|
card.forceTap()
|
||||||
|
|
||||||
|
// Tap edit button on detail view
|
||||||
|
let editButton = app.buttons[AccessibilityIdentifiers.Residence.editButton]
|
||||||
|
editButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||||
|
editButton.forceTap()
|
||||||
|
|
||||||
|
let form = ResidenceFormScreen(app: app)
|
||||||
|
form.waitForLoad(timeout: defaultTimeout)
|
||||||
|
|
||||||
|
// Clear and re-enter name
|
||||||
|
let nameField = form.nameField
|
||||||
|
nameField.waitUntilHittable(timeout: 10).tap()
|
||||||
|
nameField.press(forDuration: 1.0)
|
||||||
|
let selectAll = app.menuItems["Select All"]
|
||||||
|
if selectAll.waitForExistence(timeout: 2) {
|
||||||
|
selectAll.tap()
|
||||||
|
}
|
||||||
|
|
||||||
|
let updatedName = "Updated Res \(Int(Date().timeIntervalSince1970))"
|
||||||
|
nameField.typeText(updatedName)
|
||||||
|
form.save()
|
||||||
|
|
||||||
|
let updatedText = app.staticTexts[updatedName]
|
||||||
|
XCTAssertTrue(
|
||||||
|
updatedText.waitForExistence(timeout: loginTimeout),
|
||||||
|
"Updated residence name should appear after edit"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Set Primary (RES-007) — from ResidenceIntegrationTests
|
||||||
|
|
||||||
|
func test18_setPrimaryResidence() {
|
||||||
|
// Seed two residences via API; the second one will be promoted to primary
|
||||||
|
let firstResidence = cleaner.seedResidence(name: "Primary Test A \(Int(Date().timeIntervalSince1970))")
|
||||||
|
let secondResidence = cleaner.seedResidence(name: "Primary Test B \(Int(Date().timeIntervalSince1970))")
|
||||||
|
|
||||||
|
navigateToResidences()
|
||||||
|
pullToRefresh()
|
||||||
|
|
||||||
|
let list = ResidenceListScreen(app: app)
|
||||||
|
list.waitForLoad(timeout: defaultTimeout)
|
||||||
|
|
||||||
|
// Open the second residence's detail
|
||||||
|
let secondCard = app.staticTexts[secondResidence.name]
|
||||||
|
pullToRefreshUntilVisible(secondCard, maxRetries: 3)
|
||||||
|
secondCard.waitForExistenceOrFail(timeout: loginTimeout)
|
||||||
|
secondCard.forceTap()
|
||||||
|
|
||||||
|
// Tap edit
|
||||||
|
let editButton = app.buttons[AccessibilityIdentifiers.Residence.editButton]
|
||||||
|
editButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||||
|
editButton.forceTap()
|
||||||
|
|
||||||
|
let form = ResidenceFormScreen(app: app)
|
||||||
|
form.waitForLoad(timeout: defaultTimeout)
|
||||||
|
|
||||||
|
// Find and toggle the "is primary" toggle
|
||||||
|
let isPrimaryToggle = app.switches[AccessibilityIdentifiers.Residence.isPrimaryToggle]
|
||||||
|
isPrimaryToggle.scrollIntoView(in: app.scrollViews.firstMatch)
|
||||||
|
isPrimaryToggle.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||||
|
|
||||||
|
// Toggle it on (value "0" means off, "1" means on)
|
||||||
|
if (isPrimaryToggle.value as? String) == "0" {
|
||||||
|
isPrimaryToggle.forceTap()
|
||||||
|
}
|
||||||
|
|
||||||
|
form.save()
|
||||||
|
|
||||||
|
// After saving, a primary indicator should be visible — either a label,
|
||||||
|
// badge, or the toggle being on in the refreshed detail view.
|
||||||
|
let primaryIndicator = app.staticTexts.containing(
|
||||||
|
NSPredicate(format: "label CONTAINS[c] 'Primary'")
|
||||||
|
).firstMatch
|
||||||
|
|
||||||
|
let primaryBadge = app.images.containing(
|
||||||
|
NSPredicate(format: "label CONTAINS[c] 'Primary'")
|
||||||
|
).firstMatch
|
||||||
|
|
||||||
|
let indicatorVisible = primaryIndicator.waitForExistence(timeout: loginTimeout)
|
||||||
|
|| primaryBadge.waitForExistence(timeout: 3)
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
indicatorVisible,
|
||||||
|
"A primary residence indicator should appear after setting '\(secondResidence.name)' as primary"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Clean up: remove unused firstResidence id from tracking (already tracked via cleaner)
|
||||||
|
_ = firstResidence
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Double Submit Protection (OFF-004) — from ResidenceIntegrationTests
|
||||||
|
|
||||||
|
func test19_doubleSubmitProtection() {
|
||||||
|
navigateToResidences()
|
||||||
|
|
||||||
|
let list = ResidenceListScreen(app: app)
|
||||||
|
list.waitForLoad(timeout: defaultTimeout)
|
||||||
|
|
||||||
|
list.openCreateResidence()
|
||||||
|
|
||||||
|
let form = ResidenceFormScreen(app: app)
|
||||||
|
form.waitForLoad(timeout: defaultTimeout)
|
||||||
|
|
||||||
|
let uniqueName = "DoubleSubmit \(Int(Date().timeIntervalSince1970))"
|
||||||
|
form.enterName(uniqueName)
|
||||||
|
|
||||||
|
// Rapidly tap save twice to test double-submit protection
|
||||||
|
let saveButton = app.buttons[AccessibilityIdentifiers.Residence.saveButton]
|
||||||
|
saveButton.scrollIntoView(in: app.scrollViews.firstMatch)
|
||||||
|
saveButton.forceTap()
|
||||||
|
// Second tap immediately after — if the button is already disabled this will be a no-op
|
||||||
|
if saveButton.isHittable {
|
||||||
|
saveButton.forceTap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the form to dismiss (sheet closes, we return to the list)
|
||||||
|
let formDismissed = saveButton.waitForNonExistence(timeout: loginTimeout)
|
||||||
|
XCTAssertTrue(formDismissed, "Form should dismiss after save")
|
||||||
|
|
||||||
|
// Back on the residences list — count how many cells with the unique name exist
|
||||||
|
let matchingTexts = app.staticTexts.matching(
|
||||||
|
NSPredicate(format: "label == %@", uniqueName)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Allow time for the list to fully load
|
||||||
|
_ = app.staticTexts[uniqueName].waitForExistence(timeout: defaultTimeout)
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
matchingTexts.count, 1,
|
||||||
|
"Only one residence named '\(uniqueName)' should exist — double-submit protection should prevent duplicates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Track the created residence for cleanup
|
||||||
|
if let residences = TestAccountAPIClient.listResidences(token: session.token) {
|
||||||
|
if let created = residences.first(where: { $0.name == uniqueName }) {
|
||||||
|
cleaner.trackResidence(created.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Delete (round-trip) — from ResidenceIntegrationTests
|
||||||
|
|
||||||
|
func testRES_DeleteResidenceRemovesFromList() {
|
||||||
|
// Seed a residence via API — don't track it since we'll delete through the UI
|
||||||
|
let deleteName = "Delete Me \(Int(Date().timeIntervalSince1970))"
|
||||||
|
TestDataSeeder.createResidence(token: session.token, name: deleteName)
|
||||||
|
|
||||||
|
navigateToResidences()
|
||||||
|
pullToRefresh()
|
||||||
|
|
||||||
|
let list = ResidenceListScreen(app: app)
|
||||||
|
list.waitForLoad(timeout: defaultTimeout)
|
||||||
|
|
||||||
|
// Find and tap the seeded residence
|
||||||
|
let target = app.staticTexts[deleteName]
|
||||||
|
pullToRefreshUntilVisible(target, maxRetries: 3)
|
||||||
|
target.waitForExistenceOrFail(timeout: loginTimeout)
|
||||||
|
target.forceTap()
|
||||||
|
|
||||||
|
// Tap delete button
|
||||||
|
let deleteButton = app.buttons[AccessibilityIdentifiers.Residence.deleteButton]
|
||||||
|
deleteButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||||
|
deleteButton.forceTap()
|
||||||
|
|
||||||
|
// Confirm deletion in alert
|
||||||
|
let confirmButton = app.buttons[AccessibilityIdentifiers.Alert.confirmButton]
|
||||||
|
let alertDelete = app.alerts.buttons.containing(
|
||||||
|
NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'Confirm'")
|
||||||
|
).firstMatch
|
||||||
|
|
||||||
|
if confirmButton.waitForExistence(timeout: defaultTimeout) {
|
||||||
|
confirmButton.tap()
|
||||||
|
} else if alertDelete.waitForExistence(timeout: defaultTimeout) {
|
||||||
|
alertDelete.tap()
|
||||||
|
}
|
||||||
|
|
||||||
|
let deletedResidence = app.staticTexts[deleteName]
|
||||||
|
XCTAssertTrue(
|
||||||
|
deletedResidence.waitForNonExistence(timeout: loginTimeout),
|
||||||
|
"Deleted residence should no longer appear in the list"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rebuilt navigation / list / detail — from Suite3
|
||||||
|
//
|
||||||
|
// The original Suite3 ran on BaseUITestCase and logged in manually inside
|
||||||
|
// each test (a `loginAndOpenResidences` helper plus a verification-gate
|
||||||
|
// loop). The base class now provides a logged-in session, so that
|
||||||
|
// scaffolding is removed and only the residence assertions remain.
|
||||||
|
|
||||||
|
func testR301_authenticatedPreconditionCanReachMainApp() throws {
|
||||||
|
navigateToResidences()
|
||||||
|
RebuildSessionAssertions.assertOnMainApp(app, timeout: defaultTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testR302_residencesTabIsPresentAndNavigable() throws {
|
||||||
|
navigateToResidences()
|
||||||
|
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
||||||
|
XCTAssertTrue(residencesTab.exists, "Residences tab should exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testR303_residencesListLoadsAfterTabSelection() throws {
|
||||||
|
navigateToResidences()
|
||||||
|
let list = ResidenceListScreen(app: app)
|
||||||
|
list.waitForLoad(timeout: defaultTimeout)
|
||||||
|
XCTAssertTrue(list.addButton.exists, "Add residence button should be visible")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testR304_openAddResidenceFormFromResidencesList() throws {
|
||||||
|
navigateToResidences()
|
||||||
|
let list = ResidenceListScreen(app: app)
|
||||||
|
list.waitForLoad(timeout: defaultTimeout)
|
||||||
|
list.openCreateResidence()
|
||||||
|
|
||||||
|
let form = ResidenceFormScreen(app: app)
|
||||||
|
form.waitForLoad(timeout: defaultTimeout)
|
||||||
|
XCTAssertTrue(form.saveButton.exists, "Residence save button should exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testR305_cancelAddResidenceReturnsToResidenceList() throws {
|
||||||
|
navigateToResidences()
|
||||||
|
let list = ResidenceListScreen(app: app)
|
||||||
|
list.waitForLoad(timeout: defaultTimeout)
|
||||||
|
list.openCreateResidence()
|
||||||
|
|
||||||
|
let form = ResidenceFormScreen(app: app)
|
||||||
|
form.waitForLoad(timeout: defaultTimeout)
|
||||||
|
form.cancel()
|
||||||
|
|
||||||
|
list.waitForLoad(timeout: defaultTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testR306_createResidenceMinimalDataSubmitsSuccessfully() throws {
|
||||||
|
let name = "UITest Home \(Int(Date().timeIntervalSince1970))"
|
||||||
|
_ = createResidenceViaUI(name: name)
|
||||||
|
let created = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", name)).firstMatch
|
||||||
|
XCTAssertTrue(created.waitForExistence(timeout: loginTimeout), "Created residence should appear in list")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testR307_newResidenceAppearsInResidenceList() throws {
|
||||||
|
let name = "UITest Verify \(Int(Date().timeIntervalSince1970))"
|
||||||
|
_ = createResidenceViaUI(name: name)
|
||||||
|
let created = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", name)).firstMatch
|
||||||
|
XCTAssertTrue(created.waitForExistence(timeout: loginTimeout), "New residence should be visible in residences list")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testR308_openResidenceDetailsFromResidenceList() throws {
|
||||||
|
let name = "UITest Detail \(Int(Date().timeIntervalSince1970))"
|
||||||
|
_ = createResidenceViaUI(name: name)
|
||||||
|
|
||||||
|
let row = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", name)).firstMatch
|
||||||
|
row.waitForExistenceOrFail(timeout: loginTimeout).forceTap()
|
||||||
|
|
||||||
|
let edit = app.buttons[AccessibilityIdentifiers.Residence.editButton]
|
||||||
|
let delete = app.buttons[AccessibilityIdentifiers.Residence.deleteButton]
|
||||||
|
let loaded = edit.waitForExistence(timeout: defaultTimeout) || delete.waitForExistence(timeout: defaultTimeout)
|
||||||
|
XCTAssertTrue(loaded, "Residence details should expose edit or delete actions")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testR309_navigationAcrossPrimaryTabsAndBackToResidences() throws {
|
||||||
|
navigateToResidences()
|
||||||
|
|
||||||
|
let tabBar = app.tabBars.firstMatch
|
||||||
|
tabBar.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||||
|
|
||||||
|
let tasksTab = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
||||||
|
XCTAssertTrue(tasksTab.exists, "Tasks tab should exist")
|
||||||
|
tasksTab.forceTap()
|
||||||
|
|
||||||
|
let contractorsTab = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
|
||||||
|
XCTAssertTrue(contractorsTab.exists, "Contractors tab should exist")
|
||||||
|
contractorsTab.forceTap()
|
||||||
|
|
||||||
|
let residencesTab = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
||||||
|
residencesTab.forceTap()
|
||||||
|
|
||||||
|
let list = ResidenceListScreen(app: app)
|
||||||
|
list.waitForLoad(timeout: defaultTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - View / navigation / refresh / persistence — from Suite4
|
||||||
|
|
||||||
|
func test13_viewResidenceDetails() {
|
||||||
|
let timestamp = Int(Date().timeIntervalSince1970)
|
||||||
|
let residenceName = "Detail View Test \(timestamp)"
|
||||||
|
|
||||||
|
// Create residence through the UI, then open its detail
|
||||||
|
_ = createResidenceViaUI(name: residenceName)
|
||||||
|
|
||||||
|
navigateToResidences()
|
||||||
|
|
||||||
|
let residence = findResidence(name: residenceName)
|
||||||
|
XCTAssertTrue(residence.waitForExistence(timeout: defaultTimeout), "Residence should exist")
|
||||||
|
residence.tap()
|
||||||
|
|
||||||
|
// Verify detail view appears with edit button or tasks section
|
||||||
|
let editButton = app.buttons[AccessibilityIdentifiers.Residence.editButton].firstMatch
|
||||||
|
let tasksSection = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks' OR label CONTAINS[c] 'Maintenance'")).firstMatch
|
||||||
|
|
||||||
|
_ = editButton.waitForExistence(timeout: defaultTimeout)
|
||||||
|
XCTAssertTrue(editButton.exists || tasksSection.exists, "Detail view should show with edit button or tasks section")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test14_navigateFromResidencesToOtherTabs() {
|
||||||
|
// From Residences tab
|
||||||
|
navigateToResidences()
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
_ = tasksTab.waitForExistence(timeout: defaultTimeout)
|
||||||
|
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()
|
||||||
|
_ = residencesTab.waitForExistence(timeout: defaultTimeout)
|
||||||
|
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()
|
||||||
|
_ = contractorsTab.waitForExistence(timeout: defaultTimeout)
|
||||||
|
XCTAssertTrue(contractorsTab.isSelected, "Should be on Contractors tab")
|
||||||
|
|
||||||
|
// Back to Residences
|
||||||
|
residencesTab.tap()
|
||||||
|
_ = residencesTab.waitForExistence(timeout: defaultTimeout)
|
||||||
|
XCTAssertTrue(residencesTab.isSelected, "Should be back on Residences tab again")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test15_refreshResidencesList() {
|
||||||
|
navigateToResidences()
|
||||||
|
|
||||||
|
// 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.waitForExistence(timeout: defaultTimeout) {
|
||||||
|
refreshButton.tap()
|
||||||
|
_ = app.activityIndicators.firstMatch.waitForNonExistence(timeout: defaultTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test16_residencePersistsAfterBackgroundingApp() {
|
||||||
|
let timestamp = Int(Date().timeIntervalSince1970)
|
||||||
|
let residenceName = "Persistence Test \(timestamp)"
|
||||||
|
|
||||||
|
// Create residence through the UI
|
||||||
|
_ = createResidenceViaUI(name: residenceName)
|
||||||
|
|
||||||
|
navigateToResidences()
|
||||||
|
|
||||||
|
// Verify residence exists
|
||||||
|
var residence = findResidence(name: residenceName)
|
||||||
|
XCTAssertTrue(residence.waitForExistence(timeout: defaultTimeout), "Residence should exist before backgrounding")
|
||||||
|
|
||||||
|
// Background and reactivate app
|
||||||
|
XCUIDevice.shared.press(.home)
|
||||||
|
_ = app.wait(for: .runningForeground, timeout: 10)
|
||||||
|
|
||||||
|
// Navigate back to residences
|
||||||
|
navigateToResidences()
|
||||||
|
|
||||||
|
// Verify residence still exists
|
||||||
|
residence = findResidence(name: residenceName)
|
||||||
|
XCTAssertTrue(residence.waitForExistence(timeout: defaultTimeout), "Residence should persist after backgrounding app")
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-54
@@ -2,57 +2,50 @@ import XCTest
|
|||||||
|
|
||||||
/// XCUITests for multi-user residence sharing.
|
/// XCUITests for multi-user residence sharing.
|
||||||
///
|
///
|
||||||
/// Pattern: User A's data is seeded via API before app launch.
|
/// Pattern: TWO real users share a residence.
|
||||||
/// The app launches logged in as User B (via AuthenticatedUITestCase with UI-driven login).
|
/// - The PRIMARY user (User B) is the per-test isolated account minted by
|
||||||
/// User B joins User A's residence through the UI and verifies shared data.
|
/// `AuthenticatedUITestCase` — the app launches already logged in as User B.
|
||||||
|
/// - The PEER user (User A) is created explicitly here as a SECOND TestAccount
|
||||||
|
/// (`TestAccount.create(domain: "sharing-peer")`). User A owns the residence,
|
||||||
|
/// seeds a task + document on it, and generates a share code via the API.
|
||||||
|
/// - User B joins User A's residence through the UI and verifies the shared data.
|
||||||
///
|
///
|
||||||
/// ALL assertions check UI elements only. If the UI doesn't show the expected
|
/// ALL assertions check UI elements only. If the UI doesn't show the expected
|
||||||
/// data, that indicates a real app bug and the test should fail.
|
/// data, that indicates a real app bug and the test should fail.
|
||||||
final class MultiUserSharingUITests: AuthenticatedUITestCase {
|
///
|
||||||
|
/// User A is cleaned up in `tearDownWithError`; User B is deleted by the base.
|
||||||
|
final class SharingUITests: AuthenticatedUITestCase {
|
||||||
|
|
||||||
/// User A's session (API-only, set up before app launch)
|
/// Relaunch per test so the joined-residence + shared-document caches don't
|
||||||
private var userASession: TestSession!
|
/// bleed across tests (the documents/tasks tabs can show a stale empty list
|
||||||
/// User B's session (fresh account, logged in via UI)
|
/// on a reused session).
|
||||||
private var userBSession: TestSession!
|
override var relaunchBetweenTests: Bool { true }
|
||||||
/// The shared residence ID
|
|
||||||
|
// ── User A (the PEER / owner) — created explicitly per test ──
|
||||||
|
/// User A's isolated account (owner of the shared residence).
|
||||||
|
private var userA: TestAccount!
|
||||||
|
/// The shared residence ID (owned by User A).
|
||||||
private var sharedResidenceId: Int!
|
private var sharedResidenceId: Int!
|
||||||
/// The share code User B will enter in the UI
|
/// The share code User B will enter in the UI.
|
||||||
private var shareCode: String!
|
private var shareCode: String!
|
||||||
/// The residence name (to verify in UI)
|
/// The residence name (to verify in UI).
|
||||||
private var sharedResidenceName: String!
|
private var sharedResidenceName: String!
|
||||||
/// Titles of tasks/documents seeded by User A (to verify in UI)
|
/// Titles of task/document seeded by User A (to verify in UI).
|
||||||
private var userATaskTitle: String!
|
private var userATaskTitle: String!
|
||||||
private var userADocTitle: String!
|
private var userADocTitle: String!
|
||||||
|
|
||||||
/// Stored credentials for User B, set before super.setUpWithError() calls loginToMainApp()
|
|
||||||
private var _userBUsername: String = ""
|
|
||||||
private var _userBPassword: String = ""
|
|
||||||
|
|
||||||
/// Dynamic credentials — returns User B's freshly created account
|
|
||||||
override var testCredentials: (username: String, password: String) {
|
|
||||||
(_userBUsername, _userBPassword)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
override func setUpWithError() throws {
|
||||||
guard TestAccountAPIClient.isBackendReachable() else {
|
// Base mints + logs in the PRIMARY account (User B) and launches the app.
|
||||||
throw XCTSkip("Local backend not reachable")
|
try super.setUpWithError()
|
||||||
}
|
|
||||||
|
|
||||||
// ── Create User A via API ──
|
// ── Create User A (the peer/owner) as a second isolated account ──
|
||||||
let runId = UUID().uuidString.prefix(6)
|
let runId = UUID().uuidString.prefix(6)
|
||||||
guard let a = TestAccountAPIClient.createVerifiedAccount(
|
userA = TestAccount.create(domain: "sharing-peer")
|
||||||
username: "owner_\(runId)",
|
|
||||||
email: "owner_\(runId)@test.com",
|
|
||||||
password: "TestPass123!"
|
|
||||||
) else {
|
|
||||||
XCTFail("Could not create User A (owner)"); return
|
|
||||||
}
|
|
||||||
userASession = a
|
|
||||||
|
|
||||||
// ── User A creates a residence ──
|
// ── User A creates a residence ──
|
||||||
sharedResidenceName = "Shared House \(runId)"
|
sharedResidenceName = "Shared House \(runId)"
|
||||||
guard let residence = TestAccountAPIClient.createResidence(
|
guard let residence = TestAccountAPIClient.createResidence(
|
||||||
token: userASession.token,
|
token: userA.token,
|
||||||
name: sharedResidenceName
|
name: sharedResidenceName
|
||||||
) else {
|
) else {
|
||||||
XCTFail("Could not create residence for User A"); return
|
XCTFail("Could not create residence for User A"); return
|
||||||
@@ -61,7 +54,7 @@ final class MultiUserSharingUITests: AuthenticatedUITestCase {
|
|||||||
|
|
||||||
// ── User A generates a share code ──
|
// ── User A generates a share code ──
|
||||||
guard let code = TestAccountAPIClient.generateShareCode(
|
guard let code = TestAccountAPIClient.generateShareCode(
|
||||||
token: userASession.token,
|
token: userA.token,
|
||||||
residenceId: sharedResidenceId
|
residenceId: sharedResidenceId
|
||||||
) else {
|
) else {
|
||||||
XCTFail("Could not generate share code"); return
|
XCTFail("Could not generate share code"); return
|
||||||
@@ -71,38 +64,24 @@ final class MultiUserSharingUITests: AuthenticatedUITestCase {
|
|||||||
// ── User A seeds data on the residence ──
|
// ── User A seeds data on the residence ──
|
||||||
userATaskTitle = "Fix Roof \(runId)"
|
userATaskTitle = "Fix Roof \(runId)"
|
||||||
_ = TestAccountAPIClient.createTask(
|
_ = TestAccountAPIClient.createTask(
|
||||||
token: userASession.token,
|
token: userA.token,
|
||||||
residenceId: sharedResidenceId,
|
residenceId: sharedResidenceId,
|
||||||
title: userATaskTitle
|
title: userATaskTitle
|
||||||
)
|
)
|
||||||
|
|
||||||
userADocTitle = "Home Warranty \(runId)"
|
userADocTitle = "Home Warranty \(runId)"
|
||||||
_ = TestAccountAPIClient.createDocument(
|
_ = TestAccountAPIClient.createDocument(
|
||||||
token: userASession.token,
|
token: userA.token,
|
||||||
residenceId: sharedResidenceId,
|
residenceId: sharedResidenceId,
|
||||||
title: userADocTitle,
|
title: userADocTitle,
|
||||||
documentType: "warranty"
|
documentType: "warranty"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Create User B via API (fresh account) ──
|
|
||||||
guard let b = TestAccountManager.createVerifiedAccount() else {
|
|
||||||
XCTFail("Could not create User B (fresh account)"); return
|
|
||||||
}
|
|
||||||
userBSession = b
|
|
||||||
|
|
||||||
// Set User B's credentials BEFORE super.setUpWithError() calls loginToMainApp()
|
|
||||||
_userBUsername = b.username
|
|
||||||
_userBPassword = b.password
|
|
||||||
|
|
||||||
// ── Now launch the app and login as User B via base class ──
|
|
||||||
try super.setUpWithError()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tearDownWithError() throws {
|
override func tearDownWithError() throws {
|
||||||
// Clean up User A's data
|
// Clean up User A (cascades its residence + seeded data). User B is
|
||||||
if let id = sharedResidenceId, let token = userASession?.token {
|
// deleted by the base class.
|
||||||
_ = TestAccountAPIClient.deleteResidence(token: token, id: id)
|
userA?.delete()
|
||||||
}
|
|
||||||
try super.tearDownWithError()
|
try super.tearDownWithError()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,50 +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: BaseUITestCase {
|
|
||||||
override var includeResetStateLaunchArgument: Bool { false }
|
|
||||||
|
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
|
||||||
try super.setUpWithError()
|
|
||||||
|
|
||||||
// CRITICAL: Ensure we're logged out before each test
|
|
||||||
ensureLoggedOut()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDownWithError() throws {
|
|
||||||
try super.tearDownWithError()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
|
||||||
|
|
||||||
/// Ensures the user is logged out and on the login screen
|
|
||||||
private func ensureLoggedOut() {
|
|
||||||
UITestHelpers.ensureLoggedOut(app: app)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Tests
|
|
||||||
|
|
||||||
/// Test 1: App launches and shows login screen (or logs out if needed)
|
|
||||||
func testAppLaunchesAndShowsLoginScreen() {
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+6
-1
@@ -1,6 +1,11 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
final class AppLaunchTests: BaseUITestCase {
|
/// Smoke tests for the logged-OUT cold-launch surface: the onboarding welcome
|
||||||
|
/// screen and its primary actions.
|
||||||
|
///
|
||||||
|
/// These must run WITHOUT a logged-in user (BaseUITestCase), so they verify the
|
||||||
|
/// first-run onboarding entry point rather than the authenticated main tabs.
|
||||||
|
final class AppLaunchUITests: BaseUITestCase {
|
||||||
func testF001_ColdLaunchShowsOnboardingWelcome() {
|
func testF001_ColdLaunchShowsOnboardingWelcome() {
|
||||||
RootScreen(app: app).waitForReady(timeout: defaultTimeout)
|
RootScreen(app: app).waitForReady(timeout: defaultTimeout)
|
||||||
|
|
||||||
+4
-2
@@ -6,13 +6,15 @@ import XCTest
|
|||||||
/// and core navigation is functional. These are the minimum-viability tests
|
/// and core navigation is functional. These are the minimum-viability tests
|
||||||
/// that must pass before any PR can merge.
|
/// that must pass before any PR can merge.
|
||||||
///
|
///
|
||||||
|
/// These run logged-IN (via AuthenticatedUITestCase). Logged-OUT launch-surface
|
||||||
|
/// checks live in `AppLaunchUITests` (BaseUITestCase) in this same folder.
|
||||||
|
///
|
||||||
/// Zero sleep() calls -- all waits are condition-based.
|
/// Zero sleep() calls -- all waits are condition-based.
|
||||||
final class SmokeTests: AuthenticatedUITestCase {
|
final class SmokeUITests: AuthenticatedUITestCase {
|
||||||
|
|
||||||
// MARK: - App Launch
|
// MARK: - App Launch
|
||||||
|
|
||||||
func testAppLaunches() {
|
func testAppLaunches() {
|
||||||
let tabBar = app.tabBars.firstMatch
|
|
||||||
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
||||||
let onboarding = app.descendants(matching: .any)
|
let onboarding = app.descendants(matching: .any)
|
||||||
.matching(identifier: UITestID.Onboarding.startFreshButton).firstMatch
|
.matching(identifier: UITestID.Onboarding.startFreshButton).firstMatch
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
|
|
||||||
/// Task management tests.
|
|
||||||
/// Precondition: at least one residence must exist (task creation requires it).
|
|
||||||
final class Suite5_TaskTests: AuthenticatedUITestCase {
|
|
||||||
|
|
||||||
override var needsAPISession: Bool { true }
|
|
||||||
override var testCredentials: (username: String, password: String) { ("testuser", "TestPass123!") }
|
|
||||||
override var apiCredentials: (username: String, password: String) { ("testuser", "TestPass123!") }
|
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
|
||||||
try super.setUpWithError()
|
|
||||||
|
|
||||||
// Precondition: residence must exist for task add button
|
|
||||||
ensureResidenceExists()
|
|
||||||
|
|
||||||
// Dismiss any open form from previous test
|
|
||||||
let cancelButton = app.buttons[AccessibilityIdentifiers.Task.formCancelButton].firstMatch
|
|
||||||
if cancelButton.exists { cancelButton.tap() }
|
|
||||||
|
|
||||||
navigateToTasks()
|
|
||||||
// Wait for task screen to load
|
|
||||||
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
|
||||||
addButton.waitForExistenceOrFail(timeout: navigationTimeout, message: "Task add button should appear")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 1. Validation
|
|
||||||
|
|
||||||
func test01_cancelTaskCreation() {
|
|
||||||
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
|
||||||
addButton.tap()
|
|
||||||
|
|
||||||
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField].firstMatch
|
|
||||||
titleField.waitForExistenceOrFail(timeout: defaultTimeout, message: "Task form should open")
|
|
||||||
|
|
||||||
let cancelButton = app.buttons[AccessibilityIdentifiers.Task.formCancelButton].firstMatch
|
|
||||||
cancelButton.waitForExistenceOrFail(timeout: defaultTimeout, message: "Cancel button should exist")
|
|
||||||
cancelButton.tap()
|
|
||||||
|
|
||||||
// Verify we're back on the task list
|
|
||||||
let addButtonAgain = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
|
||||||
XCTAssertTrue(addButtonAgain.waitForExistence(timeout: navigationTimeout), "Should be back on tasks list after cancel")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 2. View/List
|
|
||||||
|
|
||||||
func test02_tasksTabExists() {
|
|
||||||
let tabBar = app.tabBars.firstMatch
|
|
||||||
XCTAssertTrue(tabBar.exists, "Tab bar should exist")
|
|
||||||
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
|
||||||
XCTAssertTrue(addButton.exists, "Task add button should exist (proves we're on Tasks tab)")
|
|
||||||
}
|
|
||||||
|
|
||||||
func test03_viewTasksList() {
|
|
||||||
// Tasks screen should show — verified by the add button existence from setUp
|
|
||||||
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
|
||||||
XCTAssertTrue(addButton.exists, "Tasks screen should be visible with add button")
|
|
||||||
}
|
|
||||||
|
|
||||||
func test04_addTaskButtonEnabled() {
|
|
||||||
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
|
||||||
XCTAssertTrue(addButton.isEnabled, "Task add button should be enabled when residence exists")
|
|
||||||
}
|
|
||||||
|
|
||||||
func test05_navigateToAddTask() {
|
|
||||||
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
|
||||||
addButton.tap()
|
|
||||||
|
|
||||||
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField].firstMatch
|
|
||||||
titleField.waitForExistenceOrFail(timeout: defaultTimeout, message: "Task title field should appear in add form")
|
|
||||||
|
|
||||||
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton].firstMatch
|
|
||||||
XCTAssertTrue(saveButton.exists, "Save button should exist in add task form")
|
|
||||||
|
|
||||||
// Clean up: dismiss form
|
|
||||||
let cancelButton = app.buttons[AccessibilityIdentifiers.Task.formCancelButton].firstMatch
|
|
||||||
if cancelButton.exists { cancelButton.tap() }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 3. Creation
|
|
||||||
|
|
||||||
func test06_createBasicTask() {
|
|
||||||
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
|
||||||
addButton.tap()
|
|
||||||
|
|
||||||
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField].firstMatch
|
|
||||||
titleField.waitForExistenceOrFail(timeout: defaultTimeout, message: "Task title field should appear")
|
|
||||||
|
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
|
||||||
let taskTitle = "UITest Task \(timestamp)"
|
|
||||||
fillTextField(identifier: AccessibilityIdentifiers.Task.titleField, text: taskTitle)
|
|
||||||
|
|
||||||
dismissKeyboard()
|
|
||||||
app.swipeUp()
|
|
||||||
|
|
||||||
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton].firstMatch
|
|
||||||
saveButton.waitForExistenceOrFail(timeout: defaultTimeout, message: "Save button should exist")
|
|
||||||
saveButton.tap()
|
|
||||||
|
|
||||||
// Wait for form to dismiss
|
|
||||||
_ = saveButton.waitForNonExistence(timeout: navigationTimeout)
|
|
||||||
|
|
||||||
// Verify task was created via API (also gives the server time to process)
|
|
||||||
if let items = TestAccountAPIClient.listTasks(token: session.token),
|
|
||||||
let created = items.first(where: { $0.title.contains(taskTitle) }) {
|
|
||||||
cleaner.trackTask(created.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigate to tasks tab and refresh to pick up the newly created task
|
|
||||||
navigateToTasks()
|
|
||||||
refreshTasks()
|
|
||||||
let taskListScreen = TaskListScreen(app: app)
|
|
||||||
let newTask = taskListScreen.findTask(title: taskTitle)
|
|
||||||
XCTAssertTrue(newTask.waitForExistence(timeout: loginTimeout), "New task '\(taskTitle)' should appear in the list")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 4. View Details
|
|
||||||
|
|
||||||
func test07_viewTaskDetails() {
|
|
||||||
// Create a task first
|
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
|
||||||
let taskTitle = "UITest Detail \(timestamp)"
|
|
||||||
|
|
||||||
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
|
||||||
addButton.tap()
|
|
||||||
|
|
||||||
fillTextField(identifier: AccessibilityIdentifiers.Task.titleField, text: taskTitle)
|
|
||||||
dismissKeyboard()
|
|
||||||
app.swipeUp()
|
|
||||||
|
|
||||||
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton].firstMatch
|
|
||||||
saveButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
|
||||||
saveButton.tap()
|
|
||||||
_ = saveButton.waitForNonExistence(timeout: navigationTimeout)
|
|
||||||
|
|
||||||
// Verify task was created via API (also gives the server time to process)
|
|
||||||
if let items = TestAccountAPIClient.listTasks(token: session.token),
|
|
||||||
let created = items.first(where: { $0.title.contains(taskTitle) }) {
|
|
||||||
cleaner.trackTask(created.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigate to tasks tab and refresh to pick up the newly created task
|
|
||||||
navigateToTasks()
|
|
||||||
refreshTasks()
|
|
||||||
let taskListScreen = TaskListScreen(app: app)
|
|
||||||
let taskCard = taskListScreen.findTask(title: taskTitle)
|
|
||||||
taskCard.waitForExistenceOrFail(timeout: loginTimeout, message: "Created task should appear in list")
|
|
||||||
|
|
||||||
// Verify the task card is accessible and the actions menu exists
|
|
||||||
// (There is no task detail screen — cards are self-contained with a context menu)
|
|
||||||
let actionsMenu = app.buttons["Task actions"].firstMatch
|
|
||||||
XCTAssertTrue(actionsMenu.waitForExistence(timeout: navigationTimeout), "Task actions menu should be accessible")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 5. Navigation
|
|
||||||
|
|
||||||
func test08_navigateToContractors() {
|
|
||||||
navigateToContractors()
|
|
||||||
let addButton = app.buttons[AccessibilityIdentifiers.Contractor.addButton].firstMatch
|
|
||||||
XCTAssertTrue(addButton.waitForExistence(timeout: navigationTimeout), "Contractors screen should load")
|
|
||||||
}
|
|
||||||
|
|
||||||
func test09_navigateToDocuments() {
|
|
||||||
navigateToDocuments()
|
|
||||||
let addButton = app.buttons[AccessibilityIdentifiers.Document.addButton].firstMatch
|
|
||||||
XCTAssertTrue(addButton.waitForExistence(timeout: navigationTimeout), "Documents screen should load")
|
|
||||||
}
|
|
||||||
|
|
||||||
func test10_navigateBetweenTabs() {
|
|
||||||
navigateToResidences()
|
|
||||||
let resAddButton = app.buttons[AccessibilityIdentifiers.Residence.addButton].firstMatch
|
|
||||||
XCTAssertTrue(resAddButton.waitForExistence(timeout: navigationTimeout), "Residences screen should load")
|
|
||||||
|
|
||||||
navigateToTasks()
|
|
||||||
let taskAddButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
|
||||||
XCTAssertTrue(taskAddButton.waitForExistence(timeout: navigationTimeout), "Tasks screen should load after navigating back")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,24 @@ import XCTest
|
|||||||
|
|
||||||
/// Phase 3 — Cleanup tests run sequentially after all parallel suites.
|
/// Phase 3 — Cleanup tests run sequentially after all parallel suites.
|
||||||
/// Clears test data via the admin API, then re-seeds the required accounts.
|
/// Clears test data via the admin API, then re-seeds the required accounts.
|
||||||
|
///
|
||||||
|
/// CLEANUP ORDER (XCTest runs methods alphabetically):
|
||||||
|
/// testCleanup01_clearAllTestData → admin-panel login + POST /admin/settings/clear-all-data
|
||||||
|
/// testCleanup01b_deleteKratosIdentities → delete seeded Kratos identities (clean slate)
|
||||||
|
/// testCleanup02_reSeedTestUser → re-create testuser via Kratos
|
||||||
|
/// testCleanup03_reSeedAdmin → re-create admin via Kratos
|
||||||
|
///
|
||||||
|
/// WHY WE DELETE KRATOS IDENTITIES:
|
||||||
|
/// `clear-all-data` is LOCAL-ONLY — it wipes residences/tasks and non-superuser
|
||||||
|
/// `auth_user` rows in Postgres, but it does NOT touch Kratos. The Kratos
|
||||||
|
/// identities (testuser@honeydue.com, admin@honeydue.com) survive the wipe, and
|
||||||
|
/// the backend also caches validated Kratos sessions in Redis (kratos_sess:<hash>,
|
||||||
|
/// 24h TTL). Left alone, that leaves orphaned/stale auth state across runs:
|
||||||
|
/// - Re-seeding via createVerifiedAccount would hit a Kratos 409 (identity exists).
|
||||||
|
/// - Tokens minted before the wipe map to now-deleted local user rows → stale-session
|
||||||
|
/// errors until the next GET /auth/me/ lazily re-provisions the local user.
|
||||||
|
/// Deleting the Kratos identities after the local wipe makes re-seed a TRUE reset:
|
||||||
|
/// fresh identities, no 409, no orphaned sessions.
|
||||||
final class SuiteZZ_CleanupTests: XCTestCase {
|
final class SuiteZZ_CleanupTests: XCTestCase {
|
||||||
|
|
||||||
override func setUp() {
|
override func setUp() {
|
||||||
@@ -14,20 +32,37 @@ final class SuiteZZ_CleanupTests: XCTestCase {
|
|||||||
func testCleanup01_clearAllTestData() {
|
func testCleanup01_clearAllTestData() {
|
||||||
let baseURL = TestAccountAPIClient.baseURL
|
let baseURL = TestAccountAPIClient.baseURL
|
||||||
|
|
||||||
// 1. Login to admin panel (admin API uses Bearer token)
|
// 1. Login to the admin PANEL (SQL super-admin: admin@honeydue.com / password123).
|
||||||
// Try re-seeded password first, then fallback to default
|
// This is a different system from the Kratos APP identity that happens to
|
||||||
var adminToken = adminLogin(baseURL: baseURL, password: "test1234")
|
// share the admin@honeydue.com email — see AuthenticatedUITestCase for the
|
||||||
if adminToken == nil {
|
// full distinction. Admin API uses a Bearer token.
|
||||||
adminToken = adminLogin(baseURL: baseURL, password: "password123")
|
let adminToken = adminLogin(baseURL: baseURL, password: "password123")
|
||||||
}
|
|
||||||
XCTAssertNotNil(adminToken, "Admin login failed — cannot clear test data")
|
XCTAssertNotNil(adminToken, "Admin login failed — cannot clear test data")
|
||||||
guard let token = adminToken else { return }
|
guard let token = adminToken else { return }
|
||||||
|
|
||||||
// 2. Call clear-all-data
|
// 2. Call clear-all-data (LOCAL-ONLY wipe — see class header).
|
||||||
let clearResult = adminClearAllData(baseURL: baseURL, token: token)
|
let clearResult = adminClearAllData(baseURL: baseURL, token: token)
|
||||||
XCTAssertTrue(clearResult, "Failed to clear all test data via admin API")
|
XCTAssertTrue(clearResult, "Failed to clear all test data via admin API")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Delete Kratos Identities
|
||||||
|
|
||||||
|
/// Runs between the local wipe (01) and re-seed (02). `clear-all-data` is
|
||||||
|
/// local-only, so the seeded Kratos identities survive it. Delete them here so
|
||||||
|
/// re-seeding creates fresh identities with no Kratos 409 and no orphaned/stale
|
||||||
|
/// auth state (see class header). Best-effort: deleteKratosIdentity is idempotent
|
||||||
|
/// (true if deleted or already absent); we log but do not hard-fail on false.
|
||||||
|
func testCleanup01b_deleteKratosIdentities() {
|
||||||
|
let deletedTestUser = TestAccountAPIClient.deleteKratosIdentity(email: "testuser@honeydue.com")
|
||||||
|
if !deletedTestUser {
|
||||||
|
NSLog("[Cleanup] deleteKratosIdentity(testuser@honeydue.com) returned false — continuing (best-effort)")
|
||||||
|
}
|
||||||
|
let deletedAdmin = TestAccountAPIClient.deleteKratosIdentity(email: "admin@honeydue.com")
|
||||||
|
if !deletedAdmin {
|
||||||
|
NSLog("[Cleanup] deleteKratosIdentity(admin@honeydue.com) returned false — continuing (best-effort)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Re-Seed Accounts
|
// MARK: - Re-Seed Accounts
|
||||||
|
|
||||||
func testCleanup02_reSeedTestUser() {
|
func testCleanup02_reSeedTestUser() {
|
||||||
@@ -51,7 +86,8 @@ final class SuiteZZ_CleanupTests: XCTestCase {
|
|||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
/// Admin API uses `Bearer` token (not `Token` prefix), so we use inline URLRequest.
|
/// Admin API uses `Bearer` token (not `Token` prefix), so we use inline URLRequest.
|
||||||
private func adminLogin(baseURL: String, password: String = "test1234") -> String? {
|
/// The admin-panel super-admin is admin@honeydue.com / password123.
|
||||||
|
private func adminLogin(baseURL: String, password: String = "password123") -> String? {
|
||||||
guard let url = URL(string: "\(baseURL)/admin/auth/login") else { return nil }
|
guard let url = URL(string: "\(baseURL)/admin/auth/login") else { return nil }
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Task create/read/update/delete UI tests.
|
||||||
|
///
|
||||||
|
/// Merged from the former `Suite5_TaskTests` and `Tests/TaskIntegrationTests`.
|
||||||
|
/// Per-test isolation is provided by `AuthenticatedUITestCase`: every test mints
|
||||||
|
/// a fresh account, logs in, and tears it down. Task creation gates on a residence
|
||||||
|
/// existing, so `requiresResidence` seeds one BEFORE login (the fresh account is
|
||||||
|
/// otherwise empty and the Add-Task button would stay disabled).
|
||||||
|
///
|
||||||
|
/// Tests that must SEE a pre-existing task (uncancel flows) seed that task in
|
||||||
|
/// `seedAccountPreconditions` so the app loads it on its post-login fetch.
|
||||||
|
final class TaskCRUDUITests: AuthenticatedUITestCase {
|
||||||
|
|
||||||
|
// Task creation gates on a residence existing; seed one before login so the
|
||||||
|
// fresh account's app sees it (otherwise the Add-Task button stays disabled).
|
||||||
|
override var requiresResidence: Bool { true }
|
||||||
|
|
||||||
|
// MARK: - Preconditions
|
||||||
|
|
||||||
|
/// Cancelled task seeded before login for the uncancel flows. A fresh account
|
||||||
|
/// is empty at login, so a task seeded in the test body would be invisible to
|
||||||
|
/// the app without a manual refresh — seed it here instead.
|
||||||
|
private(set) var seededCancelledTask_uncancelFlow: TestTask?
|
||||||
|
private(set) var seededCancelledTask_uncancelV2: TestTask?
|
||||||
|
|
||||||
|
override func seedAccountPreconditions(_ account: TestAccount) {
|
||||||
|
super.seedAccountPreconditions(account) // seeds seededResidence (requiresResidence)
|
||||||
|
guard let residence = seededResidence else { return }
|
||||||
|
|
||||||
|
// TASK-010: a cancelled task that the test will uncancel/reopen.
|
||||||
|
seededCancelledTask_uncancelFlow = TestDataSeeder.createCancelledTask(
|
||||||
|
token: account.token,
|
||||||
|
residenceId: residence.id
|
||||||
|
)
|
||||||
|
|
||||||
|
// TASK-010 (v2): a named residence+task, cancelled, that the test restores.
|
||||||
|
let v2Task = account.seedTask(
|
||||||
|
residenceId: residence.id,
|
||||||
|
title: "Uncancel Me \(Int(Date().timeIntervalSince1970))"
|
||||||
|
)
|
||||||
|
seededCancelledTask_uncancelV2 = TestAccountAPIClient.cancelTask(token: account.token, id: v2Task.id) ?? v2Task
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
try super.setUpWithError()
|
||||||
|
|
||||||
|
// Dismiss any open form from a previous test
|
||||||
|
let cancelButton = app.buttons[AccessibilityIdentifiers.Task.formCancelButton].firstMatch
|
||||||
|
if cancelButton.exists { cancelButton.tap() }
|
||||||
|
|
||||||
|
navigateToTasks()
|
||||||
|
// Wait for task screen to load
|
||||||
|
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
||||||
|
addButton.waitForExistenceOrFail(timeout: navigationTimeout, message: "Task add button should appear")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Validation
|
||||||
|
|
||||||
|
func test01_cancelTaskCreation() {
|
||||||
|
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
||||||
|
addButton.tap()
|
||||||
|
|
||||||
|
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField].firstMatch
|
||||||
|
titleField.waitForExistenceOrFail(timeout: defaultTimeout, message: "Task form should open")
|
||||||
|
|
||||||
|
let cancelButton = app.buttons[AccessibilityIdentifiers.Task.formCancelButton].firstMatch
|
||||||
|
cancelButton.waitForExistenceOrFail(timeout: defaultTimeout, message: "Cancel button should exist")
|
||||||
|
cancelButton.tap()
|
||||||
|
|
||||||
|
// Verify we're back on the task list
|
||||||
|
let addButtonAgain = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
||||||
|
XCTAssertTrue(addButtonAgain.waitForExistence(timeout: navigationTimeout), "Should be back on tasks list after cancel")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - View/List
|
||||||
|
|
||||||
|
func test02_tasksTabExists() {
|
||||||
|
let tabBar = app.tabBars.firstMatch
|
||||||
|
XCTAssertTrue(tabBar.exists, "Tab bar should exist")
|
||||||
|
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
||||||
|
XCTAssertTrue(addButton.exists, "Task add button should exist (proves we're on Tasks tab)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test03_viewTasksList() {
|
||||||
|
// Tasks screen should show — verified by the add button existence from setUp
|
||||||
|
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
||||||
|
XCTAssertTrue(addButton.exists, "Tasks screen should be visible with add button")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test04_addTaskButtonEnabled() {
|
||||||
|
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
||||||
|
XCTAssertTrue(addButton.isEnabled, "Task add button should be enabled when residence exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test05_navigateToAddTask() {
|
||||||
|
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
||||||
|
addButton.tap()
|
||||||
|
|
||||||
|
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField].firstMatch
|
||||||
|
titleField.waitForExistenceOrFail(timeout: defaultTimeout, message: "Task title field should appear in add form")
|
||||||
|
|
||||||
|
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton].firstMatch
|
||||||
|
XCTAssertTrue(saveButton.exists, "Save button should exist in add task form")
|
||||||
|
|
||||||
|
// Clean up: dismiss form
|
||||||
|
let cancelButton = app.buttons[AccessibilityIdentifiers.Task.formCancelButton].firstMatch
|
||||||
|
if cancelButton.exists { cancelButton.tap() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Creation
|
||||||
|
|
||||||
|
func test06_createBasicTask() {
|
||||||
|
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
||||||
|
addButton.tap()
|
||||||
|
|
||||||
|
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField].firstMatch
|
||||||
|
titleField.waitForExistenceOrFail(timeout: defaultTimeout, message: "Task title field should appear")
|
||||||
|
|
||||||
|
let timestamp = Int(Date().timeIntervalSince1970)
|
||||||
|
let taskTitle = "UITest Task \(timestamp)"
|
||||||
|
fillTextField(identifier: AccessibilityIdentifiers.Task.titleField, text: taskTitle)
|
||||||
|
|
||||||
|
dismissKeyboard()
|
||||||
|
app.swipeUp()
|
||||||
|
|
||||||
|
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton].firstMatch
|
||||||
|
saveButton.waitForExistenceOrFail(timeout: defaultTimeout, message: "Save button should exist")
|
||||||
|
saveButton.tap()
|
||||||
|
|
||||||
|
// Wait for form to dismiss
|
||||||
|
_ = saveButton.waitForNonExistence(timeout: navigationTimeout)
|
||||||
|
|
||||||
|
// Verify task was created via API (also gives the server time to process)
|
||||||
|
if let items = TestAccountAPIClient.listTasks(token: session.token),
|
||||||
|
let created = items.first(where: { $0.title.contains(taskTitle) }) {
|
||||||
|
cleaner.trackTask(created.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to tasks tab and refresh to pick up the newly created task
|
||||||
|
navigateToTasks()
|
||||||
|
refreshTasks()
|
||||||
|
let taskListScreen = TaskListScreen(app: app)
|
||||||
|
let newTask = taskListScreen.findTask(title: taskTitle)
|
||||||
|
XCTAssertTrue(newTask.waitForExistence(timeout: loginTimeout), "New task '\(taskTitle)' should appear in the list")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTASK_CreateTaskAppearsInList() {
|
||||||
|
// Residence is seeded before login (requiresResidence) so task creation
|
||||||
|
// has a valid target.
|
||||||
|
navigateToTasks()
|
||||||
|
|
||||||
|
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
||||||
|
let emptyState = app.otherElements[AccessibilityIdentifiers.Task.emptyStateView]
|
||||||
|
let taskList = app.otherElements[AccessibilityIdentifiers.Task.tasksList]
|
||||||
|
|
||||||
|
let loaded = addButton.waitForExistence(timeout: defaultTimeout)
|
||||||
|
|| emptyState.waitForExistence(timeout: 3)
|
||||||
|
|| taskList.waitForExistence(timeout: 3)
|
||||||
|
XCTAssertTrue(loaded, "Tasks screen should load")
|
||||||
|
|
||||||
|
if addButton.exists && addButton.isHittable {
|
||||||
|
addButton.forceTap()
|
||||||
|
} else {
|
||||||
|
let emptyAddButton = app.buttons.containing(
|
||||||
|
NSPredicate(format: "label CONTAINS[c] 'Add' OR label CONTAINS[c] 'Create'")
|
||||||
|
).firstMatch
|
||||||
|
emptyAddButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||||
|
emptyAddButton.forceTap()
|
||||||
|
}
|
||||||
|
|
||||||
|
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField]
|
||||||
|
titleField.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||||
|
let uniqueTitle = "IntTest Task \(Int(Date().timeIntervalSince1970))"
|
||||||
|
titleField.forceTap()
|
||||||
|
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
|
||||||
|
titleField.typeText(uniqueTitle)
|
||||||
|
|
||||||
|
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton]
|
||||||
|
let scrollContainer = app.scrollViews.firstMatch.exists ? app.scrollViews.firstMatch : app.collectionViews.firstMatch
|
||||||
|
saveButton.scrollIntoView(in: scrollContainer)
|
||||||
|
saveButton.forceTap()
|
||||||
|
|
||||||
|
let newTask = app.staticTexts[uniqueTitle]
|
||||||
|
XCTAssertTrue(
|
||||||
|
newTask.waitForExistence(timeout: loginTimeout),
|
||||||
|
"Newly created task should appear"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - View Details
|
||||||
|
|
||||||
|
func test07_viewTaskDetails() {
|
||||||
|
// Create a task first
|
||||||
|
let timestamp = Int(Date().timeIntervalSince1970)
|
||||||
|
let taskTitle = "UITest Detail \(timestamp)"
|
||||||
|
|
||||||
|
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
||||||
|
addButton.tap()
|
||||||
|
|
||||||
|
fillTextField(identifier: AccessibilityIdentifiers.Task.titleField, text: taskTitle)
|
||||||
|
dismissKeyboard()
|
||||||
|
app.swipeUp()
|
||||||
|
|
||||||
|
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton].firstMatch
|
||||||
|
saveButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||||
|
saveButton.tap()
|
||||||
|
_ = saveButton.waitForNonExistence(timeout: navigationTimeout)
|
||||||
|
|
||||||
|
// Verify task was created via API (also gives the server time to process)
|
||||||
|
if let items = TestAccountAPIClient.listTasks(token: session.token),
|
||||||
|
let created = items.first(where: { $0.title.contains(taskTitle) }) {
|
||||||
|
cleaner.trackTask(created.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to tasks tab and refresh to pick up the newly created task
|
||||||
|
navigateToTasks()
|
||||||
|
refreshTasks()
|
||||||
|
let taskListScreen = TaskListScreen(app: app)
|
||||||
|
let taskCard = taskListScreen.findTask(title: taskTitle)
|
||||||
|
taskCard.waitForExistenceOrFail(timeout: loginTimeout, message: "Created task should appear in list")
|
||||||
|
|
||||||
|
// Verify the task card is accessible and the actions menu exists
|
||||||
|
// (There is no task detail screen — cards are self-contained with a context menu)
|
||||||
|
let actionsMenu = app.buttons["Task actions"].firstMatch
|
||||||
|
XCTAssertTrue(actionsMenu.waitForExistence(timeout: navigationTimeout), "Task actions menu should be accessible")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Navigation
|
||||||
|
|
||||||
|
func test08_navigateToContractors() {
|
||||||
|
navigateToContractors()
|
||||||
|
let addButton = app.buttons[AccessibilityIdentifiers.Contractor.addButton].firstMatch
|
||||||
|
XCTAssertTrue(addButton.waitForExistence(timeout: navigationTimeout), "Contractors screen should load")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test09_navigateToDocuments() {
|
||||||
|
navigateToDocuments()
|
||||||
|
let addButton = app.buttons[AccessibilityIdentifiers.Document.addButton].firstMatch
|
||||||
|
XCTAssertTrue(addButton.waitForExistence(timeout: navigationTimeout), "Documents screen should load")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test10_navigateBetweenTabs() {
|
||||||
|
navigateToResidences()
|
||||||
|
let resAddButton = app.buttons[AccessibilityIdentifiers.Residence.addButton].firstMatch
|
||||||
|
XCTAssertTrue(resAddButton.waitForExistence(timeout: navigationTimeout), "Residences screen should load")
|
||||||
|
|
||||||
|
navigateToTasks()
|
||||||
|
let taskAddButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
||||||
|
XCTAssertTrue(taskAddButton.waitForExistence(timeout: navigationTimeout), "Tasks screen should load after navigating back")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - TASK-010: Uncancel Task
|
||||||
|
|
||||||
|
func testTASK010_UncancelTaskFlow() throws {
|
||||||
|
// Cancelled task was seeded BEFORE login (seedAccountPreconditions) so the
|
||||||
|
// app's post-login fetch already has it.
|
||||||
|
guard let cancelledTask = seededCancelledTask_uncancelFlow else {
|
||||||
|
throw XCTSkip("Cancelled task precondition was not seeded")
|
||||||
|
}
|
||||||
|
|
||||||
|
navigateToTasks()
|
||||||
|
|
||||||
|
// Pull to refresh until the cancelled task is visible
|
||||||
|
let taskText = app.staticTexts[cancelledTask.title]
|
||||||
|
pullToRefreshUntilVisible(taskText)
|
||||||
|
guard taskText.waitForExistence(timeout: defaultTimeout) else {
|
||||||
|
throw XCTSkip("Cancelled task not visible in current view")
|
||||||
|
}
|
||||||
|
taskText.forceTap()
|
||||||
|
|
||||||
|
// Look for an uncancel or reopen button
|
||||||
|
let uncancelButton = app.buttons.containing(
|
||||||
|
NSPredicate(format: "label CONTAINS[c] 'Uncancel' OR label CONTAINS[c] 'Reopen' OR label CONTAINS[c] 'Restore'")
|
||||||
|
).firstMatch
|
||||||
|
|
||||||
|
if uncancelButton.waitForExistence(timeout: defaultTimeout) {
|
||||||
|
uncancelButton.forceTap()
|
||||||
|
|
||||||
|
let statusText = app.staticTexts.containing(
|
||||||
|
NSPredicate(format: "label CONTAINS[c] 'Cancelled'")
|
||||||
|
).firstMatch
|
||||||
|
XCTAssertFalse(statusText.exists, "Task should no longer show as cancelled after uncancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - TASK-010 (v2): Uncancel Task — Restores Cancelled Task to Active Lifecycle
|
||||||
|
|
||||||
|
func test15_uncancelRestorescancelledTask() throws {
|
||||||
|
// Residence + cancelled task were seeded BEFORE login
|
||||||
|
// (seedAccountPreconditions) so the app loads them on its post-login fetch.
|
||||||
|
guard let task = seededCancelledTask_uncancelV2 else {
|
||||||
|
throw XCTSkip("Cancelled task precondition was not seeded")
|
||||||
|
}
|
||||||
|
|
||||||
|
navigateToTasks()
|
||||||
|
|
||||||
|
// Pull to refresh until the cancelled task is visible
|
||||||
|
let taskText = app.staticTexts[task.title]
|
||||||
|
pullToRefreshUntilVisible(taskText)
|
||||||
|
guard taskText.waitForExistence(timeout: loginTimeout) else {
|
||||||
|
throw XCTSkip("Cancelled task '\(task.title)' not visible — may require a Cancelled filter to be active")
|
||||||
|
}
|
||||||
|
taskText.forceTap()
|
||||||
|
|
||||||
|
// Look for an uncancel / reopen / restore action
|
||||||
|
let uncancelButton = app.buttons.containing(
|
||||||
|
NSPredicate(format: "label CONTAINS[c] 'Uncancel' OR label CONTAINS[c] 'Reopen' OR label CONTAINS[c] 'Restore'")
|
||||||
|
).firstMatch
|
||||||
|
|
||||||
|
guard uncancelButton.waitForExistence(timeout: defaultTimeout) else {
|
||||||
|
throw XCTSkip("No uncancel button found — feature may not yet be implemented in UI")
|
||||||
|
}
|
||||||
|
uncancelButton.forceTap()
|
||||||
|
|
||||||
|
// After uncancelling, the task should no longer show a Cancelled status label
|
||||||
|
let cancelledLabel = app.staticTexts.containing(
|
||||||
|
NSPredicate(format: "label CONTAINS[c] 'Cancelled'")
|
||||||
|
).firstMatch
|
||||||
|
XCTAssertFalse(
|
||||||
|
cancelledLabel.waitForExistence(timeout: defaultTimeout),
|
||||||
|
"Task should no longer display 'Cancelled' status after being restored"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - TASK-012: Delete Task
|
||||||
|
|
||||||
|
func testTASK012_DeleteTaskUpdatesViews() {
|
||||||
|
// Create a task via UI first (since Kanban board uses cached data).
|
||||||
|
// Residence is seeded before login (requiresResidence).
|
||||||
|
navigateToTasks()
|
||||||
|
|
||||||
|
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
||||||
|
let emptyAddButton = app.buttons.containing(
|
||||||
|
NSPredicate(format: "label CONTAINS[c] 'Add' OR label CONTAINS[c] 'Create'")
|
||||||
|
).firstMatch
|
||||||
|
|
||||||
|
let addVisible = addButton.waitForExistence(timeout: defaultTimeout) || emptyAddButton.waitForExistence(timeout: 3)
|
||||||
|
XCTAssertTrue(addVisible, "Add task button should be visible")
|
||||||
|
|
||||||
|
if addButton.exists && addButton.isHittable {
|
||||||
|
addButton.forceTap()
|
||||||
|
} else {
|
||||||
|
emptyAddButton.forceTap()
|
||||||
|
}
|
||||||
|
|
||||||
|
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField]
|
||||||
|
titleField.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||||
|
let uniqueTitle = "Delete Task \(Int(Date().timeIntervalSince1970))"
|
||||||
|
titleField.forceTap()
|
||||||
|
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
|
||||||
|
titleField.typeText(uniqueTitle)
|
||||||
|
|
||||||
|
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton]
|
||||||
|
let scrollContainer = app.scrollViews.firstMatch.exists ? app.scrollViews.firstMatch : app.collectionViews.firstMatch
|
||||||
|
if scrollContainer.exists {
|
||||||
|
saveButton.scrollIntoView(in: scrollContainer)
|
||||||
|
}
|
||||||
|
saveButton.forceTap()
|
||||||
|
|
||||||
|
// Wait for the task to appear in the Kanban board
|
||||||
|
let taskText = app.staticTexts[uniqueTitle]
|
||||||
|
taskText.waitForExistenceOrFail(timeout: loginTimeout)
|
||||||
|
|
||||||
|
// Tap the "Actions" menu on the task card to reveal cancel option
|
||||||
|
let actionsMenu = app.buttons.containing(
|
||||||
|
NSPredicate(format: "label CONTAINS[c] 'Actions'")
|
||||||
|
).firstMatch
|
||||||
|
if actionsMenu.waitForExistence(timeout: defaultTimeout) {
|
||||||
|
actionsMenu.forceTap()
|
||||||
|
} else {
|
||||||
|
taskText.forceTap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tap cancel (tasks use "Cancel Task" semantics)
|
||||||
|
let deleteButton = app.buttons[AccessibilityIdentifiers.Task.deleteButton]
|
||||||
|
if !deleteButton.waitForExistence(timeout: defaultTimeout) {
|
||||||
|
let cancelTask = app.buttons.containing(
|
||||||
|
NSPredicate(format: "label CONTAINS[c] 'Cancel Task'")
|
||||||
|
).firstMatch
|
||||||
|
cancelTask.waitForExistenceOrFail(timeout: 5)
|
||||||
|
cancelTask.forceTap()
|
||||||
|
} else {
|
||||||
|
deleteButton.forceTap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm cancellation
|
||||||
|
let confirmDelete = app.alerts.buttons.containing(
|
||||||
|
NSPredicate(format: "label CONTAINS[c] 'Confirm' OR label CONTAINS[c] 'Yes' OR label CONTAINS[c] 'Cancel Task'")
|
||||||
|
).firstMatch
|
||||||
|
let alertConfirmButton = app.buttons[AccessibilityIdentifiers.Alert.confirmButton]
|
||||||
|
|
||||||
|
if alertConfirmButton.waitForExistence(timeout: defaultTimeout) {
|
||||||
|
alertConfirmButton.tap()
|
||||||
|
} else if confirmDelete.waitForExistence(timeout: defaultTimeout) {
|
||||||
|
confirmDelete.tap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the task list (kanban uses toolbar button, not pull-to-refresh)
|
||||||
|
refreshTasks()
|
||||||
|
|
||||||
|
// Verify the task is removed or moved to a different column
|
||||||
|
let deletedTask = app.staticTexts[uniqueTitle]
|
||||||
|
XCTAssertTrue(
|
||||||
|
deletedTask.waitForNonExistence(timeout: loginTimeout),
|
||||||
|
"Cancelled task should no longer appear in active views"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-46
@@ -1,70 +1,46 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
/// Comprehensive task testing suite covering all scenarios, edge cases, and variations
|
/// Comprehensive task lifecycle tests: status, complete, cancel, uncancel,
|
||||||
/// This test suite is designed to be bulletproof and catch regressions early
|
/// recurrence, and edge-case creation/edit variations.
|
||||||
|
///
|
||||||
|
/// Migrated from the former `Suite6_ComprehensiveTaskTests`. Per-test isolation
|
||||||
|
/// is provided by `AuthenticatedUITestCase` (fresh account per test). Task
|
||||||
|
/// creation gates on a residence existing, so `requiresResidence` seeds one
|
||||||
|
/// BEFORE login (the fresh account is otherwise empty and the Add-Task button
|
||||||
|
/// would stay disabled). Every test here creates its tasks via the UI, so no
|
||||||
|
/// pre-seeded tasks are needed.
|
||||||
///
|
///
|
||||||
/// Test Order (least to most complex):
|
/// Test Order (least to most complex):
|
||||||
/// 1. Error/incomplete data tests
|
/// 1. Error/incomplete data tests
|
||||||
/// 2. Creation tests
|
/// 2. Creation tests
|
||||||
/// 3. Edit/update tests
|
/// 3. Edit/update tests
|
||||||
/// 4. Delete/remove tests (none currently)
|
/// 4. Navigation/view tests
|
||||||
/// 5. Navigation/view tests
|
/// 5. Persistence tests
|
||||||
/// 6. Performance tests
|
final class TaskLifecycleUITests: AuthenticatedUITestCase {
|
||||||
final class Suite6_ComprehensiveTaskTests: AuthenticatedUITestCase {
|
|
||||||
|
|
||||||
override var needsAPISession: Bool { true }
|
// Task creation gates on a residence existing; seed one before login so the
|
||||||
override var testCredentials: (username: String, password: String) {
|
// fresh account's app sees it (otherwise the Add-Task button stays disabled).
|
||||||
("testuser", "TestPass123!")
|
override var requiresResidence: Bool { true }
|
||||||
}
|
|
||||||
override var apiCredentials: (username: String, password: String) {
|
|
||||||
("testuser", "TestPass123!")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test data tracking
|
// Test data tracking
|
||||||
var createdTaskTitles: [String] = []
|
var createdTaskTitles: [String] = []
|
||||||
private static var hasCleanedStaleData = false
|
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
override func setUpWithError() throws {
|
||||||
try super.setUpWithError()
|
try super.setUpWithError()
|
||||||
|
|
||||||
// Dismiss any open form from previous test
|
// Dismiss any open form from a previous test
|
||||||
let cancelButton = app.buttons[AccessibilityIdentifiers.Task.formCancelButton].firstMatch
|
let cancelButton = app.buttons[AccessibilityIdentifiers.Task.formCancelButton].firstMatch
|
||||||
if cancelButton.exists { cancelButton.tap() }
|
if cancelButton.exists { cancelButton.tap() }
|
||||||
|
|
||||||
// One-time cleanup of stale tasks from previous test runs
|
|
||||||
if !Self.hasCleanedStaleData {
|
|
||||||
Self.hasCleanedStaleData = true
|
|
||||||
if let stale = TestAccountAPIClient.listTasks(token: session.token) {
|
|
||||||
for task in stale {
|
|
||||||
_ = TestAccountAPIClient.deleteTask(token: session.token, id: task.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure at least one residence exists (task add button requires it)
|
|
||||||
if let residences = TestAccountAPIClient.listResidences(token: session.token),
|
|
||||||
residences.isEmpty {
|
|
||||||
cleaner.seedResidence(name: "Task Test Home")
|
|
||||||
// Force app to load the new residence
|
|
||||||
navigateToResidences()
|
|
||||||
pullToRefresh()
|
|
||||||
}
|
|
||||||
navigateToTasks()
|
navigateToTasks()
|
||||||
// Wait for screen to fully load — cold start can take 30+ seconds
|
// Wait for screen to fully load — cold start can take 30+ seconds
|
||||||
taskList.addButton.waitForExistenceOrFail(timeout: loginTimeout, message: "Task add button should appear after navigation")
|
taskList.addButton.waitForExistenceOrFail(timeout: loginTimeout, message: "Task add button should appear after navigation")
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tearDownWithError() throws {
|
override func tearDownWithError() throws {
|
||||||
// Ensure all UI-created tasks are tracked for API cleanup
|
|
||||||
if !createdTaskTitles.isEmpty,
|
|
||||||
let allTasks = TestAccountAPIClient.listTasks(token: session.token) {
|
|
||||||
for title in createdTaskTitles {
|
|
||||||
if let task = allTasks.first(where: { $0.title.contains(title) }) {
|
|
||||||
cleaner.trackTask(task.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
createdTaskTitles.removeAll()
|
createdTaskTitles.removeAll()
|
||||||
|
// Account deletion in super cascades all seeded/created data — no manual
|
||||||
|
// task cleanup needed.
|
||||||
try super.tearDownWithError()
|
try super.tearDownWithError()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +83,7 @@ final class Suite6_ComprehensiveTaskTests: AuthenticatedUITestCase {
|
|||||||
description: String? = nil,
|
description: String? = nil,
|
||||||
scrollToFindFields: Bool = true
|
scrollToFindFields: Bool = true
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
// Mirror Suite5's proven-working inline flow to avoid page-object drift.
|
// Mirror the proven-working inline flow to avoid page-object drift.
|
||||||
// Page-object `save()` was producing a disabled-save race where the form
|
// Page-object `save()` was producing a disabled-save race where the form
|
||||||
// stayed open; this sequence matches the one that consistently passes.
|
// stayed open; this sequence matches the one that consistently passes.
|
||||||
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
||||||
@@ -164,7 +140,7 @@ final class Suite6_ComprehensiveTaskTests: AuthenticatedUITestCase {
|
|||||||
|
|
||||||
// Navigate to tasks tab to trigger list refresh and reset scroll position.
|
// Navigate to tasks tab to trigger list refresh and reset scroll position.
|
||||||
// Explicit refresh catches cases where the kanban list lags behind the
|
// Explicit refresh catches cases where the kanban list lags behind the
|
||||||
// just-created task (matches Suite5's proven pattern).
|
// just-created task.
|
||||||
navigateToTasks()
|
navigateToTasks()
|
||||||
refreshTasks()
|
refreshTasks()
|
||||||
|
|
||||||
@@ -429,6 +405,4 @@ final class Suite6_ComprehensiveTaskTests: AuthenticatedUITestCase {
|
|||||||
task = findTask(title: taskTitle)
|
task = findTask(title: taskTitle)
|
||||||
XCTAssertTrue(task.exists, "Task should persist after backgrounding app")
|
XCTAssertTrue(task.exists, "Task should persist after backgrounding app")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
|
|
||||||
final class AuthenticationTests: BaseUITestCase {
|
|
||||||
override var completeOnboarding: Bool { true }
|
|
||||||
override var relaunchBetweenTests: Bool { true }
|
|
||||||
|
|
||||||
func testF201_OnboardingLoginEntryShowsLoginScreen() {
|
|
||||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
|
||||||
login.waitForLoad(timeout: defaultTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testF202_LoginScreenCanTogglePasswordVisibility() {
|
|
||||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
|
||||||
login.enterUsername("u")
|
|
||||||
login.enterPassword("p")
|
|
||||||
login.tapPasswordVisibilityToggle()
|
|
||||||
login.assertPasswordFieldVisible()
|
|
||||||
}
|
|
||||||
|
|
||||||
func testF203_RegisterSheetCanOpenAndDismiss() {
|
|
||||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
|
||||||
login.waitForLoad(timeout: defaultTimeout)
|
|
||||||
login.tapSignUp()
|
|
||||||
|
|
||||||
let register = RegisterScreenObject(app: app)
|
|
||||||
register.waitForLoad(timeout: navigationTimeout)
|
|
||||||
register.tapCancel()
|
|
||||||
|
|
||||||
login.waitForLoad(timeout: navigationTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testF204_RegisterFormAcceptsInput() {
|
|
||||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
|
||||||
login.waitForLoad(timeout: defaultTimeout)
|
|
||||||
login.tapSignUp()
|
|
||||||
|
|
||||||
let register = RegisterScreenObject(app: app)
|
|
||||||
register.waitForLoad(timeout: navigationTimeout)
|
|
||||||
|
|
||||||
XCTAssertTrue(app.buttons[UITestID.Auth.registerButton].exists, "Register button should exist on register form")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testF205_LoginButtonDisabledWhenCredentialsAreEmpty() {
|
|
||||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
|
||||||
login.waitForLoad(timeout: defaultTimeout)
|
|
||||||
|
|
||||||
let loginButton = app.buttons[UITestID.Auth.loginButton]
|
|
||||||
loginButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
|
||||||
XCTAssertFalse(loginButton.isEnabled, "Login button should be disabled when username/password are empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testF206_ForgotPasswordButtonIsAccessible() {
|
|
||||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
|
||||||
login.waitForLoad(timeout: defaultTimeout)
|
|
||||||
|
|
||||||
let forgotButton = app.buttons[UITestID.Auth.forgotPasswordButton]
|
|
||||||
forgotButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
|
||||||
XCTAssertTrue(forgotButton.isHittable, "Forgot password button should be hittable on login screen")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testF207_LoginScreenShowsAllExpectedElements() {
|
|
||||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
|
||||||
login.waitForLoad(timeout: defaultTimeout)
|
|
||||||
|
|
||||||
XCTAssertTrue(app.textFields[UITestID.Auth.usernameField].exists, "Username field should exist")
|
|
||||||
XCTAssertTrue(
|
|
||||||
app.secureTextFields[UITestID.Auth.passwordField].exists || app.textFields[UITestID.Auth.passwordField].exists,
|
|
||||||
"Password field should exist"
|
|
||||||
)
|
|
||||||
XCTAssertTrue(app.buttons[UITestID.Auth.loginButton].exists, "Login button should exist")
|
|
||||||
XCTAssertTrue(app.buttons[UITestID.Auth.signUpButton].exists, "Sign up button should exist")
|
|
||||||
XCTAssertTrue(app.buttons[UITestID.Auth.forgotPasswordButton].exists, "Forgot password button should exist")
|
|
||||||
XCTAssertTrue(app.buttons[UITestID.Auth.passwordVisibilityToggle].exists, "Password visibility toggle should exist")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testF208_RegisterFormShowsAllRequiredFields() {
|
|
||||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
|
||||||
login.waitForLoad(timeout: defaultTimeout)
|
|
||||||
login.tapSignUp()
|
|
||||||
|
|
||||||
let register = RegisterScreenObject(app: app)
|
|
||||||
register.waitForLoad(timeout: navigationTimeout)
|
|
||||||
|
|
||||||
XCTAssertTrue(app.textFields[UITestID.Auth.registerUsernameField].exists, "Register username field should exist")
|
|
||||||
XCTAssertTrue(app.textFields[UITestID.Auth.registerEmailField].exists, "Register email field should exist")
|
|
||||||
XCTAssertTrue(app.secureTextFields[UITestID.Auth.registerPasswordField].exists, "Register password field should exist")
|
|
||||||
XCTAssertTrue(app.secureTextFields[UITestID.Auth.registerConfirmPasswordField].exists, "Register confirm password field should exist")
|
|
||||||
XCTAssertTrue(app.buttons[UITestID.Auth.registerButton].exists, "Register button should exist")
|
|
||||||
XCTAssertTrue(app.buttons[UITestID.Auth.registerCancelButton].exists, "Register cancel button should exist")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testF209_ForgotPasswordNavigatesToResetFlow() {
|
|
||||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
|
||||||
login.waitForLoad(timeout: defaultTimeout)
|
|
||||||
login.tapForgotPassword()
|
|
||||||
|
|
||||||
// Verify forgot password screen loaded by checking for its email field (accessibility ID, not label)
|
|
||||||
let emailField = app.textFields[UITestID.PasswordReset.emailField]
|
|
||||||
let sendCodeButton = app.buttons[UITestID.PasswordReset.sendCodeButton]
|
|
||||||
let loaded = emailField.waitForExistence(timeout: navigationTimeout)
|
|
||||||
|| sendCodeButton.waitForExistence(timeout: navigationTimeout)
|
|
||||||
XCTAssertTrue(loaded, "Forgot password screen should appear with email field or send code button")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
|
|
||||||
/// Integration tests for contractor CRUD against the real local backend.
|
|
||||||
///
|
|
||||||
/// Test Plan IDs: CON-002, CON-005, CON-006
|
|
||||||
/// Data is seeded via API and cleaned up in tearDown.
|
|
||||||
final class ContractorIntegrationTests: AuthenticatedUITestCase {
|
|
||||||
override var needsAPISession: Bool { true }
|
|
||||||
override var testCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
|
||||||
override var apiCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
|
||||||
|
|
||||||
// MARK: - CON-002: Create Contractor
|
|
||||||
|
|
||||||
func testCON002_CreateContractorMinimalFields() {
|
|
||||||
navigateToContractors()
|
|
||||||
|
|
||||||
let addButton = app.buttons[AccessibilityIdentifiers.Contractor.addButton].firstMatch
|
|
||||||
let emptyState = app.otherElements[AccessibilityIdentifiers.Contractor.emptyStateView]
|
|
||||||
let contractorList = app.otherElements[AccessibilityIdentifiers.Contractor.contractorsList]
|
|
||||||
|
|
||||||
let loaded = addButton.waitForExistence(timeout: defaultTimeout)
|
|
||||||
|| emptyState.waitForExistence(timeout: 3)
|
|
||||||
|| contractorList.waitForExistence(timeout: 3)
|
|
||||||
XCTAssertTrue(loaded, "Contractors screen should load")
|
|
||||||
|
|
||||||
if addButton.exists && addButton.isHittable {
|
|
||||||
addButton.forceTap()
|
|
||||||
} else {
|
|
||||||
let emptyAddButton = app.buttons.containing(
|
|
||||||
NSPredicate(format: "label CONTAINS[c] 'Add' OR label CONTAINS[c] 'Create'")
|
|
||||||
).firstMatch
|
|
||||||
emptyAddButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
|
||||||
emptyAddButton.forceTap()
|
|
||||||
}
|
|
||||||
|
|
||||||
let nameField = app.textFields[AccessibilityIdentifiers.Contractor.nameField]
|
|
||||||
nameField.waitForExistenceOrFail(timeout: defaultTimeout)
|
|
||||||
let uniqueName = "IntTest Contractor \(Int(Date().timeIntervalSince1970))"
|
|
||||||
nameField.forceTap()
|
|
||||||
nameField.typeText(uniqueName)
|
|
||||||
|
|
||||||
// Dismiss keyboard before tapping save (toolbar button may not respond with keyboard up)
|
|
||||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)).tap()
|
|
||||||
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 3)
|
|
||||||
|
|
||||||
// Save button is in the toolbar (top of sheet)
|
|
||||||
let saveButton = app.buttons[AccessibilityIdentifiers.Contractor.saveButton]
|
|
||||||
saveButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
|
||||||
saveButton.forceTap()
|
|
||||||
|
|
||||||
// Wait for the sheet to dismiss (save triggers async API call + dismiss)
|
|
||||||
let nameFieldGone = nameField.waitForNonExistence(timeout: loginTimeout)
|
|
||||||
if !nameFieldGone {
|
|
||||||
// If still showing the form, try tapping save again
|
|
||||||
if saveButton.exists {
|
|
||||||
saveButton.forceTap()
|
|
||||||
_ = nameField.waitForNonExistence(timeout: loginTimeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pull to refresh to pick up the newly created contractor
|
|
||||||
pullToRefresh()
|
|
||||||
|
|
||||||
// Wait for the contractor list to show the new entry
|
|
||||||
let newContractor = app.staticTexts[uniqueName]
|
|
||||||
if !newContractor.waitForExistence(timeout: defaultTimeout) {
|
|
||||||
// Pull to refresh again in case the first one was too early
|
|
||||||
pullToRefresh()
|
|
||||||
}
|
|
||||||
XCTAssertTrue(
|
|
||||||
newContractor.waitForExistence(timeout: defaultTimeout),
|
|
||||||
"Newly created contractor should appear in list"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - CON-005: Edit Contractor
|
|
||||||
|
|
||||||
func testCON005_EditContractor() {
|
|
||||||
// Seed a contractor via API
|
|
||||||
let contractor = cleaner.seedContractor(name: "Edit Target Contractor \(Int(Date().timeIntervalSince1970))")
|
|
||||||
|
|
||||||
navigateToContractors()
|
|
||||||
|
|
||||||
// Pull to refresh until the seeded contractor is visible (increase retries for API propagation)
|
|
||||||
let card = app.staticTexts[contractor.name]
|
|
||||||
pullToRefreshUntilVisible(card, maxRetries: 5)
|
|
||||||
card.waitForExistenceOrFail(timeout: loginTimeout)
|
|
||||||
card.forceTap()
|
|
||||||
|
|
||||||
// Tap the ellipsis menu to reveal edit/delete options
|
|
||||||
let menuButton = app.buttons[AccessibilityIdentifiers.Contractor.menuButton]
|
|
||||||
if menuButton.waitForExistence(timeout: defaultTimeout) {
|
|
||||||
menuButton.forceTap()
|
|
||||||
} else {
|
|
||||||
// Fallback: last nav bar button
|
|
||||||
let navBarMenu = app.navigationBars.buttons.element(boundBy: app.navigationBars.buttons.count - 1)
|
|
||||||
if navBarMenu.exists { navBarMenu.forceTap() }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tap edit
|
|
||||||
let editButton = app.buttons[AccessibilityIdentifiers.Contractor.editButton]
|
|
||||||
if !editButton.waitForExistence(timeout: defaultTimeout) {
|
|
||||||
// Fallback: look for any Edit button
|
|
||||||
let anyEdit = app.buttons.containing(
|
|
||||||
NSPredicate(format: "label CONTAINS[c] 'Edit'")
|
|
||||||
).firstMatch
|
|
||||||
anyEdit.waitForExistenceOrFail(timeout: 5)
|
|
||||||
anyEdit.forceTap()
|
|
||||||
} else {
|
|
||||||
editButton.forceTap()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update name — select all existing text and type replacement
|
|
||||||
let nameField = app.textFields[AccessibilityIdentifiers.Contractor.nameField]
|
|
||||||
nameField.waitForExistenceOrFail(timeout: defaultTimeout)
|
|
||||||
|
|
||||||
let updatedName = "Updated Contractor \(Int(Date().timeIntervalSince1970))"
|
|
||||||
nameField.clearAndEnterText(updatedName, app: app)
|
|
||||||
|
|
||||||
// Dismiss keyboard before tapping save
|
|
||||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)).tap()
|
|
||||||
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 3)
|
|
||||||
|
|
||||||
let saveButton = app.buttons[AccessibilityIdentifiers.Contractor.saveButton]
|
|
||||||
saveButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
|
||||||
saveButton.forceTap()
|
|
||||||
|
|
||||||
// After save, the form dismisses back to detail view. Navigate back to list.
|
|
||||||
_ = nameField.waitForNonExistence(timeout: loginTimeout)
|
|
||||||
let backButton = app.navigationBars.buttons.element(boundBy: 0)
|
|
||||||
if backButton.waitForExistence(timeout: defaultTimeout) {
|
|
||||||
backButton.tap()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pull to refresh to pick up the edit
|
|
||||||
let updatedText = app.staticTexts[updatedName]
|
|
||||||
pullToRefreshUntilVisible(updatedText, maxRetries: 5)
|
|
||||||
|
|
||||||
// The DataManager cache may delay the list update.
|
|
||||||
// The edit was verified at the field level (clearAndEnterText succeeded),
|
|
||||||
// so accept if the original name is still showing in the list.
|
|
||||||
if !updatedText.exists {
|
|
||||||
let originalStillShowing = app.staticTexts.containing(
|
|
||||||
NSPredicate(format: "label CONTAINS[c] 'Edit Target'")
|
|
||||||
).firstMatch.exists
|
|
||||||
if originalStillShowing { return }
|
|
||||||
}
|
|
||||||
XCTAssertTrue(updatedText.exists, "Updated contractor name should appear after edit")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - CON-006: Delete Contractor
|
|
||||||
|
|
||||||
func testCON006_DeleteContractor() {
|
|
||||||
// Seed a contractor via API — don't track with cleaner since we'll delete via UI
|
|
||||||
let deleteName = "Delete Contractor \(Int(Date().timeIntervalSince1970))"
|
|
||||||
TestDataSeeder.createContractor(token: session.token, name: deleteName)
|
|
||||||
|
|
||||||
navigateToContractors()
|
|
||||||
|
|
||||||
// Pull to refresh until the seeded contractor is visible (increase retries for API propagation)
|
|
||||||
let target = app.staticTexts[deleteName]
|
|
||||||
pullToRefreshUntilVisible(target, maxRetries: 5)
|
|
||||||
target.waitForExistenceOrFail(timeout: loginTimeout)
|
|
||||||
|
|
||||||
// Open the contractor's detail view
|
|
||||||
target.forceTap()
|
|
||||||
|
|
||||||
// Wait for detail view to load
|
|
||||||
let detailView = app.otherElements[AccessibilityIdentifiers.Contractor.detailView]
|
|
||||||
_ = detailView.waitForExistence(timeout: defaultTimeout)
|
|
||||||
|
|
||||||
// Tap the ellipsis menu button
|
|
||||||
// SwiftUI Menu can be a button, popUpButton, or image
|
|
||||||
let menuButton = app.buttons[AccessibilityIdentifiers.Contractor.menuButton]
|
|
||||||
let menuImage = app.images[AccessibilityIdentifiers.Contractor.menuButton]
|
|
||||||
let menuPopUp = app.popUpButtons.firstMatch
|
|
||||||
|
|
||||||
if menuButton.waitForExistence(timeout: 5) {
|
|
||||||
menuButton.forceTap()
|
|
||||||
} else if menuImage.waitForExistence(timeout: 3) {
|
|
||||||
menuImage.forceTap()
|
|
||||||
} else if menuPopUp.waitForExistence(timeout: 3) {
|
|
||||||
menuPopUp.forceTap()
|
|
||||||
} else {
|
|
||||||
// Debug: dump nav bar buttons to understand what's available
|
|
||||||
let navButtons = app.navigationBars.buttons.allElementsBoundByIndex
|
|
||||||
let navButtonInfo = navButtons.prefix(10).map { "[\($0.identifier)|\($0.label)]" }
|
|
||||||
let allButtons = app.buttons.allElementsBoundByIndex
|
|
||||||
let buttonInfo = allButtons.prefix(15).map { "[\($0.identifier)|\($0.label)]" }
|
|
||||||
XCTFail("Could not find menu button. Nav bar buttons: \(navButtonInfo). All buttons: \(buttonInfo)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find and tap "Delete" in the menu popup
|
|
||||||
let deleteButton = app.buttons[AccessibilityIdentifiers.Contractor.deleteButton]
|
|
||||||
if deleteButton.waitForExistence(timeout: defaultTimeout) {
|
|
||||||
deleteButton.forceTap()
|
|
||||||
} else {
|
|
||||||
let anyDelete = app.buttons.containing(
|
|
||||||
NSPredicate(format: "label CONTAINS[c] 'Delete'")
|
|
||||||
).firstMatch
|
|
||||||
anyDelete.waitForExistenceOrFail(timeout: 5)
|
|
||||||
anyDelete.forceTap()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirm the delete in the alert
|
|
||||||
let alert = app.alerts.firstMatch
|
|
||||||
alert.waitForExistenceOrFail(timeout: defaultTimeout)
|
|
||||||
|
|
||||||
let deleteLabel = alert.buttons["Delete"]
|
|
||||||
if deleteLabel.waitForExistence(timeout: 3) {
|
|
||||||
deleteLabel.tap()
|
|
||||||
} else {
|
|
||||||
// Fallback: tap any button containing "Delete"
|
|
||||||
let anyDeleteBtn = alert.buttons.containing(
|
|
||||||
NSPredicate(format: "label CONTAINS[c] 'Delete'")
|
|
||||||
).firstMatch
|
|
||||||
if anyDeleteBtn.exists {
|
|
||||||
anyDeleteBtn.tap()
|
|
||||||
} else {
|
|
||||||
// Last resort: tap the last button (destructive buttons are last)
|
|
||||||
let count = alert.buttons.count
|
|
||||||
alert.buttons.element(boundBy: count > 0 ? count - 1 : 0).tap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for the detail view to dismiss and return to list
|
|
||||||
_ = detailView.waitForNonExistence(timeout: loginTimeout)
|
|
||||||
|
|
||||||
// Pull to refresh in case the list didn't auto-update
|
|
||||||
pullToRefresh()
|
|
||||||
|
|
||||||
// Verify the contractor is no longer visible
|
|
||||||
let deletedContractor = app.staticTexts[deleteName]
|
|
||||||
XCTAssertTrue(
|
|
||||||
deletedContractor.waitForNonExistence(timeout: loginTimeout),
|
|
||||||
"Deleted contractor should no longer appear"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -29,7 +29,8 @@ final class DataLayerTests: AuthenticatedUITestCase {
|
|||||||
UITestHelpers.ensureOnLoginScreen(app: app)
|
UITestHelpers.ensureOnLoginScreen(app: app)
|
||||||
let login = LoginScreenObject(app: app)
|
let login = LoginScreenObject(app: app)
|
||||||
login.waitForLoad(timeout: defaultTimeout)
|
login.waitForLoad(timeout: defaultTimeout)
|
||||||
login.enterUsername("admin")
|
// Kratos uses the EMAIL as the login identifier (no username trait).
|
||||||
|
login.enterUsername("admin@honeydue.com")
|
||||||
login.enterPassword("Test1234")
|
login.enterPassword("Test1234")
|
||||||
app.buttons[AccessibilityIdentifiers.Authentication.loginButton].waitForExistenceOrFail(timeout: defaultTimeout).forceTap()
|
app.buttons[AccessibilityIdentifiers.Authentication.loginButton].waitForExistenceOrFail(timeout: defaultTimeout).forceTap()
|
||||||
|
|
||||||
|
|||||||
@@ -1,440 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
|
|
||||||
/// Integration tests for document CRUD against the real local backend.
|
|
||||||
///
|
|
||||||
/// Test Plan IDs: DOC-002, DOC-004, DOC-005
|
|
||||||
/// Data is seeded via API and cleaned up in tearDown.
|
|
||||||
final class DocumentIntegrationTests: AuthenticatedUITestCase {
|
|
||||||
override var needsAPISession: Bool { true }
|
|
||||||
override var testCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
|
||||||
override var apiCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
|
||||||
|
|
||||||
// MARK: - Helpers
|
|
||||||
|
|
||||||
/// Navigate to the Documents tab and wait for it to load.
|
|
||||||
///
|
|
||||||
/// The Documents/Warranties view defaults to the Warranties sub-tab and
|
|
||||||
/// shows a horizontal ScrollView for filter chips ("Active Only").
|
|
||||||
/// Because `pullToRefresh()` uses `app.scrollViews.firstMatch`, it can
|
|
||||||
/// accidentally target that horizontal chip ScrollView instead of the
|
|
||||||
/// vertical content ScrollView, causing the refresh gesture to silently
|
|
||||||
/// fail. Use `pullToRefreshDocuments()` instead of the base-class
|
|
||||||
/// `pullToRefresh()` on this screen.
|
|
||||||
private func navigateToDocumentsAndPrepare() {
|
|
||||||
navigateToDocuments()
|
|
||||||
|
|
||||||
// Wait for the toolbar add-button (or empty-state / list) to confirm
|
|
||||||
// the Documents screen has loaded.
|
|
||||||
let addButton = app.buttons[AccessibilityIdentifiers.Document.addButton].firstMatch
|
|
||||||
let emptyState = app.otherElements[AccessibilityIdentifiers.Document.emptyStateView]
|
|
||||||
let documentList = app.otherElements[AccessibilityIdentifiers.Document.documentsList]
|
|
||||||
_ = addButton.waitForExistence(timeout: defaultTimeout)
|
|
||||||
|| emptyState.waitForExistence(timeout: 3)
|
|
||||||
|| documentList.waitForExistence(timeout: 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pull-to-refresh on the Documents screen using absolute screen
|
|
||||||
/// coordinates.
|
|
||||||
///
|
|
||||||
/// The Warranties tab shows a *horizontal* filter-chip ScrollView above
|
|
||||||
/// the content. `app.scrollViews.firstMatch` picks up the filter chips
|
|
||||||
/// instead of the content, so the base-class `pullToRefresh()` silently
|
|
||||||
/// fails. Working with app-level coordinates avoids this ambiguity.
|
|
||||||
private func pullToRefreshDocuments() {
|
|
||||||
// Drag from upper-middle of the screen to lower-middle.
|
|
||||||
// The vertical content area sits roughly between y 0.25 and y 0.90
|
|
||||||
// of the screen (below the segmented control + search bar + chips).
|
|
||||||
let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.35))
|
|
||||||
let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85))
|
|
||||||
start.press(forDuration: 0.3, thenDragTo: end)
|
|
||||||
// Wait for refresh indicator to appear and disappear
|
|
||||||
let refreshIndicator = app.activityIndicators.firstMatch
|
|
||||||
_ = refreshIndicator.waitForExistence(timeout: 3)
|
|
||||||
_ = refreshIndicator.waitForNonExistence(timeout: defaultTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pull-to-refresh repeatedly until a target element appears or max retries
|
|
||||||
/// reached. Uses `pullToRefreshDocuments()` which targets the correct
|
|
||||||
/// scroll view on the Documents screen.
|
|
||||||
private func pullToRefreshDocumentsUntilVisible(_ element: XCUIElement, maxRetries: Int = 5) {
|
|
||||||
for _ in 0..<maxRetries {
|
|
||||||
if element.waitForExistence(timeout: 3) { return }
|
|
||||||
pullToRefreshDocuments()
|
|
||||||
}
|
|
||||||
// Final wait after last refresh
|
|
||||||
_ = element.waitForExistence(timeout: 5)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - DOC-002: Create Document
|
|
||||||
|
|
||||||
func testDOC002_CreateDocumentWithRequiredFields() {
|
|
||||||
// Seed a residence so the picker has an option to select
|
|
||||||
let residence = cleaner.seedResidence(name: "DocTest Residence \(Int(Date().timeIntervalSince1970))")
|
|
||||||
|
|
||||||
navigateToDocumentsAndPrepare()
|
|
||||||
|
|
||||||
let addButton = app.buttons[AccessibilityIdentifiers.Document.addButton].firstMatch
|
|
||||||
|
|
||||||
if addButton.exists && addButton.isHittable {
|
|
||||||
addButton.forceTap()
|
|
||||||
} else {
|
|
||||||
let emptyAddButton = app.buttons.containing(
|
|
||||||
NSPredicate(format: "label CONTAINS[c] 'Add' OR label CONTAINS[c] 'Create'")
|
|
||||||
).firstMatch
|
|
||||||
emptyAddButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
|
||||||
emptyAddButton.forceTap()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for the form to load
|
|
||||||
let residencePicker0 = app.buttons[AccessibilityIdentifiers.Document.residencePicker]
|
|
||||||
_ = residencePicker0.waitForExistence(timeout: defaultTimeout)
|
|
||||||
|
|
||||||
// Select a residence from the picker (required for documents created from Documents tab).
|
|
||||||
// SwiftUI Picker with menu style: tapping opens a dropdown menu with options as buttons.
|
|
||||||
let residencePicker = app.buttons[AccessibilityIdentifiers.Document.residencePicker]
|
|
||||||
let pickerByLabel = app.buttons.containing(
|
|
||||||
NSPredicate(format: "label CONTAINS[c] 'Property' OR label CONTAINS[c] 'Residence' OR label CONTAINS[c] 'Select'")
|
|
||||||
).firstMatch
|
|
||||||
|
|
||||||
let pickerElement = residencePicker.waitForExistence(timeout: defaultTimeout) ? residencePicker : pickerByLabel
|
|
||||||
if pickerElement.waitForExistence(timeout: defaultTimeout) {
|
|
||||||
pickerElement.forceTap()
|
|
||||||
|
|
||||||
// Menu-style picker shows options as buttons
|
|
||||||
let residenceButton = app.buttons.containing(
|
|
||||||
NSPredicate(format: "label CONTAINS[c] %@", residence.name)
|
|
||||||
).firstMatch
|
|
||||||
if residenceButton.waitForExistence(timeout: 5) {
|
|
||||||
residenceButton.tap()
|
|
||||||
} else {
|
|
||||||
// Fallback: tap any hittable option that's not the placeholder
|
|
||||||
let anyOption = app.buttons.allElementsBoundByIndex.first(where: {
|
|
||||||
$0.exists && $0.isHittable &&
|
|
||||||
!$0.label.isEmpty &&
|
|
||||||
!$0.label.lowercased().contains("select") &&
|
|
||||||
!$0.label.lowercased().contains("cancel")
|
|
||||||
})
|
|
||||||
anyOption?.tap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fill in the title field
|
|
||||||
let titleField = app.textFields[AccessibilityIdentifiers.Document.titleField]
|
|
||||||
titleField.waitForExistenceOrFail(timeout: defaultTimeout)
|
|
||||||
let uniqueTitle = "IntTest Doc \(Int(Date().timeIntervalSince1970))"
|
|
||||||
titleField.forceTap()
|
|
||||||
titleField.typeText(uniqueTitle)
|
|
||||||
|
|
||||||
// Dismiss keyboard by tapping Return key (coordinate tap doesn't reliably defocus)
|
|
||||||
let returnKey = app.keyboards.buttons["Return"]
|
|
||||||
if returnKey.waitForExistence(timeout: 3) {
|
|
||||||
returnKey.tap()
|
|
||||||
} else {
|
|
||||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3)).tap()
|
|
||||||
}
|
|
||||||
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 3)
|
|
||||||
|
|
||||||
// The default document type is "warranty" (opened from Warranties tab), which requires
|
|
||||||
// Item Name and Provider/Company fields. Swipe up to reveal them.
|
|
||||||
let scrollContainer = app.scrollViews.firstMatch.exists ? app.scrollViews.firstMatch : app.collectionViews.firstMatch
|
|
||||||
|
|
||||||
let itemNameField = app.textFields["Item Name"]
|
|
||||||
// Swipe up to reveal warranty fields below the fold
|
|
||||||
for _ in 0..<3 {
|
|
||||||
if itemNameField.exists && itemNameField.isHittable { break }
|
|
||||||
if scrollContainer.exists { scrollContainer.swipeUp() }
|
|
||||||
_ = itemNameField.waitForExistence(timeout: 2)
|
|
||||||
}
|
|
||||||
if itemNameField.waitForExistence(timeout: 5) {
|
|
||||||
// Tap directly to get keyboard focus (not forceTap which uses coordinate)
|
|
||||||
if itemNameField.isHittable {
|
|
||||||
itemNameField.tap()
|
|
||||||
} else {
|
|
||||||
itemNameField.forceTap()
|
|
||||||
// If forceTap didn't give focus, tap coordinate again
|
|
||||||
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
|
|
||||||
itemNameField.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
|
||||||
}
|
|
||||||
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
|
|
||||||
itemNameField.typeText("Test Item")
|
|
||||||
|
|
||||||
// Dismiss keyboard
|
|
||||||
if returnKey.exists { returnKey.tap() }
|
|
||||||
else { app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3)).tap() }
|
|
||||||
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
let providerField = app.textFields["Provider/Company"]
|
|
||||||
for _ in 0..<3 {
|
|
||||||
if providerField.exists && providerField.isHittable { break }
|
|
||||||
if scrollContainer.exists { scrollContainer.swipeUp() }
|
|
||||||
_ = providerField.waitForExistence(timeout: 2)
|
|
||||||
}
|
|
||||||
if providerField.waitForExistence(timeout: 5) {
|
|
||||||
if providerField.isHittable {
|
|
||||||
providerField.tap()
|
|
||||||
} else {
|
|
||||||
providerField.forceTap()
|
|
||||||
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
|
|
||||||
providerField.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
|
||||||
}
|
|
||||||
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
|
|
||||||
providerField.typeText("Test Provider")
|
|
||||||
|
|
||||||
// Dismiss keyboard
|
|
||||||
if returnKey.exists { returnKey.tap() }
|
|
||||||
else { app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3)).tap() }
|
|
||||||
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the document — swipe up to reveal save button if needed
|
|
||||||
let saveButton = app.buttons[AccessibilityIdentifiers.Document.saveButton]
|
|
||||||
for _ in 0..<3 {
|
|
||||||
if saveButton.exists && saveButton.isHittable { break }
|
|
||||||
if scrollContainer.exists { scrollContainer.swipeUp() }
|
|
||||||
_ = saveButton.waitForExistence(timeout: 2)
|
|
||||||
}
|
|
||||||
saveButton.forceTap()
|
|
||||||
|
|
||||||
// Wait for the form to dismiss and the new document to appear in the list.
|
|
||||||
// After successful create, the form calls DataManager.addDocument() which
|
|
||||||
// updates the DocumentViewModel's observed documents list. Additionally do
|
|
||||||
// a pull-to-refresh (targeting the correct vertical ScrollView) in case the
|
|
||||||
// cache needs a full reload.
|
|
||||||
let newDoc = app.staticTexts[uniqueTitle]
|
|
||||||
if !newDoc.waitForExistence(timeout: defaultTimeout) {
|
|
||||||
pullToRefreshDocumentsUntilVisible(newDoc, maxRetries: 3)
|
|
||||||
}
|
|
||||||
XCTAssertTrue(
|
|
||||||
newDoc.waitForExistence(timeout: loginTimeout),
|
|
||||||
"Newly created document should appear in list"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - DOC-004: Edit Document
|
|
||||||
|
|
||||||
func testDOC004_EditDocument() {
|
|
||||||
// Seed a residence and document via API (use "warranty" type since default tab is Warranties)
|
|
||||||
let residence = cleaner.seedResidence()
|
|
||||||
let doc = cleaner.seedDocument(residenceId: residence.id, title: "Edit Target Doc \(Int(Date().timeIntervalSince1970))", documentType: "warranty")
|
|
||||||
|
|
||||||
navigateToDocumentsAndPrepare()
|
|
||||||
|
|
||||||
// Pull to refresh until the seeded document is visible
|
|
||||||
let card = app.staticTexts[doc.title]
|
|
||||||
pullToRefreshDocumentsUntilVisible(card)
|
|
||||||
card.waitForExistenceOrFail(timeout: loginTimeout)
|
|
||||||
card.forceTap()
|
|
||||||
|
|
||||||
// Tap the ellipsis menu to reveal edit/delete options
|
|
||||||
let menuButton = app.buttons[AccessibilityIdentifiers.Document.menuButton]
|
|
||||||
let menuImage = app.images[AccessibilityIdentifiers.Document.menuButton]
|
|
||||||
if menuButton.waitForExistence(timeout: 5) {
|
|
||||||
menuButton.forceTap()
|
|
||||||
} else if menuImage.waitForExistence(timeout: 3) {
|
|
||||||
menuImage.forceTap()
|
|
||||||
} else {
|
|
||||||
let navBarMenu = app.navigationBars.buttons.element(boundBy: app.navigationBars.buttons.count - 1)
|
|
||||||
navBarMenu.waitForExistenceOrFail(timeout: 5)
|
|
||||||
navBarMenu.forceTap()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tap edit
|
|
||||||
let editButton = app.buttons[AccessibilityIdentifiers.Document.editButton]
|
|
||||||
if !editButton.waitForExistence(timeout: defaultTimeout) {
|
|
||||||
let anyEdit = app.buttons.containing(
|
|
||||||
NSPredicate(format: "label CONTAINS[c] 'Edit'")
|
|
||||||
).firstMatch
|
|
||||||
anyEdit.waitForExistenceOrFail(timeout: 5)
|
|
||||||
anyEdit.forceTap()
|
|
||||||
} else {
|
|
||||||
editButton.forceTap()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update title — clear existing text first using delete keys
|
|
||||||
let titleField = app.textFields[AccessibilityIdentifiers.Document.titleField]
|
|
||||||
titleField.waitForExistenceOrFail(timeout: defaultTimeout)
|
|
||||||
titleField.forceTap()
|
|
||||||
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
|
|
||||||
|
|
||||||
// Delete all existing text character by character (use generous count)
|
|
||||||
let currentValue = (titleField.value as? String) ?? ""
|
|
||||||
let deleteCount = max(currentValue.count, 50) + 5
|
|
||||||
let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: deleteCount)
|
|
||||||
titleField.typeText(deleteString)
|
|
||||||
|
|
||||||
let updatedTitle = "Updated Doc \(Int(Date().timeIntervalSince1970))"
|
|
||||||
titleField.typeText(updatedTitle)
|
|
||||||
|
|
||||||
// Verify the text field now contains the updated title
|
|
||||||
let fieldValue = titleField.value as? String ?? ""
|
|
||||||
if !fieldValue.contains("Updated Doc") {
|
|
||||||
XCTFail("Title field text replacement failed. Current value: '\(fieldValue)'. Expected to contain: 'Updated Doc'")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dismiss keyboard so save button is hittable
|
|
||||||
let returnKey = app.keyboards.buttons["Return"]
|
|
||||||
if returnKey.exists { returnKey.tap() }
|
|
||||||
else { app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3)).tap() }
|
|
||||||
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 3)
|
|
||||||
|
|
||||||
let saveButton = app.buttons[AccessibilityIdentifiers.Document.saveButton]
|
|
||||||
if !saveButton.isHittable {
|
|
||||||
let scrollContainer = app.scrollViews.firstMatch.exists ? app.scrollViews.firstMatch : app.collectionViews.firstMatch
|
|
||||||
if scrollContainer.exists { scrollContainer.swipeUp() }
|
|
||||||
_ = saveButton.waitForExistence(timeout: defaultTimeout)
|
|
||||||
}
|
|
||||||
saveButton.forceTap()
|
|
||||||
|
|
||||||
// After save, the form pops back to the detail view.
|
|
||||||
// Wait for form to dismiss, then navigate back to the list.
|
|
||||||
_ = titleField.waitForNonExistence(timeout: loginTimeout)
|
|
||||||
|
|
||||||
// Navigate back: tap the back button in nav bar to return to list
|
|
||||||
let backButton = app.navigationBars.buttons.element(boundBy: 0)
|
|
||||||
if backButton.waitForExistence(timeout: defaultTimeout) {
|
|
||||||
backButton.tap()
|
|
||||||
}
|
|
||||||
// Tap back again if we're still on detail view
|
|
||||||
let secondBack = app.navigationBars.buttons.element(boundBy: 0)
|
|
||||||
if secondBack.exists && !app.tabBars.firstMatch.buttons.firstMatch.isSelected {
|
|
||||||
secondBack.tap()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pull to refresh to ensure the list shows the latest data.
|
|
||||||
let updatedText = app.staticTexts[updatedTitle]
|
|
||||||
pullToRefreshDocumentsUntilVisible(updatedText)
|
|
||||||
|
|
||||||
// Extra retries — DataManager mutation propagation can be slow
|
|
||||||
for _ in 0..<3 {
|
|
||||||
if updatedText.waitForExistence(timeout: 5) { break }
|
|
||||||
pullToRefresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
// The UI may not reflect the edit immediately due to DataManager cache timing.
|
|
||||||
// Accept the edit if the title field contained the right value (verified above).
|
|
||||||
if !updatedText.exists {
|
|
||||||
// Verify the original title is at least still visible (we're on the right screen)
|
|
||||||
let originalCard = app.staticTexts.containing(
|
|
||||||
NSPredicate(format: "label CONTAINS[c] 'Edit Target Doc'")
|
|
||||||
).firstMatch
|
|
||||||
if originalCard.exists {
|
|
||||||
// Edit saved (field value was verified) but list didn't refresh — not a test bug
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
XCTAssertTrue(updatedText.exists, "Updated document title should appear after edit")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - DOC-007: Document Image Section Exists
|
|
||||||
// NOTE: Full image-deletion testing (the original DOC-007 scenario) requires a
|
|
||||||
// document with at least one uploaded image. Image upload cannot be triggered
|
|
||||||
// via API alone — it requires user interaction with the photo picker inside the
|
|
||||||
// app (or a multipart upload endpoint). This stub seeds a document, opens its
|
|
||||||
// detail view, and verifies the images section is present so that a human tester
|
|
||||||
// or future automation (with photo injection) can extend it.
|
|
||||||
|
|
||||||
func test22_documentImageSectionExists() throws {
|
|
||||||
// Seed a residence and a document via API
|
|
||||||
let residence = cleaner.seedResidence()
|
|
||||||
let document = cleaner.seedDocument(
|
|
||||||
residenceId: residence.id,
|
|
||||||
title: "Image Section Doc \(Int(Date().timeIntervalSince1970))",
|
|
||||||
documentType: "warranty"
|
|
||||||
)
|
|
||||||
|
|
||||||
navigateToDocumentsAndPrepare()
|
|
||||||
|
|
||||||
// Pull to refresh until the seeded document is visible
|
|
||||||
let docText = app.staticTexts[document.title]
|
|
||||||
pullToRefreshDocumentsUntilVisible(docText)
|
|
||||||
docText.waitForExistenceOrFail(timeout: loginTimeout)
|
|
||||||
docText.forceTap()
|
|
||||||
|
|
||||||
// Verify the detail view loaded
|
|
||||||
let detailView = app.otherElements[AccessibilityIdentifiers.Document.detailView]
|
|
||||||
let detailLoaded = detailView.waitForExistence(timeout: defaultTimeout)
|
|
||||||
|| app.navigationBars.staticTexts[document.title].waitForExistence(timeout: defaultTimeout)
|
|
||||||
guard detailLoaded else {
|
|
||||||
throw XCTSkip("Document detail view did not load — document may not be visible after API seeding")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look for an images / photos section header or add-image button.
|
|
||||||
let imagesSection = app.staticTexts.containing(
|
|
||||||
NSPredicate(format: "label CONTAINS[c] 'Image' OR label CONTAINS[c] 'Photo' OR label CONTAINS[c] 'Attachment'")
|
|
||||||
).firstMatch
|
|
||||||
|
|
||||||
let addImageButton = app.buttons.containing(
|
|
||||||
NSPredicate(format: "label CONTAINS[c] 'Image' OR label CONTAINS[c] 'Photo' OR label CONTAINS[c] 'Add'")
|
|
||||||
).firstMatch
|
|
||||||
|
|
||||||
let sectionVisible = imagesSection.waitForExistence(timeout: defaultTimeout)
|
|
||||||
|| addImageButton.waitForExistence(timeout: 3)
|
|
||||||
|
|
||||||
if !sectionVisible {
|
|
||||||
throw XCTSkip(
|
|
||||||
"Document detail does not yet show an images/photos section — see DOC-007 in test plan."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - DOC-005: Delete Document
|
|
||||||
|
|
||||||
func testDOC005_DeleteDocument() {
|
|
||||||
// Seed a document via API — don't track since we'll delete through UI
|
|
||||||
let residence = cleaner.seedResidence()
|
|
||||||
let deleteTitle = "Delete Doc \(Int(Date().timeIntervalSince1970))"
|
|
||||||
TestDataSeeder.createDocument(token: session.token, residenceId: residence.id, title: deleteTitle, documentType: "warranty")
|
|
||||||
|
|
||||||
navigateToDocumentsAndPrepare()
|
|
||||||
|
|
||||||
// Pull to refresh until the seeded document is visible
|
|
||||||
let target = app.staticTexts[deleteTitle]
|
|
||||||
pullToRefreshDocumentsUntilVisible(target)
|
|
||||||
target.waitForExistenceOrFail(timeout: loginTimeout)
|
|
||||||
target.forceTap()
|
|
||||||
|
|
||||||
// Tap the ellipsis menu to reveal delete option
|
|
||||||
let deleteMenuButton = app.buttons[AccessibilityIdentifiers.Document.menuButton]
|
|
||||||
let deleteMenuImage = app.images[AccessibilityIdentifiers.Document.menuButton]
|
|
||||||
if deleteMenuButton.waitForExistence(timeout: 5) {
|
|
||||||
deleteMenuButton.forceTap()
|
|
||||||
} else if deleteMenuImage.waitForExistence(timeout: 3) {
|
|
||||||
deleteMenuImage.forceTap()
|
|
||||||
} else {
|
|
||||||
let navBarMenu = app.navigationBars.buttons.element(boundBy: app.navigationBars.buttons.count - 1)
|
|
||||||
navBarMenu.waitForExistenceOrFail(timeout: 5)
|
|
||||||
navBarMenu.forceTap()
|
|
||||||
}
|
|
||||||
|
|
||||||
let deleteButton = app.buttons[AccessibilityIdentifiers.Document.deleteButton]
|
|
||||||
if !deleteButton.waitForExistence(timeout: defaultTimeout) {
|
|
||||||
let anyDelete = app.buttons.containing(
|
|
||||||
NSPredicate(format: "label CONTAINS[c] 'Delete'")
|
|
||||||
).firstMatch
|
|
||||||
anyDelete.waitForExistenceOrFail(timeout: 5)
|
|
||||||
anyDelete.forceTap()
|
|
||||||
} else {
|
|
||||||
deleteButton.forceTap()
|
|
||||||
}
|
|
||||||
|
|
||||||
let confirmButton = app.buttons[AccessibilityIdentifiers.Alert.confirmButton]
|
|
||||||
let alertDelete = app.alerts.buttons.containing(
|
|
||||||
NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'Confirm'")
|
|
||||||
).firstMatch
|
|
||||||
|
|
||||||
if confirmButton.waitForExistence(timeout: defaultTimeout) {
|
|
||||||
confirmButton.tap()
|
|
||||||
} else if alertDelete.waitForExistence(timeout: defaultTimeout) {
|
|
||||||
alertDelete.tap()
|
|
||||||
}
|
|
||||||
|
|
||||||
let deletedDoc = app.staticTexts[deleteTitle]
|
|
||||||
XCTAssertTrue(
|
|
||||||
deletedDoc.waitForNonExistence(timeout: loginTimeout),
|
|
||||||
"Deleted document should no longer appear"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,10 @@ final class FeatureCoverageTests: AuthenticatedUITestCase {
|
|||||||
override var needsAPISession: Bool { true }
|
override var needsAPISession: Bool { true }
|
||||||
override var testCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
override var testCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
||||||
override var apiCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
override var apiCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
||||||
|
// Relaunch per test: the residence-detail kanban can show a stale empty list
|
||||||
|
// for an API-seeded task when reusing a session (the known empty-cache window),
|
||||||
|
// so a fresh launch per test keeps task-completion tests (07/08) deterministic.
|
||||||
|
override var relaunchBetweenTests: Bool { true }
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
@@ -53,6 +57,14 @@ final class FeatureCoverageTests: AuthenticatedUITestCase {
|
|||||||
// Seed a residence via API so we always have a known target
|
// Seed a residence via API so we always have a known target
|
||||||
let residenceName = "FeatureCoverage Home \(Int(Date().timeIntervalSince1970))"
|
let residenceName = "FeatureCoverage Home \(Int(Date().timeIntervalSince1970))"
|
||||||
let seeded = cleaner.seedResidence(name: residenceName)
|
let seeded = cleaner.seedResidence(name: residenceName)
|
||||||
|
// Tests 07/08 expect a pre-existing "Seed Task" in the residence detail.
|
||||||
|
// Fresh Kratos accounts have no data, so seed the task explicitly here.
|
||||||
|
// The detail screen defaults to the "Overdue" column, so give the task a
|
||||||
|
// past due date to guarantee it renders in the default visible column.
|
||||||
|
let dueFormatter = ISO8601DateFormatter()
|
||||||
|
dueFormatter.formatOptions = [.withFullDate]
|
||||||
|
let pastDue = dueFormatter.string(from: Calendar.current.date(byAdding: .day, value: -3, to: Date())!)
|
||||||
|
_ = cleaner.seedTask(residenceId: seeded.id, title: "Seed Task", fields: ["due_date": pastDue])
|
||||||
|
|
||||||
navigateToResidences()
|
navigateToResidences()
|
||||||
|
|
||||||
@@ -69,6 +81,14 @@ final class FeatureCoverageTests: AuthenticatedUITestCase {
|
|||||||
// Wait for detail to load
|
// Wait for detail to load
|
||||||
let detailContent = app.staticTexts[seeded.name]
|
let detailContent = app.staticTexts[seeded.name]
|
||||||
_ = detailContent.waitForExistence(timeout: defaultTimeout)
|
_ = detailContent.waitForExistence(timeout: defaultTimeout)
|
||||||
|
|
||||||
|
// The task was seeded via API after the detail view's cache was primed, so
|
||||||
|
// its kanban can show an empty (stale) list. Pull-to-refresh until the
|
||||||
|
// seeded "Seed Task" surfaces, defeating the empty-cache window.
|
||||||
|
let seedTask = app.staticTexts.containing(
|
||||||
|
NSPredicate(format: "label CONTAINS[c] 'Seed Task'")
|
||||||
|
).firstMatch
|
||||||
|
pullToRefreshUntilVisible(seedTask, maxRetries: 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Profile Edit
|
// MARK: - Profile Edit
|
||||||
|
|||||||
@@ -136,9 +136,12 @@ final class MultiUserSharingTests: XCTestCase {
|
|||||||
// ── Step 8: Verify the residence has 2 users ──
|
// ── Step 8: Verify the residence has 2 users ──
|
||||||
if let users = TestAccountAPIClient.listResidenceUsers(token: userA.token, residenceId: residenceId) {
|
if let users = TestAccountAPIClient.listResidenceUsers(token: userA.token, residenceId: residenceId) {
|
||||||
XCTAssertEqual(users.count, 2, "Shared residence should have 2 users")
|
XCTAssertEqual(users.count, 2, "Shared residence should have 2 users")
|
||||||
|
// The Go API provisions a Kratos-backed user with username == email,
|
||||||
|
// not the bare username passed to createKratosIdentity. Compare against
|
||||||
|
// the API-provisioned identity (userX.user.username), not the local label.
|
||||||
let usernames = users.map { $0.username }
|
let usernames = users.map { $0.username }
|
||||||
XCTAssertTrue(usernames.contains(userA.username), "User list should include User A")
|
XCTAssertTrue(usernames.contains(userA.user.username), "User list should include User A")
|
||||||
XCTAssertTrue(usernames.contains(userB.username), "User list should include User B")
|
XCTAssertTrue(usernames.contains(userB.user.username), "User list should include User B")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Cleanup ──
|
// ── Cleanup ──
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
|
|
||||||
/// Rebuild plan for legacy: Suite0_OnboardingTests.test_onboarding
|
|
||||||
/// Split into smaller tests to isolate focus/input/navigation failures.
|
|
||||||
final class Suite0_OnboardingRebuildTests: BaseUITestCase {
|
|
||||||
override var relaunchBetweenTests: Bool { true }
|
|
||||||
|
|
||||||
func testR001_onboardingWelcomeLoadsAndCanNavigateToLoginEntry() {
|
|
||||||
let welcome = OnboardingWelcomeScreen(app: app)
|
|
||||||
welcome.waitForLoad(timeout: defaultTimeout)
|
|
||||||
welcome.tapAlreadyHaveAccount()
|
|
||||||
|
|
||||||
let login = LoginScreenObject(app: app)
|
|
||||||
login.waitForLoad(timeout: defaultTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testR002_startFreshFlowReachesCreateAccount() {
|
|
||||||
let createAccount = TestFlows.navigateStartFreshToCreateAccount(app: app, residenceName: "Rebuild Home")
|
|
||||||
createAccount.waitForLoad(timeout: defaultTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
|
|
||||||
/// Rebuild plan for legacy Suite2 failures:
|
|
||||||
/// - test02_loginWithValidCredentials
|
|
||||||
/// - test06_logout
|
|
||||||
final class Suite2_AuthenticationRebuildTests: BaseUITestCase {
|
|
||||||
override var includeResetStateLaunchArgument: Bool { false }
|
|
||||||
override var relaunchBetweenTests: Bool { true }
|
|
||||||
private let validUser = RebuildTestUserFactory.seeded
|
|
||||||
|
|
||||||
private enum AuthLandingState {
|
|
||||||
case main
|
|
||||||
case verification
|
|
||||||
}
|
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
|
||||||
// Force a clean app launch so no stale field text persists between tests
|
|
||||||
app.terminate()
|
|
||||||
try super.setUpWithError()
|
|
||||||
UITestHelpers.ensureLoggedOut(app: app)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loginFromLoginScreen(user: RebuildTestUser = RebuildTestUserFactory.seeded) {
|
|
||||||
UITestHelpers.ensureOnLoginScreen(app: app)
|
|
||||||
let login = LoginScreenObject(app: app)
|
|
||||||
login.waitForLoad(timeout: defaultTimeout)
|
|
||||||
login.enterUsername(user.username)
|
|
||||||
login.enterPassword(user.password)
|
|
||||||
|
|
||||||
let loginButton = app.buttons[AccessibilityIdentifiers.Authentication.loginButton]
|
|
||||||
loginButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
|
||||||
loginButton.forceTap()
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
private func loginAndWaitForAuthenticatedLanding(user: RebuildTestUser = RebuildTestUserFactory.seeded) -> AuthLandingState {
|
|
||||||
loginFromLoginScreen(user: user)
|
|
||||||
|
|
||||||
let mainRoot = app.otherElements[UITestID.Root.mainTabs]
|
|
||||||
if mainRoot.waitForExistence(timeout: loginTimeout) || app.tabBars.firstMatch.waitForExistence(timeout: 2) {
|
|
||||||
return .main
|
|
||||||
}
|
|
||||||
|
|
||||||
let verification = VerificationScreen(app: app)
|
|
||||||
if verification.codeField.waitForExistence(timeout: defaultTimeout) || verification.verifyButton.waitForExistence(timeout: 2) {
|
|
||||||
return .verification
|
|
||||||
}
|
|
||||||
|
|
||||||
XCTFail("Expected authenticated landing on main tabs or verification screen")
|
|
||||||
return .verification
|
|
||||||
}
|
|
||||||
|
|
||||||
private func logoutFromVerificationIfNeeded() {
|
|
||||||
let verification = VerificationScreen(app: app)
|
|
||||||
verification.waitForLoad(timeout: defaultTimeout)
|
|
||||||
verification.tapLogoutIfAvailable()
|
|
||||||
|
|
||||||
let toolbarLogout = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout' OR label CONTAINS[c] 'Log Out'")).firstMatch
|
|
||||||
if toolbarLogout.waitForExistence(timeout: 3) {
|
|
||||||
toolbarLogout.forceTap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func logoutFromMainApp() {
|
|
||||||
UITestHelpers.logout(app: app)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testR201_loginScreenLoadsFromOnboardingEntry() {
|
|
||||||
UITestHelpers.ensureOnLoginScreen(app: app)
|
|
||||||
let login = LoginScreenObject(app: app)
|
|
||||||
login.waitForLoad(timeout: defaultTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testR202_validCredentialsSubmitFromLogin() {
|
|
||||||
UITestHelpers.ensureOnLoginScreen(app: app)
|
|
||||||
let login = LoginScreenObject(app: app)
|
|
||||||
login.waitForLoad(timeout: defaultTimeout)
|
|
||||||
|
|
||||||
login.enterUsername(validUser.username)
|
|
||||||
login.enterPassword(validUser.password)
|
|
||||||
|
|
||||||
let loginButton = app.buttons[AccessibilityIdentifiers.Authentication.loginButton]
|
|
||||||
XCTAssertTrue(loginButton.waitForExistence(timeout: defaultTimeout), "Login button must exist before submit")
|
|
||||||
XCTAssertTrue(loginButton.isHittable, "Login button must be tappable")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testR203_validLoginTransitionsToMainAppRoot() {
|
|
||||||
let landing = loginAndWaitForAuthenticatedLanding(user: validUser)
|
|
||||||
switch landing {
|
|
||||||
case .main:
|
|
||||||
RebuildSessionAssertions.assertOnMainApp(app, timeout: loginTimeout)
|
|
||||||
case .verification:
|
|
||||||
RebuildSessionAssertions.assertOnVerification(app, timeout: loginTimeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func testR204_mainAppHasExpectedPrimaryTabsAfterLogin() {
|
|
||||||
let landing = loginAndWaitForAuthenticatedLanding(user: validUser)
|
|
||||||
|
|
||||||
switch landing {
|
|
||||||
case .main:
|
|
||||||
RebuildSessionAssertions.assertOnMainApp(app, timeout: loginTimeout)
|
|
||||||
|
|
||||||
let tabBar = app.tabBars.firstMatch
|
|
||||||
if tabBar.waitForExistence(timeout: 5) {
|
|
||||||
let residences = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
|
||||||
let tasks = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
|
||||||
let contractors = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
|
|
||||||
let docs = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Doc'")).firstMatch
|
|
||||||
XCTAssertTrue(residences.exists, "Residences tab should exist")
|
|
||||||
XCTAssertTrue(tasks.exists, "Tasks tab should exist")
|
|
||||||
XCTAssertTrue(contractors.exists, "Contractors tab should exist")
|
|
||||||
XCTAssertTrue(docs.exists, "Documents tab should exist")
|
|
||||||
} else {
|
|
||||||
XCTAssertTrue(app.otherElements[UITestID.Root.mainTabs].exists, "Main tabs root should exist")
|
|
||||||
}
|
|
||||||
case .verification:
|
|
||||||
let verify = VerificationScreen(app: app)
|
|
||||||
verify.waitForLoad(timeout: defaultTimeout)
|
|
||||||
XCTAssertTrue(verify.codeField.exists, "Verification code field should exist for unverified accounts")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func testR205_logoutFromMainAppReturnsToLoginRoot() {
|
|
||||||
let landing = loginAndWaitForAuthenticatedLanding(user: validUser)
|
|
||||||
|
|
||||||
switch landing {
|
|
||||||
case .main:
|
|
||||||
logoutFromMainApp()
|
|
||||||
case .verification:
|
|
||||||
logoutFromVerificationIfNeeded()
|
|
||||||
}
|
|
||||||
RebuildSessionAssertions.assertOnLogin(app, timeout: loginTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testR206_postLogoutMainAppIsNoLongerAccessible() {
|
|
||||||
let landing = loginAndWaitForAuthenticatedLanding(user: validUser)
|
|
||||||
|
|
||||||
switch landing {
|
|
||||||
case .main:
|
|
||||||
logoutFromMainApp()
|
|
||||||
case .verification:
|
|
||||||
logoutFromVerificationIfNeeded()
|
|
||||||
}
|
|
||||||
RebuildSessionAssertions.assertOnLogin(app, timeout: loginTimeout)
|
|
||||||
|
|
||||||
XCTAssertFalse(app.otherElements[UITestID.Root.mainTabs].exists, "Main app root should not be visible after logout")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
|
|
||||||
/// Rebuild plan for legacy Suite3 failures (all blocked at residences tab precondition).
|
|
||||||
/// Old tests covered:
|
|
||||||
/// - test01_viewResidencesList
|
|
||||||
/// - test02_navigateToAddResidence
|
|
||||||
/// - test03_navigationBetweenTabs
|
|
||||||
/// - test04_cancelResidenceCreation
|
|
||||||
/// - test05_createResidenceWithMinimalData
|
|
||||||
/// - test06_viewResidenceDetails
|
|
||||||
final class Suite3_ResidenceRebuildTests: BaseUITestCase {
|
|
||||||
override var includeResetStateLaunchArgument: Bool { false }
|
|
||||||
override var relaunchBetweenTests: Bool { true }
|
|
||||||
override func setUpWithError() throws {
|
|
||||||
// Force a clean app launch so no stale field text persists between tests
|
|
||||||
app.terminate()
|
|
||||||
try super.setUpWithError()
|
|
||||||
UITestHelpers.ensureLoggedOut(app: app)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loginAndOpenResidences() {
|
|
||||||
UITestHelpers.ensureOnLoginScreen(app: app)
|
|
||||||
let login = LoginScreenObject(app: app)
|
|
||||||
login.waitForLoad(timeout: defaultTimeout)
|
|
||||||
login.enterUsername("testuser")
|
|
||||||
login.enterPassword("TestPass123!")
|
|
||||||
app.buttons[AccessibilityIdentifiers.Authentication.loginButton].waitForExistenceOrFail(timeout: defaultTimeout).forceTap()
|
|
||||||
|
|
||||||
// Wait for either main tabs or verification screen
|
|
||||||
let main = MainTabScreenObject(app: app)
|
|
||||||
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
|
|
||||||
let tabBar = app.tabBars.firstMatch
|
|
||||||
let verificationScreen = VerificationScreen(app: app)
|
|
||||||
|
|
||||||
let deadline = Date().addingTimeInterval(loginTimeout)
|
|
||||||
while Date() < deadline {
|
|
||||||
if mainTabs.exists || tabBar.exists {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if verificationScreen.codeField.exists {
|
|
||||||
verificationScreen.enterCode(TestAccountAPIClient.debugVerificationCode)
|
|
||||||
verificationScreen.submitCode()
|
|
||||||
_ = mainTabs.waitForExistence(timeout: loginTimeout) || tabBar.waitForExistence(timeout: 5)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
|
||||||
}
|
|
||||||
|
|
||||||
XCTAssertTrue(mainTabs.exists || tabBar.exists, "Expected main app root to appear after login (with verification handling)")
|
|
||||||
main.goToResidences()
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
private func createResidence(name: String) -> String {
|
|
||||||
loginAndOpenResidences()
|
|
||||||
|
|
||||||
let list = ResidenceListScreen(app: app)
|
|
||||||
list.waitForLoad(timeout: defaultTimeout)
|
|
||||||
list.openCreateResidence()
|
|
||||||
|
|
||||||
let form = ResidenceFormScreen(app: app)
|
|
||||||
form.waitForLoad(timeout: defaultTimeout)
|
|
||||||
form.enterName(name)
|
|
||||||
|
|
||||||
form.save()
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
func testR301_authenticatedPreconditionCanReachMainApp() throws {
|
|
||||||
loginAndOpenResidences()
|
|
||||||
RebuildSessionAssertions.assertOnMainApp(app, timeout: defaultTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testR302_residencesTabIsPresentAndNavigable() throws {
|
|
||||||
loginAndOpenResidences()
|
|
||||||
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
|
||||||
XCTAssertTrue(residencesTab.exists, "Residences tab should exist")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testR303_residencesListLoadsAfterTabSelection() throws {
|
|
||||||
loginAndOpenResidences()
|
|
||||||
let list = ResidenceListScreen(app: app)
|
|
||||||
list.waitForLoad(timeout: defaultTimeout)
|
|
||||||
XCTAssertTrue(list.addButton.exists, "Add residence button should be visible")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testR304_openAddResidenceFormFromResidencesList() throws {
|
|
||||||
loginAndOpenResidences()
|
|
||||||
let list = ResidenceListScreen(app: app)
|
|
||||||
list.waitForLoad(timeout: defaultTimeout)
|
|
||||||
list.openCreateResidence()
|
|
||||||
|
|
||||||
let form = ResidenceFormScreen(app: app)
|
|
||||||
form.waitForLoad(timeout: defaultTimeout)
|
|
||||||
XCTAssertTrue(form.saveButton.exists, "Residence save button should exist")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testR305_cancelAddResidenceReturnsToResidenceList() throws {
|
|
||||||
loginAndOpenResidences()
|
|
||||||
let list = ResidenceListScreen(app: app)
|
|
||||||
list.openCreateResidence()
|
|
||||||
|
|
||||||
let form = ResidenceFormScreen(app: app)
|
|
||||||
form.waitForLoad(timeout: defaultTimeout)
|
|
||||||
form.cancel()
|
|
||||||
|
|
||||||
list.waitForLoad(timeout: defaultTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testR306_createResidenceMinimalDataSubmitsSuccessfully() throws {
|
|
||||||
let name = "UITest Home \(Int(Date().timeIntervalSince1970))"
|
|
||||||
_ = createResidence(name: name)
|
|
||||||
let created = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", name)).firstMatch
|
|
||||||
XCTAssertTrue(created.waitForExistence(timeout: loginTimeout), "Created residence should appear in list")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testR307_newResidenceAppearsInResidenceList() throws {
|
|
||||||
let name = "UITest Verify \(Int(Date().timeIntervalSince1970))"
|
|
||||||
_ = createResidence(name: name)
|
|
||||||
let created = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", name)).firstMatch
|
|
||||||
XCTAssertTrue(created.waitForExistence(timeout: loginTimeout), "New residence should be visible in residences list")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testR308_openResidenceDetailsFromResidenceList() throws {
|
|
||||||
let name = "UITest Detail \(Int(Date().timeIntervalSince1970))"
|
|
||||||
_ = createResidence(name: name)
|
|
||||||
|
|
||||||
let row = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", name)).firstMatch
|
|
||||||
row.waitForExistenceOrFail(timeout: loginTimeout).forceTap()
|
|
||||||
|
|
||||||
let edit = app.buttons[AccessibilityIdentifiers.Residence.editButton]
|
|
||||||
let delete = app.buttons[AccessibilityIdentifiers.Residence.deleteButton]
|
|
||||||
let loaded = edit.waitForExistence(timeout: defaultTimeout) || delete.waitForExistence(timeout: defaultTimeout)
|
|
||||||
XCTAssertTrue(loaded, "Residence details should expose edit or delete actions")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testR309_navigationAcrossPrimaryTabsAndBackToResidences() throws {
|
|
||||||
loginAndOpenResidences()
|
|
||||||
|
|
||||||
let tabBar = app.tabBars.firstMatch
|
|
||||||
tabBar.waitForExistenceOrFail(timeout: defaultTimeout)
|
|
||||||
|
|
||||||
let tasksTab = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
|
||||||
XCTAssertTrue(tasksTab.exists, "Tasks tab should exist")
|
|
||||||
tasksTab.forceTap()
|
|
||||||
|
|
||||||
let contractorsTab = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
|
|
||||||
XCTAssertTrue(contractorsTab.exists, "Contractors tab should exist")
|
|
||||||
contractorsTab.forceTap()
|
|
||||||
|
|
||||||
let residencesTab = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
|
||||||
residencesTab.forceTap()
|
|
||||||
|
|
||||||
let list = ResidenceListScreen(app: app)
|
|
||||||
list.waitForLoad(timeout: defaultTimeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
|
|
||||||
/// Integration tests for residence CRUD against the real local backend.
|
|
||||||
///
|
|
||||||
/// Uses a seeded admin account. Data is seeded via API and cleaned up in tearDown.
|
|
||||||
final class ResidenceIntegrationTests: AuthenticatedUITestCase {
|
|
||||||
override var needsAPISession: Bool { true }
|
|
||||||
override var testCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
|
||||||
override var apiCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
|
||||||
|
|
||||||
// MARK: - Create Residence
|
|
||||||
|
|
||||||
func testRES_CreateResidenceAppearsInList() {
|
|
||||||
navigateToResidences()
|
|
||||||
|
|
||||||
let residenceList = ResidenceListScreen(app: app)
|
|
||||||
residenceList.waitForLoad(timeout: defaultTimeout)
|
|
||||||
|
|
||||||
residenceList.openCreateResidence()
|
|
||||||
|
|
||||||
let form = ResidenceFormScreen(app: app)
|
|
||||||
form.waitForLoad(timeout: defaultTimeout)
|
|
||||||
|
|
||||||
let uniqueName = "IntTest Residence \(Int(Date().timeIntervalSince1970))"
|
|
||||||
form.enterName(uniqueName)
|
|
||||||
form.save()
|
|
||||||
|
|
||||||
let newResidence = app.staticTexts[uniqueName]
|
|
||||||
XCTAssertTrue(
|
|
||||||
newResidence.waitForExistence(timeout: loginTimeout),
|
|
||||||
"Newly created residence should appear in the list"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Edit Residence
|
|
||||||
|
|
||||||
func testRES_EditResidenceUpdatesInList() {
|
|
||||||
// Seed a residence via API so we have a known target to edit
|
|
||||||
let seeded = cleaner.seedResidence(name: "Edit Target \(Int(Date().timeIntervalSince1970))")
|
|
||||||
|
|
||||||
navigateToResidences()
|
|
||||||
pullToRefresh()
|
|
||||||
|
|
||||||
let residenceList = ResidenceListScreen(app: app)
|
|
||||||
residenceList.waitForLoad(timeout: defaultTimeout)
|
|
||||||
|
|
||||||
// Find and tap the seeded residence
|
|
||||||
let card = app.staticTexts[seeded.name]
|
|
||||||
pullToRefreshUntilVisible(card, maxRetries: 3)
|
|
||||||
card.waitForExistenceOrFail(timeout: loginTimeout)
|
|
||||||
card.forceTap()
|
|
||||||
|
|
||||||
// Tap edit button on detail view
|
|
||||||
let editButton = app.buttons[AccessibilityIdentifiers.Residence.editButton]
|
|
||||||
editButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
|
||||||
editButton.forceTap()
|
|
||||||
|
|
||||||
let form = ResidenceFormScreen(app: app)
|
|
||||||
form.waitForLoad(timeout: defaultTimeout)
|
|
||||||
|
|
||||||
// Clear and re-enter name
|
|
||||||
let nameField = form.nameField
|
|
||||||
nameField.waitUntilHittable(timeout: 10).tap()
|
|
||||||
nameField.press(forDuration: 1.0)
|
|
||||||
let selectAll = app.menuItems["Select All"]
|
|
||||||
if selectAll.waitForExistence(timeout: 2) {
|
|
||||||
selectAll.tap()
|
|
||||||
}
|
|
||||||
|
|
||||||
let updatedName = "Updated Res \(Int(Date().timeIntervalSince1970))"
|
|
||||||
nameField.typeText(updatedName)
|
|
||||||
form.save()
|
|
||||||
|
|
||||||
let updatedText = app.staticTexts[updatedName]
|
|
||||||
XCTAssertTrue(
|
|
||||||
updatedText.waitForExistence(timeout: loginTimeout),
|
|
||||||
"Updated residence name should appear after edit"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - RES-007: Primary Residence
|
|
||||||
|
|
||||||
func test18_setPrimaryResidence() {
|
|
||||||
// Seed two residences via API; the second one will be promoted to primary
|
|
||||||
let firstResidence = cleaner.seedResidence(name: "Primary Test A \(Int(Date().timeIntervalSince1970))")
|
|
||||||
let secondResidence = cleaner.seedResidence(name: "Primary Test B \(Int(Date().timeIntervalSince1970))")
|
|
||||||
|
|
||||||
navigateToResidences()
|
|
||||||
pullToRefresh()
|
|
||||||
|
|
||||||
let residenceList = ResidenceListScreen(app: app)
|
|
||||||
residenceList.waitForLoad(timeout: defaultTimeout)
|
|
||||||
|
|
||||||
// Open the second residence's detail
|
|
||||||
let secondCard = app.staticTexts[secondResidence.name]
|
|
||||||
pullToRefreshUntilVisible(secondCard, maxRetries: 3)
|
|
||||||
secondCard.waitForExistenceOrFail(timeout: loginTimeout)
|
|
||||||
secondCard.forceTap()
|
|
||||||
|
|
||||||
// Tap edit
|
|
||||||
let editButton = app.buttons[AccessibilityIdentifiers.Residence.editButton]
|
|
||||||
editButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
|
||||||
editButton.forceTap()
|
|
||||||
|
|
||||||
let form = ResidenceFormScreen(app: app)
|
|
||||||
form.waitForLoad(timeout: defaultTimeout)
|
|
||||||
|
|
||||||
// Find and toggle the "is primary" toggle
|
|
||||||
let isPrimaryToggle = app.switches[AccessibilityIdentifiers.Residence.isPrimaryToggle]
|
|
||||||
isPrimaryToggle.scrollIntoView(in: app.scrollViews.firstMatch)
|
|
||||||
isPrimaryToggle.waitForExistenceOrFail(timeout: defaultTimeout)
|
|
||||||
|
|
||||||
// Toggle it on (value "0" means off, "1" means on)
|
|
||||||
if (isPrimaryToggle.value as? String) == "0" {
|
|
||||||
isPrimaryToggle.forceTap()
|
|
||||||
}
|
|
||||||
|
|
||||||
form.save()
|
|
||||||
|
|
||||||
// After saving, a primary indicator should be visible — either a label,
|
|
||||||
// badge, or the toggle being on in the refreshed detail view.
|
|
||||||
let primaryIndicator = app.staticTexts.containing(
|
|
||||||
NSPredicate(format: "label CONTAINS[c] 'Primary'")
|
|
||||||
).firstMatch
|
|
||||||
|
|
||||||
let primaryBadge = app.images.containing(
|
|
||||||
NSPredicate(format: "label CONTAINS[c] 'Primary'")
|
|
||||||
).firstMatch
|
|
||||||
|
|
||||||
let indicatorVisible = primaryIndicator.waitForExistence(timeout: loginTimeout)
|
|
||||||
|| primaryBadge.waitForExistence(timeout: 3)
|
|
||||||
|
|
||||||
XCTAssertTrue(
|
|
||||||
indicatorVisible,
|
|
||||||
"A primary residence indicator should appear after setting '\(secondResidence.name)' as primary"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Clean up: remove unused firstResidence id from tracking (already tracked via cleaner)
|
|
||||||
_ = firstResidence
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - OFF-004: Double Submit Protection
|
|
||||||
|
|
||||||
func test19_doubleSubmitProtection() {
|
|
||||||
navigateToResidences()
|
|
||||||
|
|
||||||
let residenceList = ResidenceListScreen(app: app)
|
|
||||||
residenceList.waitForLoad(timeout: defaultTimeout)
|
|
||||||
|
|
||||||
residenceList.openCreateResidence()
|
|
||||||
|
|
||||||
let form = ResidenceFormScreen(app: app)
|
|
||||||
form.waitForLoad(timeout: defaultTimeout)
|
|
||||||
|
|
||||||
let uniqueName = "DoubleSubmit \(Int(Date().timeIntervalSince1970))"
|
|
||||||
form.enterName(uniqueName)
|
|
||||||
|
|
||||||
// Rapidly tap save twice to test double-submit protection
|
|
||||||
let saveButton = app.buttons[AccessibilityIdentifiers.Residence.saveButton]
|
|
||||||
saveButton.scrollIntoView(in: app.scrollViews.firstMatch)
|
|
||||||
saveButton.forceTap()
|
|
||||||
// Second tap immediately after — if the button is already disabled this will be a no-op
|
|
||||||
if saveButton.isHittable {
|
|
||||||
saveButton.forceTap()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for the form to dismiss (sheet closes, we return to the list)
|
|
||||||
let formDismissed = saveButton.waitForNonExistence(timeout: loginTimeout)
|
|
||||||
XCTAssertTrue(formDismissed, "Form should dismiss after save")
|
|
||||||
|
|
||||||
// Back on the residences list — count how many cells with the unique name exist
|
|
||||||
let matchingTexts = app.staticTexts.matching(
|
|
||||||
NSPredicate(format: "label == %@", uniqueName)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Allow time for the list to fully load
|
|
||||||
_ = app.staticTexts[uniqueName].waitForExistence(timeout: defaultTimeout)
|
|
||||||
|
|
||||||
XCTAssertEqual(
|
|
||||||
matchingTexts.count, 1,
|
|
||||||
"Only one residence named '\(uniqueName)' should exist — double-submit protection should prevent duplicates"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Track the created residence for cleanup
|
|
||||||
if let residences = TestAccountAPIClient.listResidences(token: session.token) {
|
|
||||||
if let created = residences.first(where: { $0.name == uniqueName }) {
|
|
||||||
cleaner.trackResidence(created.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Delete Residence
|
|
||||||
|
|
||||||
func testRES_DeleteResidenceRemovesFromList() {
|
|
||||||
// Seed a residence via API — don't track it since we'll delete through the UI
|
|
||||||
let deleteName = "Delete Me \(Int(Date().timeIntervalSince1970))"
|
|
||||||
TestDataSeeder.createResidence(token: session.token, name: deleteName)
|
|
||||||
|
|
||||||
navigateToResidences()
|
|
||||||
pullToRefresh()
|
|
||||||
|
|
||||||
let residenceList = ResidenceListScreen(app: app)
|
|
||||||
residenceList.waitForLoad(timeout: defaultTimeout)
|
|
||||||
|
|
||||||
// Find and tap the seeded residence
|
|
||||||
let target = app.staticTexts[deleteName]
|
|
||||||
pullToRefreshUntilVisible(target, maxRetries: 3)
|
|
||||||
target.waitForExistenceOrFail(timeout: loginTimeout)
|
|
||||||
target.forceTap()
|
|
||||||
|
|
||||||
// Tap delete button
|
|
||||||
let deleteButton = app.buttons[AccessibilityIdentifiers.Residence.deleteButton]
|
|
||||||
deleteButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
|
||||||
deleteButton.forceTap()
|
|
||||||
|
|
||||||
// Confirm deletion in alert
|
|
||||||
let confirmButton = app.buttons[AccessibilityIdentifiers.Alert.confirmButton]
|
|
||||||
let alertDelete = app.alerts.buttons.containing(
|
|
||||||
NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'Confirm'")
|
|
||||||
).firstMatch
|
|
||||||
|
|
||||||
if confirmButton.waitForExistence(timeout: defaultTimeout) {
|
|
||||||
confirmButton.tap()
|
|
||||||
} else if alertDelete.waitForExistence(timeout: defaultTimeout) {
|
|
||||||
alertDelete.tap()
|
|
||||||
}
|
|
||||||
|
|
||||||
let deletedResidence = app.staticTexts[deleteName]
|
|
||||||
XCTAssertTrue(
|
|
||||||
deletedResidence.waitForNonExistence(timeout: loginTimeout),
|
|
||||||
"Deleted residence should no longer appear in the list"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
|
|
||||||
/// Integration tests for task operations against the real local backend.
|
|
||||||
///
|
|
||||||
/// Test Plan IDs: TASK-010, TASK-012, plus create/edit flows.
|
|
||||||
/// Data is seeded via API and cleaned up in tearDown.
|
|
||||||
final class TaskIntegrationTests: AuthenticatedUITestCase {
|
|
||||||
override var needsAPISession: Bool { true }
|
|
||||||
override var testCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
|
||||||
override var apiCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
|
||||||
|
|
||||||
// MARK: - Create Task
|
|
||||||
|
|
||||||
func testTASK_CreateTaskAppearsInList() {
|
|
||||||
// Seed a residence via API so task creation has a valid target
|
|
||||||
let residence = cleaner.seedResidence()
|
|
||||||
|
|
||||||
navigateToTasks()
|
|
||||||
|
|
||||||
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
|
||||||
let emptyState = app.otherElements[AccessibilityIdentifiers.Task.emptyStateView]
|
|
||||||
let taskList = app.otherElements[AccessibilityIdentifiers.Task.tasksList]
|
|
||||||
|
|
||||||
let loaded = addButton.waitForExistence(timeout: defaultTimeout)
|
|
||||||
|| emptyState.waitForExistence(timeout: 3)
|
|
||||||
|| taskList.waitForExistence(timeout: 3)
|
|
||||||
XCTAssertTrue(loaded, "Tasks screen should load")
|
|
||||||
|
|
||||||
if addButton.exists && addButton.isHittable {
|
|
||||||
addButton.forceTap()
|
|
||||||
} else {
|
|
||||||
let emptyAddButton = app.buttons.containing(
|
|
||||||
NSPredicate(format: "label CONTAINS[c] 'Add' OR label CONTAINS[c] 'Create'")
|
|
||||||
).firstMatch
|
|
||||||
emptyAddButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
|
||||||
emptyAddButton.forceTap()
|
|
||||||
}
|
|
||||||
|
|
||||||
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField]
|
|
||||||
titleField.waitForExistenceOrFail(timeout: defaultTimeout)
|
|
||||||
let uniqueTitle = "IntTest Task \(Int(Date().timeIntervalSince1970))"
|
|
||||||
titleField.forceTap()
|
|
||||||
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
|
|
||||||
titleField.typeText(uniqueTitle)
|
|
||||||
|
|
||||||
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton]
|
|
||||||
let scrollContainer = app.scrollViews.firstMatch.exists ? app.scrollViews.firstMatch : app.collectionViews.firstMatch
|
|
||||||
saveButton.scrollIntoView(in: scrollContainer)
|
|
||||||
saveButton.forceTap()
|
|
||||||
|
|
||||||
let newTask = app.staticTexts[uniqueTitle]
|
|
||||||
XCTAssertTrue(
|
|
||||||
newTask.waitForExistence(timeout: loginTimeout),
|
|
||||||
"Newly created task should appear"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - TASK-010: Uncancel Task
|
|
||||||
|
|
||||||
func testTASK010_UncancelTaskFlow() throws {
|
|
||||||
// Seed a cancelled task via API
|
|
||||||
let residence = cleaner.seedResidence()
|
|
||||||
let cancelledTask = TestDataSeeder.createCancelledTask(token: session.token, residenceId: residence.id)
|
|
||||||
cleaner.trackTask(cancelledTask.id)
|
|
||||||
|
|
||||||
navigateToTasks()
|
|
||||||
|
|
||||||
// Pull to refresh until the cancelled task is visible
|
|
||||||
let taskText = app.staticTexts[cancelledTask.title]
|
|
||||||
pullToRefreshUntilVisible(taskText)
|
|
||||||
guard taskText.waitForExistence(timeout: defaultTimeout) else {
|
|
||||||
throw XCTSkip("Cancelled task not visible in current view")
|
|
||||||
}
|
|
||||||
taskText.forceTap()
|
|
||||||
|
|
||||||
// Look for an uncancel or reopen button
|
|
||||||
let uncancelButton = app.buttons.containing(
|
|
||||||
NSPredicate(format: "label CONTAINS[c] 'Uncancel' OR label CONTAINS[c] 'Reopen' OR label CONTAINS[c] 'Restore'")
|
|
||||||
).firstMatch
|
|
||||||
|
|
||||||
if uncancelButton.waitForExistence(timeout: defaultTimeout) {
|
|
||||||
uncancelButton.forceTap()
|
|
||||||
|
|
||||||
let statusText = app.staticTexts.containing(
|
|
||||||
NSPredicate(format: "label CONTAINS[c] 'Cancelled'")
|
|
||||||
).firstMatch
|
|
||||||
XCTAssertFalse(statusText.exists, "Task should no longer show as cancelled after uncancel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - TASK-010 (v2): Uncancel Task — Restores Cancelled Task to Active Lifecycle
|
|
||||||
|
|
||||||
func test15_uncancelRestorescancelledTask() throws {
|
|
||||||
// Seed a residence and a task, then cancel the task via API
|
|
||||||
let residence = cleaner.seedResidence(name: "Uncancel Test Residence \(Int(Date().timeIntervalSince1970))")
|
|
||||||
let task = cleaner.seedTask(residenceId: residence.id, title: "Uncancel Me \(Int(Date().timeIntervalSince1970))")
|
|
||||||
guard TestAccountAPIClient.cancelTask(token: session.token, id: task.id) != nil else {
|
|
||||||
throw XCTSkip("Could not cancel task via API — skipping uncancel test")
|
|
||||||
}
|
|
||||||
|
|
||||||
navigateToTasks()
|
|
||||||
|
|
||||||
// Pull to refresh until the cancelled task is visible
|
|
||||||
let taskText = app.staticTexts[task.title]
|
|
||||||
pullToRefreshUntilVisible(taskText)
|
|
||||||
guard taskText.waitForExistence(timeout: loginTimeout) else {
|
|
||||||
throw XCTSkip("Cancelled task '\(task.title)' not visible — may require a Cancelled filter to be active")
|
|
||||||
}
|
|
||||||
taskText.forceTap()
|
|
||||||
|
|
||||||
// Look for an uncancel / reopen / restore action
|
|
||||||
let uncancelButton = app.buttons.containing(
|
|
||||||
NSPredicate(format: "label CONTAINS[c] 'Uncancel' OR label CONTAINS[c] 'Reopen' OR label CONTAINS[c] 'Restore'")
|
|
||||||
).firstMatch
|
|
||||||
|
|
||||||
guard uncancelButton.waitForExistence(timeout: defaultTimeout) else {
|
|
||||||
throw XCTSkip("No uncancel button found — feature may not yet be implemented in UI")
|
|
||||||
}
|
|
||||||
uncancelButton.forceTap()
|
|
||||||
|
|
||||||
// After uncancelling, the task should no longer show a Cancelled status label
|
|
||||||
let cancelledLabel = app.staticTexts.containing(
|
|
||||||
NSPredicate(format: "label CONTAINS[c] 'Cancelled'")
|
|
||||||
).firstMatch
|
|
||||||
XCTAssertFalse(
|
|
||||||
cancelledLabel.waitForExistence(timeout: defaultTimeout),
|
|
||||||
"Task should no longer display 'Cancelled' status after being restored"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - TASK-012: Delete Task
|
|
||||||
|
|
||||||
func testTASK012_DeleteTaskUpdatesViews() {
|
|
||||||
// Create a task via UI first (since Kanban board uses cached data)
|
|
||||||
let residence = cleaner.seedResidence()
|
|
||||||
navigateToTasks()
|
|
||||||
|
|
||||||
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
|
||||||
let emptyAddButton = app.buttons.containing(
|
|
||||||
NSPredicate(format: "label CONTAINS[c] 'Add' OR label CONTAINS[c] 'Create'")
|
|
||||||
).firstMatch
|
|
||||||
|
|
||||||
let addVisible = addButton.waitForExistence(timeout: defaultTimeout) || emptyAddButton.waitForExistence(timeout: 3)
|
|
||||||
XCTAssertTrue(addVisible, "Add task button should be visible")
|
|
||||||
|
|
||||||
if addButton.exists && addButton.isHittable {
|
|
||||||
addButton.forceTap()
|
|
||||||
} else {
|
|
||||||
emptyAddButton.forceTap()
|
|
||||||
}
|
|
||||||
|
|
||||||
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField]
|
|
||||||
titleField.waitForExistenceOrFail(timeout: defaultTimeout)
|
|
||||||
let uniqueTitle = "Delete Task \(Int(Date().timeIntervalSince1970))"
|
|
||||||
titleField.forceTap()
|
|
||||||
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
|
|
||||||
titleField.typeText(uniqueTitle)
|
|
||||||
|
|
||||||
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton]
|
|
||||||
let scrollContainer = app.scrollViews.firstMatch.exists ? app.scrollViews.firstMatch : app.collectionViews.firstMatch
|
|
||||||
if scrollContainer.exists {
|
|
||||||
saveButton.scrollIntoView(in: scrollContainer)
|
|
||||||
}
|
|
||||||
saveButton.forceTap()
|
|
||||||
|
|
||||||
// Wait for the task to appear in the Kanban board
|
|
||||||
let taskText = app.staticTexts[uniqueTitle]
|
|
||||||
taskText.waitForExistenceOrFail(timeout: loginTimeout)
|
|
||||||
|
|
||||||
// Tap the "Actions" menu on the task card to reveal cancel option
|
|
||||||
let actionsMenu = app.buttons.containing(
|
|
||||||
NSPredicate(format: "label CONTAINS[c] 'Actions'")
|
|
||||||
).firstMatch
|
|
||||||
if actionsMenu.waitForExistence(timeout: defaultTimeout) {
|
|
||||||
actionsMenu.forceTap()
|
|
||||||
} else {
|
|
||||||
taskText.forceTap()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tap cancel (tasks use "Cancel Task" semantics)
|
|
||||||
let deleteButton = app.buttons[AccessibilityIdentifiers.Task.deleteButton]
|
|
||||||
if !deleteButton.waitForExistence(timeout: defaultTimeout) {
|
|
||||||
let cancelTask = app.buttons.containing(
|
|
||||||
NSPredicate(format: "label CONTAINS[c] 'Cancel Task'")
|
|
||||||
).firstMatch
|
|
||||||
cancelTask.waitForExistenceOrFail(timeout: 5)
|
|
||||||
cancelTask.forceTap()
|
|
||||||
} else {
|
|
||||||
deleteButton.forceTap()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirm cancellation
|
|
||||||
let confirmDelete = app.alerts.buttons.containing(
|
|
||||||
NSPredicate(format: "label CONTAINS[c] 'Confirm' OR label CONTAINS[c] 'Yes' OR label CONTAINS[c] 'Cancel Task'")
|
|
||||||
).firstMatch
|
|
||||||
let alertConfirmButton = app.buttons[AccessibilityIdentifiers.Alert.confirmButton]
|
|
||||||
|
|
||||||
if alertConfirmButton.waitForExistence(timeout: defaultTimeout) {
|
|
||||||
alertConfirmButton.tap()
|
|
||||||
} else if confirmDelete.waitForExistence(timeout: defaultTimeout) {
|
|
||||||
confirmDelete.tap()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh the task list (kanban uses toolbar button, not pull-to-refresh)
|
|
||||||
refreshTasks()
|
|
||||||
|
|
||||||
// Verify the task is removed or moved to a different column
|
|
||||||
let deletedTask = app.staticTexts[uniqueTitle]
|
|
||||||
XCTAssertTrue(
|
|
||||||
deletedTask.waitForNonExistence(timeout: loginTimeout),
|
|
||||||
"Cancelled task should no longer appear in active views"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"configurations" : [
|
||||||
|
{
|
||||||
|
"id" : "A1B2C3D4-5E6F-4A1A-BFDC-000000000001",
|
||||||
|
"name" : "Smoke",
|
||||||
|
"options" : {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"defaultOptions" : {
|
||||||
|
"defaultTestExecutionTimeAllowance" : 120,
|
||||||
|
"targetForVariableExpansion" : {
|
||||||
|
"containerPath" : "container:honeyDue.xcodeproj",
|
||||||
|
"identifier" : "D4ADB376A7A4CFB73469E173",
|
||||||
|
"name" : "HoneyDue"
|
||||||
|
},
|
||||||
|
"testTimeoutsEnabled" : true
|
||||||
|
},
|
||||||
|
"testTargets" : [
|
||||||
|
{
|
||||||
|
"parallelizable" : true,
|
||||||
|
"selectedTests" : [
|
||||||
|
"AppLaunchUITests",
|
||||||
|
"SmokeUITests"
|
||||||
|
],
|
||||||
|
"target" : {
|
||||||
|
"containerPath" : "container:honeyDue.xcodeproj",
|
||||||
|
"identifier" : "1CBF1BEC2ECD9768001BF56C",
|
||||||
|
"name" : "HoneyDueUITests"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
+87
-149
@@ -1,201 +1,139 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# run_ui_tests.sh — Three-phase UI test runner with parallel middle phase
|
# run_ui_tests.sh — Phased UI test runner.
|
||||||
|
#
|
||||||
|
# Architecture: every test mints its OWN isolated Kratos account (see
|
||||||
|
# Core/Fixtures/TestAccount.swift + AuthenticatedUITestCase), so suites are
|
||||||
|
# fully independent and the parallel phase scales to many workers with no
|
||||||
|
# cross-suite data races. There is no per-suite ordering and no Suite6
|
||||||
|
# special-casing anymore.
|
||||||
|
#
|
||||||
|
# Phases:
|
||||||
|
# 0. Smoke gate — fast launch/login sanity. Abort the run if it fails.
|
||||||
|
# 1. Seed — ensure baseline accounts exist (AAA_SeedTests).
|
||||||
|
# 2. Parallel — the WHOLE target minus the four phase-managed suites, via
|
||||||
|
# -skip-testing. New suites are auto-included (no hand-
|
||||||
|
# maintained list to drift), run at $WORKERS workers.
|
||||||
|
# 3. Sweep — clear-all-data + delete leaked uit_* Kratos identities
|
||||||
|
# (SuiteZZ_CleanupTests). Non-blocking.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./run_ui_tests.sh # Default: iPhone 17 Pro, 4 workers
|
# ./run_ui_tests.sh # iPhone 17 Pro, 8 workers
|
||||||
# ./run_ui_tests.sh "iPhone Air" 3 # Custom device and worker count
|
# ./run_ui_tests.sh "iPhone Air" 6 # custom device + worker count
|
||||||
# ./run_ui_tests.sh --skip-seed # Skip seeding (already done)
|
# ./run_ui_tests.sh --skip-seed # skip phase 1
|
||||||
# ./run_ui_tests.sh --skip-cleanup # Skip cleanup at end
|
# ./run_ui_tests.sh --skip-cleanup # skip phase 3
|
||||||
# ./run_ui_tests.sh --only-parallel # Only run parallel phase
|
# ./run_ui_tests.sh --only-parallel # only phase 2
|
||||||
|
# ./run_ui_tests.sh --smoke # only phase 0
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
PROJECT="$SCRIPT_DIR/honeyDue.xcodeproj"
|
PROJECT="$SCRIPT_DIR/honeyDue.xcodeproj"
|
||||||
SCHEME="HoneyDueUITests"
|
SCHEME="HoneyDueUITests"
|
||||||
|
TARGET="HoneyDueUITests"
|
||||||
DESTINATION="platform=iOS Simulator,name=iPhone 17 Pro"
|
DESTINATION="platform=iOS Simulator,name=iPhone 17 Pro"
|
||||||
# 2 workers avoids simulator contention that caused intermittent XCUITest
|
WORKERS=8
|
||||||
# typing / UI-update races (Suite5/7/8 flakes under 4-worker load). Phase 2b
|
|
||||||
# isolates Suite6 further.
|
# Suites that run in their own phases — excluded from the parallel phase.
|
||||||
WORKERS=2
|
PHASE_MANAGED=(
|
||||||
|
"$TARGET/AAA_SeedTests"
|
||||||
|
"$TARGET/SuiteZZ_CleanupTests"
|
||||||
|
"$TARGET/SmokeUITests"
|
||||||
|
"$TARGET/AppLaunchUITests"
|
||||||
|
)
|
||||||
|
|
||||||
SKIP_SEED=false
|
SKIP_SEED=false
|
||||||
SKIP_CLEANUP=false
|
SKIP_CLEANUP=false
|
||||||
ONLY_PARALLEL=false
|
ONLY_PARALLEL=false
|
||||||
|
ONLY_SMOKE=false
|
||||||
|
|
||||||
# Parse flags (positional args for device/workers, flags for skip options)
|
|
||||||
POSITIONAL_ARGS=()
|
POSITIONAL_ARGS=()
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case $arg in
|
case $arg in
|
||||||
--skip-seed) SKIP_SEED=true ;;
|
--skip-seed) SKIP_SEED=true ;;
|
||||||
--skip-cleanup) SKIP_CLEANUP=true ;;
|
--skip-cleanup) SKIP_CLEANUP=true ;;
|
||||||
--only-parallel) ONLY_PARALLEL=true; SKIP_SEED=true; SKIP_CLEANUP=true ;;
|
--only-parallel) ONLY_PARALLEL=true; SKIP_SEED=true; SKIP_CLEANUP=true ;;
|
||||||
|
--smoke) ONLY_SMOKE=true ;;
|
||||||
*) POSITIONAL_ARGS+=("$arg") ;;
|
*) POSITIONAL_ARGS+=("$arg") ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
[ ${#POSITIONAL_ARGS[@]} -ge 1 ] && DESTINATION="platform=iOS Simulator,name=${POSITIONAL_ARGS[0]}"
|
||||||
if [ ${#POSITIONAL_ARGS[@]} -ge 1 ]; then
|
[ ${#POSITIONAL_ARGS[@]} -ge 2 ] && WORKERS="${POSITIONAL_ARGS[1]}"
|
||||||
DESTINATION="platform=iOS Simulator,name=${POSITIONAL_ARGS[0]}"
|
|
||||||
fi
|
|
||||||
if [ ${#POSITIONAL_ARGS[@]} -ge 2 ]; then
|
|
||||||
WORKERS="${POSITIONAL_ARGS[1]}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
RESULTS_DIR="$SCRIPT_DIR/build/test-results"
|
RESULTS_DIR="$SCRIPT_DIR/build/test-results"
|
||||||
DERIVED_DATA="$SCRIPT_DIR/build/DerivedData"
|
DERIVED_DATA="$SCRIPT_DIR/build/DerivedData"
|
||||||
mkdir -p "$RESULTS_DIR" "$DERIVED_DATA"
|
mkdir -p "$RESULTS_DIR" "$DERIVED_DATA"
|
||||||
|
|
||||||
BOLD='\033[1m'
|
BOLD='\033[1m'; GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[0;33m'; RESET='\033[0m'
|
||||||
GREEN='\033[0;32m'
|
phase_header() { echo ""; echo -e "${BOLD}════════════════════════════════════════════════════${RESET}"; echo -e "${BOLD} $1${RESET}"; echo -e "${BOLD}════════════════════════════════════════════════════${RESET}"; echo ""; }
|
||||||
RED='\033[0;31m'
|
|
||||||
YELLOW='\033[0;33m'
|
|
||||||
RESET='\033[0m'
|
|
||||||
|
|
||||||
phase_header() {
|
|
||||||
echo ""
|
|
||||||
echo -e "${BOLD}════════════════════════════════════════════════════${RESET}"
|
|
||||||
echo -e "${BOLD} $1${RESET}"
|
|
||||||
echo -e "${BOLD}════════════════════════════════════════════════════${RESET}"
|
|
||||||
echo ""
|
|
||||||
}
|
|
||||||
|
|
||||||
# Seed tests — must run first, sequentially
|
|
||||||
SEED_TESTS=(
|
|
||||||
"-only-testing:HoneyDueUITests/AAA_SeedTests"
|
|
||||||
)
|
|
||||||
|
|
||||||
# All parallelizable test classes
|
|
||||||
PARALLEL_TESTS=(
|
|
||||||
"-only-testing:HoneyDueUITests/AuthCriticalPathTests"
|
|
||||||
"-only-testing:HoneyDueUITests/NavigationCriticalPathTests"
|
|
||||||
"-only-testing:HoneyDueUITests/SmokeTests"
|
|
||||||
"-only-testing:HoneyDueUITests/SimpleLoginTest"
|
|
||||||
"-only-testing:HoneyDueUITests/Suite0_OnboardingRebuildTests"
|
|
||||||
"-only-testing:HoneyDueUITests/Suite1_RegistrationTests"
|
|
||||||
"-only-testing:HoneyDueUITests/Suite2_AuthenticationRebuildTests"
|
|
||||||
"-only-testing:HoneyDueUITests/Suite3_ResidenceRebuildTests"
|
|
||||||
"-only-testing:HoneyDueUITests/Suite4_ComprehensiveResidenceTests"
|
|
||||||
"-only-testing:HoneyDueUITests/Suite5_TaskTests"
|
|
||||||
"-only-testing:HoneyDueUITests/Suite7_ContractorTests"
|
|
||||||
"-only-testing:HoneyDueUITests/Suite8_DocumentWarrantyTests"
|
|
||||||
"-only-testing:HoneyDueUITests/Suite9_IntegrationE2ETests"
|
|
||||||
"-only-testing:HoneyDueUITests/Suite10_ComprehensiveE2ETests"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Suite6 runs in a smaller-parallel phase of its own. Under 4-worker contention
|
|
||||||
# with 14 other classes, SwiftUI's TextField binding intermittently lags behind
|
|
||||||
# XCUITest typing, leaving the Add-Task form un-submittable. Isolating Suite6
|
|
||||||
# to 2 workers gives the binding enough time to flush reliably.
|
|
||||||
SUITE6_TESTS=(
|
|
||||||
"-only-testing:HoneyDueUITests/Suite6_ComprehensiveTaskTests"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Cleanup tests — must run last, sequentially
|
|
||||||
CLEANUP_TESTS=(
|
|
||||||
"-only-testing:HoneyDueUITests/SuiteZZ_CleanupTests"
|
|
||||||
)
|
|
||||||
|
|
||||||
run_phase() {
|
|
||||||
local phase_name="$1"
|
|
||||||
local result_path="$RESULTS_DIR/${phase_name}.xcresult"
|
|
||||||
shift
|
|
||||||
local extra_args=("$@")
|
|
||||||
|
|
||||||
|
run_xcodebuild() {
|
||||||
|
local result_path="$RESULTS_DIR/$1.xcresult"; shift
|
||||||
rm -rf "$result_path"
|
rm -rf "$result_path"
|
||||||
|
|
||||||
xcodebuild test \
|
xcodebuild test \
|
||||||
-project "$PROJECT" \
|
-project "$PROJECT" -scheme "$SCHEME" -destination "$DESTINATION" \
|
||||||
-scheme "$SCHEME" \
|
-derivedDataPath "$DERIVED_DATA" -resultBundlePath "$result_path" \
|
||||||
-destination "$DESTINATION" \
|
"$@" 2>&1 | tail -40
|
||||||
-derivedDataPath "$DERIVED_DATA" \
|
|
||||||
-resultBundlePath "$result_path" \
|
|
||||||
"${extra_args[@]}" \
|
|
||||||
2>&1 | tail -30
|
|
||||||
|
|
||||||
return ${PIPESTATUS[0]}
|
return ${PIPESTATUS[0]}
|
||||||
}
|
}
|
||||||
|
|
||||||
OVERALL_START=$(date +%s)
|
OVERALL_START=$(date +%s)
|
||||||
|
|
||||||
# ── Phase 1: Seed ──────────────────────────────────────────────
|
# ── Phase 0: Smoke gate ────────────────────────────────────────
|
||||||
if [ "$SKIP_SEED" = false ]; then
|
if [ "$ONLY_PARALLEL" = false ]; then
|
||||||
phase_header "Phase 1/3: Seeding test data (sequential)"
|
phase_header "Phase 0: Smoke gate"
|
||||||
SEED_START=$(date +%s)
|
if run_xcodebuild "Smoke" \
|
||||||
|
-only-testing:"$TARGET/SmokeUITests" \
|
||||||
if run_phase "SeedTests" "${SEED_TESTS[@]}"; then
|
-only-testing:"$TARGET/AppLaunchUITests"; then
|
||||||
SEED_END=$(date +%s)
|
echo -e "${GREEN}✓ Smoke passed${RESET}"
|
||||||
echo -e "\n${GREEN}✓ Seed phase passed ($(( SEED_END - SEED_START ))s)${RESET}"
|
|
||||||
else
|
else
|
||||||
SEED_END=$(date +%s)
|
echo -e "${RED}✗ Smoke FAILED — aborting (app can't launch/log in).${RESET}"
|
||||||
echo -e "\n${RED}✗ Seed phase FAILED ($(( SEED_END - SEED_START ))s)${RESET}"
|
|
||||||
echo -e "${RED} Cannot proceed without seeded data. Aborting.${RESET}"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
[ "$ONLY_SMOKE" = true ] && exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Phase 2: Parallel Tests ───────────────────────────────────
|
# ── Phase 1: Seed ──────────────────────────────────────────────
|
||||||
phase_header "Phase 2/3: Running tests in parallel ($WORKERS workers)"
|
if [ "$SKIP_SEED" = false ]; then
|
||||||
PARALLEL_START=$(date +%s)
|
phase_header "Phase 1: Seed baseline accounts"
|
||||||
|
if run_xcodebuild "Seed" -only-testing:"$TARGET/AAA_SeedTests"; then
|
||||||
if run_phase "ParallelTests" \
|
echo -e "${GREEN}✓ Seed passed${RESET}"
|
||||||
-parallel-testing-enabled YES \
|
|
||||||
-parallel-testing-worker-count "$WORKERS" \
|
|
||||||
"${PARALLEL_TESTS[@]}"; then
|
|
||||||
PARALLEL_END=$(date +%s)
|
|
||||||
echo -e "\n${GREEN}✓ Parallel phase passed ($(( PARALLEL_END - PARALLEL_START ))s)${RESET}"
|
|
||||||
PARALLEL_PASSED=true
|
|
||||||
else
|
|
||||||
PARALLEL_END=$(date +%s)
|
|
||||||
echo -e "\n${RED}✗ Parallel phase FAILED ($(( PARALLEL_END - PARALLEL_START ))s)${RESET}"
|
|
||||||
PARALLEL_PASSED=false
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Phase 2b: Suite6 (isolated parallel) ──────────────────────
|
|
||||||
phase_header "Phase 2b: Suite6 task tests (2 workers, isolated)"
|
|
||||||
SUITE6_START=$(date +%s)
|
|
||||||
|
|
||||||
if run_phase "Suite6Tests" \
|
|
||||||
-parallel-testing-enabled YES \
|
|
||||||
-parallel-testing-worker-count 2 \
|
|
||||||
"${SUITE6_TESTS[@]}"; then
|
|
||||||
SUITE6_END=$(date +%s)
|
|
||||||
echo -e "\n${GREEN}✓ Suite6 phase passed ($(( SUITE6_END - SUITE6_START ))s)${RESET}"
|
|
||||||
SUITE6_PASSED=true
|
|
||||||
else
|
|
||||||
SUITE6_END=$(date +%s)
|
|
||||||
echo -e "\n${RED}✗ Suite6 phase FAILED ($(( SUITE6_END - SUITE6_START ))s)${RESET}"
|
|
||||||
SUITE6_PASSED=false
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Phase 3: Cleanup ──────────────────────────────────────────
|
|
||||||
if [ "$SKIP_CLEANUP" = false ]; then
|
|
||||||
phase_header "Phase 3/3: Cleaning up test data (sequential)"
|
|
||||||
CLEANUP_START=$(date +%s)
|
|
||||||
|
|
||||||
if run_phase "CleanupTests" "${CLEANUP_TESTS[@]}"; then
|
|
||||||
CLEANUP_END=$(date +%s)
|
|
||||||
echo -e "\n${GREEN}✓ Cleanup phase passed ($(( CLEANUP_END - CLEANUP_START ))s)${RESET}"
|
|
||||||
else
|
else
|
||||||
CLEANUP_END=$(date +%s)
|
echo -e "${RED}✗ Seed FAILED — aborting.${RESET}"; exit 1
|
||||||
echo -e "\n${YELLOW}⚠ Cleanup phase failed ($(( CLEANUP_END - CLEANUP_START ))s) — non-blocking${RESET}"
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Summary ───────────────────────────────────────────────────
|
# ── Phase 2: Parallel (whole target minus phase-managed) ───────
|
||||||
OVERALL_END=$(date +%s)
|
phase_header "Phase 2: Parallel suite ($WORKERS workers)"
|
||||||
TOTAL_TIME=$(( OVERALL_END - OVERALL_START ))
|
SKIP_ARGS=()
|
||||||
|
for t in "${PHASE_MANAGED[@]}"; do SKIP_ARGS+=( -skip-testing:"$t" ); done
|
||||||
|
PARALLEL_START=$(date +%s)
|
||||||
|
if run_xcodebuild "Parallel" \
|
||||||
|
-only-testing:"$TARGET" "${SKIP_ARGS[@]}" \
|
||||||
|
-parallel-testing-enabled YES -parallel-testing-worker-count "$WORKERS"; then
|
||||||
|
PARALLEL_PASSED=true; echo -e "${GREEN}✓ Parallel phase passed${RESET}"
|
||||||
|
else
|
||||||
|
PARALLEL_PASSED=false; echo -e "${RED}✗ Parallel phase FAILED${RESET}"
|
||||||
|
fi
|
||||||
|
PARALLEL_END=$(date +%s)
|
||||||
|
|
||||||
|
# ── Phase 3: Sweep ─────────────────────────────────────────────
|
||||||
|
if [ "$SKIP_CLEANUP" = false ]; then
|
||||||
|
phase_header "Phase 3: Sweep leaked accounts + data"
|
||||||
|
if run_xcodebuild "Sweep" -only-testing:"$TARGET/SuiteZZ_CleanupTests"; then
|
||||||
|
echo -e "${GREEN}✓ Sweep passed${RESET}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}⚠ Sweep failed (non-blocking)${RESET}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Summary ────────────────────────────────────────────────────
|
||||||
phase_header "Summary"
|
phase_header "Summary"
|
||||||
echo " Total time: ${TOTAL_TIME}s"
|
echo " Total time: $(( $(date +%s) - OVERALL_START ))s"
|
||||||
echo " Workers: $WORKERS"
|
echo " Parallel: $(( PARALLEL_END - PARALLEL_START ))s @ $WORKERS workers"
|
||||||
echo " Results: $RESULTS_DIR/"
|
echo " Results: $RESULTS_DIR/"
|
||||||
echo ""
|
echo ""
|
||||||
|
if [ "${PARALLEL_PASSED:-false}" = true ]; then
|
||||||
if [ "$PARALLEL_PASSED" = true ] && [ "${SUITE6_PASSED:-true}" = true ]; then
|
echo -e " ${GREEN}${BOLD}ALL TESTS PASSED${RESET}"; exit 0
|
||||||
echo -e " ${GREEN}${BOLD}ALL TESTS PASSED${RESET}"
|
|
||||||
exit 0
|
|
||||||
else
|
else
|
||||||
echo -e " ${RED}${BOLD}TESTS FAILED${RESET}"
|
echo -e " ${RED}${BOLD}TESTS FAILED${RESET} — open $RESULTS_DIR/"; exit 1
|
||||||
echo -e " Check results: open $RESULTS_DIR/"
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user