Re-architect iOS XCUITest suite: per-test isolation + domain organization
Migrate the XCUITest suite off the legacy shared-account model (and the prior Django-style auth assumptions) to a parallel-safe, domain-organized architecture, validated end-to-end against the live Kratos stack. Isolation (parallel-safe by construction): - Core/Fixtures/TestAccount.swift: each test mints its own pre-verified Kratos identity (uit_<domain>_<uuid>@test.honeydue.local), logs in, seeds under its own token, and deletes the identity in teardown (cascading all data + clearing Kratos). No shared testuser; parallel workers no longer race. - AuthenticatedUITestCase rewritten to that model (member surface preserved); adds requiresResidence / seedAccountPreconditions to seed UI-gated data BEFORE login (a fresh account is empty at login). Organization (255 tests preserved, none dropped): - 21 domain suites under Auth/ Onboarding/ Residence/ Task/ Contractor/ Document/ Sharing/ Navigation/ Smoke/ CrossCutting/ E2E/, consistent <Domain>UITests naming. Removes the Suite1..11 / AAA_ / ZZ_ / Tests/Rebuild naming chaos and the overlapping task/residence/auth suites. Runner + test plans: - run_ui_tests.sh: Smoke gate -> Seed -> Parallel(8 workers) -> Sweep. The parallel phase runs the whole target minus phase-managed suites via -skip-testing, so new suites auto-include (no hand-maintained list to drift). Drops the 2-worker cap and Suite6 isolation (isolation made them moot). - HoneyDueUITests.xctestplan skips the 4 phase-managed suites; adds Smoke.xctestplan. Kratos auth fixes folded in (login/verify/reset endpoints removed under Kratos): real Mailpit verification codes replace the obsolete fixed "123456"; teardown deletes Kratos identities; admin-panel login uses the correct seeded password. Build green; isolation, parallelism, and the precondition/sharing migrations validated against the live stack (0 leaked accounts). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
import XCTest
|
||||
|
||||
/// Captures the gitea#2 regression at the user-visible level: after onboarding
|
||||
/// (register → name residence → bulk-create tasks → land on home), tapping the
|
||||
/// residence cell shows "no tasks" even though the server has them. Restarting
|
||||
/// the app fixes it. This test reproduces the flow without an app restart and
|
||||
/// asserts that tasks render on the residence detail screen.
|
||||
///
|
||||
/// CRITICAL: this test must FAIL at the cache-unification fix's first commit and
|
||||
/// must PASS after Phase 1-3 lands. The failing assertion is pinned to a specific
|
||||
/// message so the regression is unambiguous.
|
||||
///
|
||||
/// The test deliberately does NOT visit the Tasks tab between onboarding and
|
||||
/// tapping the residence cell. Visiting the Tasks tab would prime `_allTasks` and
|
||||
/// mask the bug — the bug is that residence detail cannot recover from the
|
||||
/// empty-cache + sink-timing window on its own.
|
||||
final class OnboardingTaskCacheUITests: BaseUITestCase {
|
||||
// We need to start at the onboarding welcome screen, not the standalone
|
||||
// login screen — `completeOnboarding` would skip the entire flow.
|
||||
override var completeOnboarding: Bool { false }
|
||||
// Single test in this suite — relaunch isn't necessary, but we want a
|
||||
// clean state every time (handled by the default --reset-state).
|
||||
override var relaunchBetweenTests: Bool { true }
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
/// 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
|
||||
/// `app.staticTexts[residenceName]` if the id-based identifier doesn't
|
||||
/// resolve in time.
|
||||
private let residenceName = "UI Test Property"
|
||||
|
||||
// MARK: - Test
|
||||
|
||||
/// Reproduces gitea#2: tasks created via the onboarding bulk endpoint
|
||||
/// must appear on the residence detail screen without an app restart
|
||||
/// and without first visiting the Tasks tab.
|
||||
@MainActor
|
||||
func test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart() throws {
|
||||
// Step 1 — Register a fresh user via the onboarding Start Fresh flow.
|
||||
// The flow is: Welcome → ValueProps → NameResidence → CreateAccount
|
||||
// → VerifyEmail → HomeProfile → FirstTask → main app.
|
||||
let createAccount = TestFlows.navigateStartFreshToCreateAccount(
|
||||
app: app,
|
||||
residenceName: residenceName
|
||||
)
|
||||
createAccount.waitForLoad(timeout: navigationTimeout)
|
||||
|
||||
// Step 2 — Fill the create-account form. We address the onboarding
|
||||
// form's fields (not the standalone register sheet's fields).
|
||||
let creds = TestAccountManager.uniqueCredentials(prefix: "gitea2")
|
||||
|
||||
createAccount.expandEmailSignup()
|
||||
|
||||
// Use the same focusAndType path that OnboardingTests uses — it
|
||||
// already handles SecureTextField + iOS strong-password panel.
|
||||
// Under --ui-testing, OrganicOnboardingSecureField defaults to
|
||||
// visibility=ON (renders as TextField) to dodge the iOS 26 SecureField
|
||||
// keyboard bug. Query textFields, not secureTextFields.
|
||||
let usernameField = app.textFields[AccessibilityIdentifiers.Onboarding.usernameField]
|
||||
let emailField = app.textFields[AccessibilityIdentifiers.Onboarding.emailField]
|
||||
let passwordField = app.textFields[AccessibilityIdentifiers.Onboarding.passwordField]
|
||||
let confirmPasswordField = app.textFields[AccessibilityIdentifiers.Onboarding.confirmPasswordField]
|
||||
|
||||
usernameField.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||
usernameField.focusAndType(creds.username, app: app)
|
||||
emailField.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||
emailField.focusAndType(creds.email, app: app)
|
||||
passwordField.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||
passwordField.focusAndType(creds.password, app: app)
|
||||
confirmPasswordField.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||
confirmPasswordField.focusAndType(creds.password, app: app)
|
||||
|
||||
let createAccountButton = app.descendants(matching: .any)
|
||||
.matching(identifier: AccessibilityIdentifiers.Onboarding.createAccountButton)
|
||||
.firstMatch
|
||||
createAccountButton.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||
createAccountButton.forceTap()
|
||||
|
||||
// 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)
|
||||
verification.waitForLoad(timeout: loginTimeout)
|
||||
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
|
||||
// code. If a verify button still exists and a code field is still
|
||||
// visible, tap it to push past edge cases.
|
||||
if verification.codeField.waitForExistence(timeout: 1) && verification.verifyButton.exists {
|
||||
verification.submitCode()
|
||||
}
|
||||
|
||||
// Step 4 — Skip the home-profile step. The home-profile screen has
|
||||
// its own Skip button (the shared onboarding skip in the nav bar)
|
||||
// which routes to the first-task step without making us pick climate
|
||||
// / appliance fields.
|
||||
let onboardingSkipButton = app.buttons[AccessibilityIdentifiers.Onboarding.skipButton]
|
||||
XCTAssertTrue(
|
||||
onboardingSkipButton.waitForExistence(timeout: loginTimeout),
|
||||
"Onboarding skip button should exist on the home-profile screen"
|
||||
)
|
||||
// The skip button can briefly be non-hittable during the screen-in
|
||||
// transition. Use forceTap() to bypass the strict hittable check.
|
||||
// We confirmed existence above; if the tap doesn't land on the
|
||||
// intended button the next assertion (Browse All tab) will catch it.
|
||||
onboardingSkipButton.forceTap()
|
||||
|
||||
// Step 5 — Switch to the "Browse All" tab on the First-Task screen.
|
||||
// "For You" suggestions can be empty for a fresh residence with no
|
||||
// home-profile data, so deterministic browsing is required.
|
||||
// The tab bar is a SwiftUI segmented Picker — its segments are
|
||||
// exposed as buttons with the segment label, regardless of an
|
||||
// identifier on the parent.
|
||||
let browseAllTab = app.buttons["Browse All"]
|
||||
XCTAssertTrue(
|
||||
browseAllTab.waitForExistence(timeout: loginTimeout),
|
||||
"Browse All tab should appear on the first-task screen"
|
||||
)
|
||||
browseAllTab.tap()
|
||||
|
||||
// Step 6 — Pick 3 templates by accessibility identifier prefix.
|
||||
// The catalog is loaded via GET /api/tasks/templates/grouped/, so
|
||||
// we need to wait for at least one row to render before tapping.
|
||||
let templateRowQuery = app.buttons.matching(
|
||||
NSPredicate(format: "identifier BEGINSWITH %@",
|
||||
AccessibilityIdentifiers.Onboarding.templateRowPrefix)
|
||||
)
|
||||
|
||||
// Wait for the catalog to load. The grouped endpoint returns first
|
||||
// category expanded by default in the view, so rows should appear
|
||||
// shortly after Browse All becomes visible. Network call: 10s.
|
||||
let firstRow = templateRowQuery.element(boundBy: 0)
|
||||
XCTAssertTrue(
|
||||
firstRow.waitForExistence(timeout: loginTimeout),
|
||||
"At least one template row must render on the Browse All tab. " +
|
||||
"If no rows appear, the catalog endpoint failed — bug repro is invalid."
|
||||
)
|
||||
|
||||
// Tap the first 3 visible rows. Some categories may collapse rows
|
||||
// we never see; we only need at least 1, so the floor is 1 with a
|
||||
// soft cap of 3.
|
||||
let rowCount = templateRowQuery.count
|
||||
let toPick = min(3, rowCount)
|
||||
XCTAssertGreaterThanOrEqual(toPick, 1, "Expected at least one template row")
|
||||
for index in 0..<toPick {
|
||||
let row = templateRowQuery.element(boundBy: index)
|
||||
row.waitUntilHittable(timeout: navigationTimeout)
|
||||
row.tap()
|
||||
}
|
||||
|
||||
// Step 7 — Submit the bulk-create. This is the
|
||||
// POST /api/tasks/bulk/ call that produces the inconsistent client
|
||||
// cache state at the heart of gitea#2.
|
||||
let submitButton = app.buttons[AccessibilityIdentifiers.Onboarding.submitTasksButton]
|
||||
XCTAssertTrue(
|
||||
submitButton.waitForExistence(timeout: navigationTimeout),
|
||||
"Submit-tasks button must exist on the first-task screen"
|
||||
)
|
||||
submitButton.waitUntilHittable(timeout: navigationTimeout).tap()
|
||||
|
||||
// Step 8 — Land on the main app (Residences tab is selected by
|
||||
// default). CRITICAL: do NOT tap the Tasks tab. Tapping it would
|
||||
// populate `_allTasks` and mask the bug.
|
||||
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
let reachedMain = mainTabs.waitForExistence(timeout: loginTimeout)
|
||||
|| tabBar.waitForExistence(timeout: navigationTimeout)
|
||||
XCTAssertTrue(reachedMain, "App should reach main tabs after onboarding submit")
|
||||
|
||||
// Step 9 — Tap the residence cell directly. Prefer the
|
||||
// identifier-prefix match for any cell; fall back to the static
|
||||
// text match by name.
|
||||
let residenceCellQuery = app.buttons.matching(
|
||||
NSPredicate(format: "identifier BEGINSWITH %@",
|
||||
AccessibilityIdentifiers.Residence.cellPrefix)
|
||||
)
|
||||
let residenceCell = residenceCellQuery.firstMatch
|
||||
if residenceCell.waitForExistence(timeout: navigationTimeout) && residenceCell.isHittable {
|
||||
residenceCell.tap()
|
||||
} else {
|
||||
// Fallback: tap the static text inside the card. The
|
||||
// NavigationLink wraps the entire card so a tap on the name
|
||||
// still routes into the detail view.
|
||||
let residenceText = app.staticTexts[residenceName]
|
||||
XCTAssertTrue(
|
||||
residenceText.waitForExistence(timeout: navigationTimeout),
|
||||
"Residence cell or name '\(residenceName)' must exist on the residences list"
|
||||
)
|
||||
residenceText.tap()
|
||||
}
|
||||
|
||||
// Step 10 — THE BUG ASSERTION. With the bug present:
|
||||
// - `_allTasks` is null on the client (never primed).
|
||||
// - `_tasksByResidence[id]` is empty (cache miss).
|
||||
// - residence detail attempts to load, hits the iOS Combine sink
|
||||
// timing window, and renders the empty state.
|
||||
// With the fix, both `_allTasks` is populated by `bulkCreateTasks`
|
||||
// and residence detail filters from it in-memory, so the empty
|
||||
// state must not appear.
|
||||
let taskRowQuery = app.descendants(matching: .any).matching(
|
||||
NSPredicate(format: "identifier BEGINSWITH %@",
|
||||
AccessibilityIdentifiers.Task.rowPrefix)
|
||||
)
|
||||
let firstTaskRow = taskRowQuery.element(boundBy: 0)
|
||||
let anyTaskAppeared = firstTaskRow.waitForExistence(timeout: 10)
|
||||
|
||||
let emptyState = app.otherElements[AccessibilityIdentifiers.Task.noTasksLabel]
|
||||
let emptyStateVisible = emptyState.exists
|
||||
|
||||
// Pin the failure message so the bug-capture is unambiguous. This
|
||||
// is the assertion that should FAIL at this commit and PASS after
|
||||
// the cache fix lands. Don't change the message — Task 12 grep's
|
||||
// for it.
|
||||
XCTAssertTrue(
|
||||
anyTaskAppeared && !emptyStateVisible,
|
||||
"Tasks created during onboarding must appear on residence detail without restart (gitea#2)"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user