Files
honeyDueKMP/iosApp/HoneyDueUITests/Suite11_TaskCacheRegressionTests.swift
T
Trey t 03a9dfa0de fix: 2 latent iOS bugs that blocked Suite11 XCUITest from running end-to-end
The XCUITest for gitea#2 (Suite11) was failing for reasons unrelated
to the cache fix — actual bugs in the registration/onboarding code
that real users probably hit too:

1. OrganicOnboardingSecureField + iOS 26 SecureField/autofill bug
   On iOS 26, tapping a SwiftUI SecureField with .textContentType(.password)
   doesn't reliably bring up the keyboard — the strong-password autofill
   panel steals focus. Fix: under --ui-testing, default the visibility
   toggle to ON so the field renders as a plain TextField (which has
   reliable focus). Real users are unaffected.

2. Email registration didn't propagate auth state
   Apple/Google sign-in paths called AuthenticationManager.shared.login(),
   but email-registration's onChange(viewModel.isRegistered) handler did
   not. As a result, AuthenticationManager.isAuthenticated stayed false
   through the entire onboarding flow. OnboardingState.completeOnboarding
   has an auth guard that silently no-ops when isAuthenticated is false,
   leaving users stuck on the firstTask screen forever (until a
   scenePhase event triggered checkAuthenticationStatus to re-sync from
   DataManager). Fix: call authManager.login(verified: false) when
   isRegistered flips true.

Suite11 now passes 2/2 in 96-107s, exercising the full onboarding flow
and asserting tasks appear on residence detail without restart.

Refs gitea#2
2026-05-01 18:35:40 -07:00

222 lines
11 KiB
Swift

import XCTest
/// Suite 11 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 Suite11_TaskCacheRegressionTests: 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
/// 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
/// 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 debug fixed code.
let verification = VerificationScreen(app: app)
verification.waitForLoad(timeout: loginTimeout)
verification.enterCode(debugVerificationCode)
// 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)"
)
}
}