Files
honeyDueKMP/iosApp/HoneyDueUITests/Onboarding/OnboardingUITests.swift
T
Trey T c52ce4d497 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>
2026-06-05 16:26:50 -05:00

364 lines
18 KiB
Swift

import XCTest
/// 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 }
// MARK: - From OnboardingTests
func testF101_StartFreshFlowReachesCreateAccount() {
let createAccount = TestFlows.navigateStartFreshToCreateAccount(app: app, residenceName: "Blueprint House")
createAccount.waitForLoad(timeout: defaultTimeout)
}
func testF102_JoinExistingFlowGoesToCreateAccount() {
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad()
welcome.tapJoinExisting()
let createAccount = OnboardingCreateAccountScreen(app: app)
createAccount.waitForLoad(timeout: defaultTimeout)
}
func testF103_BackNavigationFromNameResidenceReturnsToValueProps() {
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad()
welcome.tapStartFresh()
let valueProps = OnboardingValuePropsScreen(app: app)
valueProps.waitForLoad()
valueProps.tapContinue()
let nameResidence = OnboardingNameResidenceScreen(app: app)
nameResidence.waitForLoad()
nameResidence.tapBack()
XCTAssertTrue(app.otherElements[UITestID.Root.onboarding].waitForExistence(timeout: defaultTimeout))
}
func testF104_SkipOnValuePropsMovesToNameResidence() {
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad()
welcome.tapStartFresh()
let valueProps = OnboardingValuePropsScreen(app: app)
valueProps.waitForLoad()
let skipButton = app.buttons[UITestID.Onboarding.skipButton]
skipButton.waitForExistenceOrFail(timeout: defaultTimeout)
skipButton.forceTap()
let nameResidence = OnboardingNameResidenceScreen(app: app)
nameResidence.waitForLoad(timeout: defaultTimeout)
}
// MARK: - Additional Onboarding Coverage
func testF105_JoinExistingFlowSkipsValuePropsAndNameResidence() {
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad()
welcome.tapJoinExisting()
let createAccount = OnboardingCreateAccountScreen(app: app)
createAccount.waitForLoad(timeout: defaultTimeout)
// Verify value props and name residence screens were NOT shown
let valuePropsTitle = app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.valuePropsContainer).firstMatch
XCTAssertFalse(valuePropsTitle.exists, "Value props should be skipped for Join Existing flow")
let nameResidenceTitle = app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.nameResidenceTitle).firstMatch
XCTAssertFalse(nameResidenceTitle.exists, "Name residence should be skipped for Join Existing flow")
}
func testF106_NameResidenceFieldAcceptsInput() {
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad()
welcome.tapStartFresh()
let valueProps = OnboardingValuePropsScreen(app: app)
valueProps.waitForLoad()
valueProps.tapContinue()
let nameResidence = OnboardingNameResidenceScreen(app: app)
nameResidence.waitForLoad()
let nameField = app.textFields[UITestID.Onboarding.residenceNameField]
nameField.waitUntilHittable(timeout: defaultTimeout)
nameField.focusAndType("My Test Home", app: app)
XCTAssertEqual(nameField.value as? String, "My Test Home", "Residence name field should accept and display typed text")
}
func testF107_ProgressIndicatorVisibleDuringOnboarding() {
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad()
welcome.tapStartFresh()
let valueProps = OnboardingValuePropsScreen(app: app)
valueProps.waitForLoad()
let progress = app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.progressIndicator).firstMatch
XCTAssertTrue(progress.waitForExistence(timeout: defaultTimeout), "Progress indicator should be visible during onboarding flow")
}
func testF108_BackFromCreateAccountNavigatesToPreviousStep() {
let createAccount = TestFlows.navigateStartFreshToCreateAccount(app: app, residenceName: "Back Test")
createAccount.waitForLoad(timeout: defaultTimeout)
let backButton = app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.backButton).firstMatch
backButton.waitForExistenceOrFail(timeout: defaultTimeout)
backButton.forceTap()
// Should return to name residence step
let nameResidence = OnboardingNameResidenceScreen(app: app)
nameResidence.waitForLoad(timeout: defaultTimeout)
}
// MARK: - ONB-005: Residence Bootstrap
/// ONB-005: Start Fresh creates a residence automatically after email verification.
/// Drives the full Start Fresh flow welcome value props name residence
/// create account verify email then confirms the app lands on main tabs,
/// which indicates the residence was bootstrapped during onboarding.
func testF110_startFreshCreatesResidenceAfterVerification() throws {
try? XCTSkipIf(
!TestAccountAPIClient.isBackendReachable(),
"Local backend is not reachable — skipping ONB-005"
)
// Generate unique credentials so we don't collide with other test runs
let creds = TestAccountManager.uniqueCredentials(prefix: "onb005")
let uniqueResidenceName = "ONB005 Home \(Int(Date().timeIntervalSince1970))"
// Step 1: Navigate Start Fresh flow to the Create Account screen
let createAccount = TestFlows.navigateStartFreshToCreateAccount(app: app, residenceName: uniqueResidenceName)
createAccount.waitForLoad(timeout: defaultTimeout)
// Step 2: Expand the email sign-up form and fill it in
createAccount.expandEmailSignup()
// Use the Onboarding-specific field identifiers for the create account form.
// 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 onbEmailField = app.textFields[AccessibilityIdentifiers.Onboarding.emailField]
let onbPasswordField = app.textFields[AccessibilityIdentifiers.Onboarding.passwordField]
let onbConfirmPasswordField = app.textFields[AccessibilityIdentifiers.Onboarding.confirmPasswordField]
onbUsernameField.waitForExistenceOrFail(timeout: defaultTimeout)
onbUsernameField.focusAndType(creds.username, app: app)
onbEmailField.waitForExistenceOrFail(timeout: defaultTimeout)
onbEmailField.focusAndType(creds.email, app: app)
onbPasswordField.waitForExistenceOrFail(timeout: defaultTimeout)
onbPasswordField.focusAndType(creds.password, app: app)
onbConfirmPasswordField.waitForExistenceOrFail(timeout: defaultTimeout)
onbConfirmPasswordField.focusAndType(creds.password, app: app)
// Step 3: Submit the create account form
let createAccountButton = app.descendants(matching: .any)
.matching(identifier: UITestID.Onboarding.createAccountButton).firstMatch
createAccountButton.waitForExistenceOrFail(timeout: defaultTimeout)
createAccountButton.forceTap()
// Step 4: Verify email with the debug code
let verificationScreen = VerificationScreen(app: app)
// If the create account button was disabled (password fields didn't fill),
// we won't reach verification. Check before asserting.
let verificationLoaded = verificationScreen.codeField.waitForExistence(timeout: loginTimeout)
guard verificationLoaded else {
// Check if the create account button is still visible (form submission failed)
if createAccountButton.exists {
throw XCTSkip("Create account form submission did not proceed to verification — password fields may not have received input")
}
XCTFail("Expected verification screen to load")
return
}
// The app's onboarding registration uses Kratos's real email verification
// 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)
// The Onboarding Verify button is disabled until the 6-digit code commits;
// wait for it to enable, then tap. Fall back to the generic submit helper.
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 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)
|| tabBar.waitForExistence(timeout: 5)
let onbVisible = app.otherElements[UITestID.Root.onboarding].exists
let firstTaskVisible = firstTaskTitle.exists
let diag = "onboarding=\(onbVisible) firstTask=\(firstTaskVisible)"
XCTAssertTrue(
reachedMain,
"App should reach main tabs after Start Fresh onboarding + email verification, " +
"confirming the residence '\(uniqueResidenceName)' was created automatically. Stuck: \(diag)"
)
}
// MARK: - ONB-008: Completion Persistence
/// ONB-008: Completing onboarding persists the completion flag so the next
/// launch bypasses onboarding entirely and goes directly to login or main tabs.
func testF111_completedOnboardingBypassedOnRelaunch() {
try? XCTSkipIf(
!TestAccountAPIClient.isBackendReachable(),
"Local backend is not reachable — skipping ONB-008"
)
// Step 1: Complete onboarding via the Join Existing path (quickest path to main tabs).
// Navigate to the create account screen which marks the onboarding intent as started.
// Then use a pre-seeded account so we can reach main tabs without creating a new account.
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad()
welcome.tapAlreadyHaveAccount()
// Log in with the seeded account to complete onboarding and reach main tabs
let login = LoginScreenObject(app: app)
// The login sheet may take time to appear after onboarding transition
let loginFieldAppeared = app.textFields[UITestID.Auth.usernameField].waitForExistence(timeout: loginTimeout)
guard loginFieldAppeared else {
// If already on main tabs (persisted session), skip login
if app.tabBars.firstMatch.exists { /* continue to Step 2 */ }
else { XCTFail("Login screen did not appear after tapping Already Have Account"); return }
return
}
// Kratos uses the EMAIL as the login identifier (no username trait).
login.enterUsername("admin@honeydue.com")
login.enterPassword("Test1234")
let loginButton = app.buttons[UITestID.Auth.loginButton]
loginButton.waitUntilHittable(timeout: defaultTimeout).tap()
// Wait for main tabs this confirms onboarding is considered complete
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
let tabBar = app.tabBars.firstMatch
let reachedMain = mainTabs.waitForExistence(timeout: loginTimeout)
|| tabBar.waitForExistence(timeout: 5)
XCTAssertTrue(reachedMain, "Should reach main tabs after first login to establish completed-onboarding state")
// Step 2: Terminate the app
app.terminate()
// Step 3: Relaunch WITHOUT --reset-state so the onboarding-completed flag is preserved.
// This simulates a real app restart where the user should NOT see onboarding again.
app.launchArguments = [
"--ui-testing",
"--disable-animations"
// NOTE: intentionally omitting --reset-state
]
app.launch()
app.otherElements[UITestID.Root.ready].waitForExistenceOrFail(timeout: defaultTimeout)
// Step 4: The app should NOT show the onboarding welcome screen.
// It should land on the login screen (token expired/missing) or main tabs
// (if the auth token persisted). Either outcome is valid what matters is
// that the onboarding root is NOT shown.
let onboardingWelcomeTitle = app.descendants(matching: .any)
.matching(identifier: UITestID.Onboarding.welcomeTitle).firstMatch
let startFreshButton = app.descendants(matching: .any)
.matching(identifier: UITestID.Onboarding.startFreshButton).firstMatch
// Wait for the app to settle on its landing screen
let loginField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
_ = loginField.waitForExistence(timeout: defaultTimeout)
|| mainTabs.waitForExistence(timeout: 3)
|| tabBar.waitForExistence(timeout: 3)
let isShowingOnboarding = onboardingWelcomeTitle.exists || startFreshButton.exists
XCTAssertFalse(
isShowingOnboarding,
"App should NOT show the onboarding welcome screen after onboarding was completed on a previous launch"
)
// Additionally verify the app landed on a valid post-onboarding screen
let isOnLogin = loginField.waitForExistence(timeout: defaultTimeout)
let isOnMain = mainTabs.exists || tabBar.exists
XCTAssertTrue(
isOnLogin || isOnMain,
"After relaunch without reset, app should show login or main tabs — not onboarding"
)
}
// 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)
}
}