Rearchitect UI test suite for complete, non-flaky coverage against live API
- Migrate Suite4-10, SmokeTests, NavigationCriticalPathTests to AuthenticatedTestCase with seeded admin account and real backend login - Add 34 accessibility identifiers across 11 app views (task completion, profile, notifications, theme, join residence, manage users, forms) - Create FeatureCoverageTests (14 tests) covering previously untested features: profile edit, theme selection, notification prefs, task completion, manage users, join residence, task templates - Create MultiUserSharingTests (18 API tests) and MultiUserSharingUITests (8 XCUI tests) for full cross-user residence sharing lifecycle - Add cleanup infrastructure: SuiteZZ_CleanupTests auto-wipes test data after runs, cleanup_test_data.sh script for manual reset via admin API - Add share code API methods to TestAccountAPIClient (generateShareCode, joinWithCode, getShareCode, listResidenceUsers, removeUser) - Fix app bugs found by tests: - ResidencesListView join callback now uses forceRefresh:true - APILayer invalidates task cache when residence count changes - AllTasksView auto-reloads tasks when residence list changes - Fix test quality: keyboard focus waits, Save/Add button label matching, Documents tab label (Docs), remove API verification from UI tests - DataLayerTests and PasswordResetTests now verify through UI, not API calls Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,14 @@ class AuthenticatedTestCase: BaseUITestCase {
|
||||
/// Override to `false` to skip driving the app through the login UI.
|
||||
var performUILogin: Bool { true }
|
||||
|
||||
/// Skip onboarding so the app goes straight to the login screen.
|
||||
override var completeOnboarding: Bool { true }
|
||||
|
||||
/// Don't reset state — DataManager.shared.clear() during app init triggers
|
||||
/// a Kotlin/Native SIGKILL crash on the simulator. Since we use the seeded
|
||||
/// admin account and loginViaUI() handles persisted sessions, this is safe.
|
||||
override var includeResetStateLaunchArgument: Bool { false }
|
||||
|
||||
/// No mock auth - we're testing against the real backend.
|
||||
override var additionalLaunchArguments: [String] { [] }
|
||||
|
||||
@@ -71,6 +79,11 @@ class AuthenticatedTestCase: BaseUITestCase {
|
||||
// Launch the app (calls BaseUITestCase.setUpWithError which launches and waits for ready)
|
||||
try super.setUpWithError()
|
||||
|
||||
// Tap somewhere on the app to trigger any pending interruption monitors
|
||||
// (BaseUITestCase already adds an addUIInterruptionMonitor in setUp)
|
||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||
sleep(1)
|
||||
|
||||
// Drive the UI through login if needed
|
||||
if performUILogin {
|
||||
loginViaUI()
|
||||
@@ -85,25 +98,87 @@ class AuthenticatedTestCase: BaseUITestCase {
|
||||
|
||||
// MARK: - UI Login
|
||||
|
||||
/// Navigate from onboarding welcome → login screen → type credentials → wait for main tabs.
|
||||
/// Navigate to login screen → type credentials → wait for main tabs.
|
||||
func loginViaUI() {
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
// If already on main tabs (persisted session from previous test), skip login.
|
||||
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
if mainTabs.waitForExistence(timeout: 3) || tabBar.waitForExistence(timeout: 2) {
|
||||
return
|
||||
}
|
||||
|
||||
// With --complete-onboarding the app should land on login directly.
|
||||
// Use ensureOnLoginScreen as a robust fallback that handles any state.
|
||||
let usernameField = app.textFields[UITestID.Auth.usernameField]
|
||||
if !usernameField.waitForExistence(timeout: 10) {
|
||||
UITestHelpers.ensureOnLoginScreen(app: app)
|
||||
}
|
||||
|
||||
let login = LoginScreenObject(app: app)
|
||||
login.waitForLoad(timeout: defaultTimeout)
|
||||
login.enterUsername(session.username)
|
||||
login.enterPassword(session.password)
|
||||
|
||||
// Tap the login button
|
||||
let loginButton = app.buttons[UITestID.Auth.loginButton]
|
||||
loginButton.waitUntilHittable(timeout: defaultTimeout).tap()
|
||||
// Try tapping the keyboard "Go" button first (triggers onSubmit which logs in)
|
||||
let goButton = app.keyboards.buttons["Go"]
|
||||
let returnButton = app.keyboards.buttons["Return"]
|
||||
if goButton.waitForExistence(timeout: 3) && goButton.isHittable {
|
||||
goButton.tap()
|
||||
} else if returnButton.exists && returnButton.isHittable {
|
||||
returnButton.tap()
|
||||
} else {
|
||||
// Dismiss keyboard by tapping empty area, then tap login button
|
||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)).tap()
|
||||
sleep(1)
|
||||
|
||||
let loginButton = app.buttons[UITestID.Auth.loginButton]
|
||||
if loginButton.waitForExistence(timeout: defaultTimeout) {
|
||||
// Wait until truly hittable (not behind keyboard)
|
||||
let hittable = NSPredicate(format: "exists == true AND hittable == true")
|
||||
let exp = XCTNSPredicateExpectation(predicate: hittable, object: loginButton)
|
||||
_ = XCTWaiter().wait(for: [exp], timeout: 10)
|
||||
loginButton.forceTap()
|
||||
} else {
|
||||
XCTFail("Login button not found")
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for either main tabs or verification screen
|
||||
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
|
||||
let deadline = Date().addingTimeInterval(longTimeout)
|
||||
var checkedForError = false
|
||||
while Date() < deadline {
|
||||
if mainTabs.exists || tabBar.exists {
|
||||
return
|
||||
}
|
||||
|
||||
// After a few seconds, check for login error messages
|
||||
if !checkedForError {
|
||||
sleep(3)
|
||||
checkedForError = true
|
||||
|
||||
// Check if we're still on the login screen (login failed)
|
||||
if usernameField.exists {
|
||||
// Look for error messages
|
||||
let errorTexts = app.staticTexts.allElementsBoundByIndex.filter {
|
||||
let label = $0.label.lowercased()
|
||||
return label.contains("error") || label.contains("invalid") ||
|
||||
label.contains("failed") || label.contains("incorrect") ||
|
||||
label.contains("not authenticated") || label.contains("wrong")
|
||||
}
|
||||
if !errorTexts.isEmpty {
|
||||
let errorMsg = errorTexts.map { $0.label }.joined(separator: ", ")
|
||||
XCTFail("Login failed with error: \(errorMsg)")
|
||||
return
|
||||
}
|
||||
|
||||
// No error visible but still on login — try tapping login again
|
||||
let retryLoginButton = app.buttons[UITestID.Auth.loginButton]
|
||||
if retryLoginButton.exists {
|
||||
retryLoginButton.forceTap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for email verification gate - if we hit it, enter the debug code
|
||||
let verificationScreen = VerificationScreen(app: app)
|
||||
if verificationScreen.codeField.exists {
|
||||
@@ -117,24 +192,67 @@ class AuthenticatedTestCase: BaseUITestCase {
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||
}
|
||||
|
||||
XCTFail("Failed to reach main app after login. Debug tree:\n\(app.debugDescription)")
|
||||
// Capture what's on screen for debugging
|
||||
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||
attachment.name = "LoginFailure"
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
|
||||
let visibleTexts = app.staticTexts.allElementsBoundByIndex.prefix(15).map { $0.label }
|
||||
let visibleButtons = app.buttons.allElementsBoundByIndex.prefix(10).map { $0.identifier.isEmpty ? $0.label : $0.identifier }
|
||||
XCTFail("Failed to reach main app after login. Visible texts: \(visibleTexts). Buttons: \(visibleButtons)")
|
||||
}
|
||||
|
||||
// MARK: - Tab Navigation
|
||||
|
||||
/// Map from identifier suffix to the actual tab bar label (handles mismatches like "Documents" → "Docs")
|
||||
private static let tabLabelMap: [String: String] = [
|
||||
"Documents": "Docs"
|
||||
]
|
||||
|
||||
func navigateToTab(_ tab: String) {
|
||||
let tabButton = app.buttons[tab]
|
||||
if tabButton.waitForExistence(timeout: defaultTimeout) {
|
||||
tabButton.forceTap()
|
||||
} else {
|
||||
// Fallback: search tab bar buttons by label
|
||||
let label = tab.replacingOccurrences(of: "TabBar.", with: "")
|
||||
let byLabel = app.tabBars.buttons.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] %@", label)
|
||||
).firstMatch
|
||||
byLabel.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
byLabel.forceTap()
|
||||
// With .sidebarAdaptable tab style, there can be duplicate buttons.
|
||||
// Always use the tab bar's buttons directly to avoid ambiguity.
|
||||
let label = tab.replacingOccurrences(of: "TabBar.", with: "")
|
||||
|
||||
// Try exact match first
|
||||
let tabBarButton = app.tabBars.firstMatch.buttons[label]
|
||||
if tabBarButton.waitForExistence(timeout: defaultTimeout) {
|
||||
tabBarButton.tap()
|
||||
// Verify the tap took effect by checking the tab is selected
|
||||
if !tabBarButton.waitForExistence(timeout: 2) || !tabBarButton.isSelected {
|
||||
// Retry - tap the app to trigger any interruption monitors, then retry
|
||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||
sleep(1)
|
||||
tabBarButton.tap()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Try mapped label (e.g. "Documents" → "Docs")
|
||||
if let mappedLabel = Self.tabLabelMap[label] {
|
||||
let mappedButton = app.tabBars.firstMatch.buttons[mappedLabel]
|
||||
if mappedButton.waitForExistence(timeout: 5) {
|
||||
mappedButton.tap()
|
||||
if !mappedButton.isSelected {
|
||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||
sleep(1)
|
||||
mappedButton.tap()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: search by partial match
|
||||
let byLabel = app.tabBars.firstMatch.buttons.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] %@", label)
|
||||
).firstMatch
|
||||
if byLabel.waitForExistence(timeout: 5) {
|
||||
byLabel.tap()
|
||||
return
|
||||
}
|
||||
|
||||
XCTFail("Could not find tab '\(label)' in tab bar")
|
||||
}
|
||||
|
||||
func navigateToResidences() {
|
||||
@@ -156,4 +274,32 @@ class AuthenticatedTestCase: BaseUITestCase {
|
||||
func navigateToProfile() {
|
||||
navigateToTab(AccessibilityIdentifiers.Navigation.profileTab)
|
||||
}
|
||||
|
||||
// MARK: - Pull to Refresh
|
||||
|
||||
/// Perform a pull-to-refresh gesture on the current screen's scrollable content.
|
||||
/// Use after navigating to a tab when data was seeded via API after login.
|
||||
func pullToRefresh() {
|
||||
// SwiftUI List/Form uses UICollectionView internally
|
||||
let collectionView = app.collectionViews.firstMatch
|
||||
let scrollView = app.scrollViews.firstMatch
|
||||
let listElement = collectionView.exists ? collectionView : scrollView
|
||||
|
||||
guard listElement.waitForExistence(timeout: 5) else { return }
|
||||
|
||||
let start = listElement.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15))
|
||||
let end = listElement.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85))
|
||||
start.press(forDuration: 0.3, thenDragTo: end)
|
||||
sleep(3) // wait for refresh to complete
|
||||
}
|
||||
|
||||
/// Perform pull-to-refresh repeatedly until a target element appears or max retries reached.
|
||||
func pullToRefreshUntilVisible(_ element: XCUIElement, maxRetries: Int = 3) {
|
||||
for _ in 0..<maxRetries {
|
||||
if element.waitForExistence(timeout: 3) { return }
|
||||
pullToRefresh()
|
||||
}
|
||||
// Final wait after last refresh
|
||||
_ = element.waitForExistence(timeout: 5)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,16 +8,36 @@ class BaseUITestCase: XCTestCase {
|
||||
let longTimeout: TimeInterval = 30
|
||||
|
||||
var includeResetStateLaunchArgument: Bool { true }
|
||||
/// Override to `true` in tests that need the standalone login screen
|
||||
/// (skips onboarding). Default is `false` so tests that navigate from
|
||||
/// onboarding or test onboarding screens work without extra config.
|
||||
var completeOnboarding: Bool { false }
|
||||
var additionalLaunchArguments: [String] { [] }
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
XCUIDevice.shared.orientation = .portrait
|
||||
|
||||
// Auto-dismiss any system alerts (notifications, tracking, etc.)
|
||||
addUIInterruptionMonitor(withDescription: "System Alert") { alert in
|
||||
let buttons = ["Allow", "OK", "Don't Allow", "Not Now", "Dismiss", "Allow While Using App"]
|
||||
for label in buttons {
|
||||
let button = alert.buttons[label]
|
||||
if button.exists {
|
||||
button.tap()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var launchArguments = [
|
||||
"--ui-testing",
|
||||
"--disable-animations"
|
||||
]
|
||||
if completeOnboarding {
|
||||
launchArguments.append("--complete-onboarding")
|
||||
}
|
||||
if includeResetStateLaunchArgument {
|
||||
launchArguments.append("--reset-state")
|
||||
}
|
||||
@@ -25,7 +45,7 @@ class BaseUITestCase: XCTestCase {
|
||||
app.launchArguments = launchArguments
|
||||
|
||||
app.launch()
|
||||
app.otherElements["ui.app.ready"].waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
app.otherElements["ui.app.ready"].waitForExistenceOrFail(timeout: longTimeout)
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
|
||||
@@ -106,7 +106,7 @@ struct ResidenceListScreen {
|
||||
let app: XCUIApplication
|
||||
|
||||
var addButton: XCUIElement {
|
||||
let byID = app.buttons[AccessibilityIdentifiers.Residence.addButton]
|
||||
let byID = app.buttons[AccessibilityIdentifiers.Residence.addButton].firstMatch
|
||||
if byID.exists { return byID }
|
||||
return app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Add'")).firstMatch
|
||||
}
|
||||
|
||||
@@ -215,7 +215,18 @@ struct LoginScreenObject {
|
||||
}
|
||||
|
||||
func tapSignUp() {
|
||||
signUpButton.waitUntilHittable(timeout: 10).tap()
|
||||
signUpButton.waitForExistenceOrFail(timeout: 10)
|
||||
if signUpButton.isHittable {
|
||||
signUpButton.tap()
|
||||
} else {
|
||||
// Button may be off-screen in the ScrollView — scroll to reveal it
|
||||
app.swipeUp()
|
||||
if signUpButton.isHittable {
|
||||
signUpButton.tap()
|
||||
} else {
|
||||
signUpButton.forceTap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tapForgotPassword() {
|
||||
|
||||
57
iosApp/HoneyDueUITests/Framework/SeededTestData.swift
Normal file
57
iosApp/HoneyDueUITests/Framework/SeededTestData.swift
Normal file
@@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
|
||||
/// Shared constants and mutable state for baseline data seeded by `Suite00_SeedTests`.
|
||||
///
|
||||
/// Other test suites can reference these values to locate pre-existing backend
|
||||
/// entities without hard-coding names or IDs in multiple places.
|
||||
enum SeededTestData {
|
||||
|
||||
// MARK: - Accounts
|
||||
|
||||
enum TestUser {
|
||||
static let username = "testuser"
|
||||
static let email = "test@example.com"
|
||||
static let password = "TestPass123!"
|
||||
}
|
||||
|
||||
enum AdminUser {
|
||||
static let username = "admin"
|
||||
static let email = "admin@example.com"
|
||||
static let password = "test1234"
|
||||
}
|
||||
|
||||
// MARK: - Entities (populated by Suite00)
|
||||
|
||||
enum Residence {
|
||||
static let name = "Seed Home"
|
||||
/// Set by Suite00 after creation/lookup. `-1` means not yet seeded.
|
||||
static var id: Int = -1
|
||||
}
|
||||
|
||||
enum Task {
|
||||
static let title = "Seed Task"
|
||||
static var id: Int = -1
|
||||
}
|
||||
|
||||
enum Contractor {
|
||||
static let name = "Seed Contractor"
|
||||
static var id: Int = -1
|
||||
}
|
||||
|
||||
enum Document {
|
||||
static let title = "Seed Document"
|
||||
static var id: Int = -1
|
||||
}
|
||||
|
||||
// MARK: - Tokens (populated by Suite00)
|
||||
|
||||
static var testUserToken: String?
|
||||
static var adminUserToken: String?
|
||||
|
||||
// MARK: - Status
|
||||
|
||||
/// `true` once all entity IDs have been populated.
|
||||
static var isSeeded: Bool {
|
||||
Residence.id != -1 && Task.id != -1 && Contractor.id != -1 && Document.id != -1
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,52 @@ struct TestDocument: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
struct TestShareCode: Decodable {
|
||||
let id: Int
|
||||
let code: String
|
||||
let residenceId: Int
|
||||
let isActive: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, code
|
||||
case residenceId = "residence_id"
|
||||
case isActive = "is_active"
|
||||
}
|
||||
}
|
||||
|
||||
struct TestGenerateShareCodeResponse: Decodable {
|
||||
let message: String
|
||||
let shareCode: TestShareCode
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case message
|
||||
case shareCode = "share_code"
|
||||
}
|
||||
}
|
||||
|
||||
struct TestGetShareCodeResponse: Decodable {
|
||||
let shareCode: TestShareCode
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case shareCode = "share_code"
|
||||
}
|
||||
}
|
||||
|
||||
struct TestJoinResidenceResponse: Decodable {
|
||||
let message: String
|
||||
let residence: TestResidence
|
||||
}
|
||||
|
||||
struct TestResidenceUser: Decodable {
|
||||
let id: Int
|
||||
let username: String
|
||||
let email: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, username, email
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - API Client
|
||||
|
||||
enum TestAccountAPIClient {
|
||||
@@ -350,7 +396,7 @@ enum TestAccountAPIClient {
|
||||
|
||||
// MARK: - Document CRUD
|
||||
|
||||
static func createDocument(token: String, residenceId: Int, title: String, documentType: String = "Other", fields: [String: Any] = [:]) -> TestDocument? {
|
||||
static func createDocument(token: String, residenceId: Int, title: String, documentType: String = "general", fields: [String: Any] = [:]) -> TestDocument? {
|
||||
var body: [String: Any] = ["residence_id": residenceId, "title": title, "document_type": documentType]
|
||||
for (k, v) in fields { body[k] = v }
|
||||
return performRequest(method: "POST", path: "/documents/", body: body, token: token, responseType: TestDocument.self)
|
||||
@@ -372,6 +418,49 @@ enum TestAccountAPIClient {
|
||||
return result.succeeded
|
||||
}
|
||||
|
||||
// MARK: - Residence Sharing
|
||||
|
||||
static func generateShareCode(token: String, residenceId: Int) -> TestShareCode? {
|
||||
let wrapped: TestGenerateShareCodeResponse? = performRequest(
|
||||
method: "POST", path: "/residences/\(residenceId)/generate-share-code/",
|
||||
body: [:], token: token,
|
||||
responseType: TestGenerateShareCodeResponse.self
|
||||
)
|
||||
return wrapped?.shareCode
|
||||
}
|
||||
|
||||
static func getShareCode(token: String, residenceId: Int) -> TestShareCode? {
|
||||
let wrapped: TestGetShareCodeResponse? = performRequest(
|
||||
method: "GET", path: "/residences/\(residenceId)/share-code/",
|
||||
token: token, responseType: TestGetShareCodeResponse.self
|
||||
)
|
||||
return wrapped?.shareCode
|
||||
}
|
||||
|
||||
static func joinWithCode(token: String, code: String) -> TestJoinResidenceResponse? {
|
||||
let body: [String: Any] = ["code": code]
|
||||
return performRequest(
|
||||
method: "POST", path: "/residences/join-with-code/",
|
||||
body: body, token: token,
|
||||
responseType: TestJoinResidenceResponse.self
|
||||
)
|
||||
}
|
||||
|
||||
static func removeUser(token: String, residenceId: Int, userId: Int) -> Bool {
|
||||
let result: APIResult<TestMessageResponse> = performRequestWithResult(
|
||||
method: "DELETE", path: "/residences/\(residenceId)/users/\(userId)/",
|
||||
token: token, responseType: TestMessageResponse.self
|
||||
)
|
||||
return result.succeeded
|
||||
}
|
||||
|
||||
static func listResidenceUsers(token: String, residenceId: Int) -> [TestResidenceUser]? {
|
||||
return performRequest(
|
||||
method: "GET", path: "/residences/\(residenceId)/users/",
|
||||
token: token, responseType: [TestResidenceUser].self
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Raw Request (for custom/edge-case assertions)
|
||||
|
||||
/// Make a raw request and return the full APIResult with status code.
|
||||
|
||||
@@ -68,7 +68,7 @@ class TestDataCleaner {
|
||||
|
||||
/// Create a document and automatically track it for cleanup.
|
||||
@discardableResult
|
||||
func seedDocument(residenceId: Int, title: String? = nil, documentType: String = "Other") -> TestDocument {
|
||||
func seedDocument(residenceId: Int, title: String? = nil, documentType: String = "general") -> TestDocument {
|
||||
let document = TestDataSeeder.createDocument(token: token, residenceId: residenceId, title: title, documentType: documentType)
|
||||
trackDocument(document.id)
|
||||
return document
|
||||
|
||||
@@ -174,7 +174,7 @@ enum TestDataSeeder {
|
||||
token: String,
|
||||
residenceId: Int,
|
||||
title: String? = nil,
|
||||
documentType: String = "Other",
|
||||
documentType: String = "general",
|
||||
fields: [String: Any] = [:],
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
|
||||
@@ -3,11 +3,29 @@ import XCTest
|
||||
enum TestFlows {
|
||||
@discardableResult
|
||||
static func navigateToLoginFromOnboarding(app: XCUIApplication) -> LoginScreenObject {
|
||||
let welcome = OnboardingWelcomeScreen(app: app)
|
||||
welcome.waitForLoad()
|
||||
welcome.tapAlreadyHaveAccount()
|
||||
|
||||
let login = LoginScreenObject(app: app)
|
||||
|
||||
// If already on standalone login screen, return immediately.
|
||||
// Use a generous timeout — the app may still be rendering after launch.
|
||||
if app.textFields[UITestID.Auth.usernameField].waitForExistence(timeout: 10)
|
||||
|| app.otherElements[UITestID.Root.login].waitForExistence(timeout: 3) {
|
||||
login.waitForLoad()
|
||||
return login
|
||||
}
|
||||
|
||||
// Check if onboarding is actually present before trying to navigate from it
|
||||
let onboardingRoot = app.otherElements[UITestID.Root.onboarding]
|
||||
if onboardingRoot.waitForExistence(timeout: 5) {
|
||||
// Navigate from onboarding welcome
|
||||
let welcome = OnboardingWelcomeScreen(app: app)
|
||||
welcome.waitForLoad()
|
||||
welcome.tapAlreadyHaveAccount()
|
||||
login.waitForLoad()
|
||||
return login
|
||||
}
|
||||
|
||||
// Fallback: use ensureOnLoginScreen which handles all edge cases
|
||||
UITestHelpers.ensureOnLoginScreen(app: app)
|
||||
login.waitForLoad()
|
||||
return login
|
||||
}
|
||||
@@ -79,8 +97,9 @@ enum TestFlows {
|
||||
@discardableResult
|
||||
static func openRegisterFromLogin(app: XCUIApplication) -> RegisterScreenObject {
|
||||
let login: LoginScreenObject
|
||||
let loginRoot = app.otherElements[UITestID.Root.login]
|
||||
if loginRoot.exists || app.textFields[UITestID.Auth.usernameField].exists {
|
||||
// Wait for login screen elements instead of instantaneous .exists checks
|
||||
if app.textFields[UITestID.Auth.usernameField].waitForExistence(timeout: 10)
|
||||
|| app.otherElements[UITestID.Root.login].waitForExistence(timeout: 3) {
|
||||
login = LoginScreenObject(app: app)
|
||||
login.waitForLoad()
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user