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..