import XCTest extension XCUIApplication { /// The guest address-picker sheet (`AddressPickerModalView` -> /// `PublicLocationPickerView`) is a plain `.sheet`, so it's /// swipe-dismissible - but `HomeView+Data.loadGuestStores()` /// re-presents it every time Home reappears with no guest location /// set (see decisions/2026-08-06-ui-test-account-and-app-attest-bypass.md), /// so dismissing it is a race against that re-presentation. Retries a /// swipe-down + Home-tab tap until either Home's search field is /// reachable or the attempt budget runs out. @discardableResult func reachGuestHome(attempts: Int = 8) -> Bool { let searchField = textFields["Search menu, restaurant or craving"] // The sheet can reappear a few hundred ms after Home first shows up, // so a single `.exists` check can pass right before it gets covered // again - re-check after a short settle delay to confirm it stuck. func settled() -> Bool { guard searchField.exists else { return false } usleep(300_000) return searchField.exists } if settled() { return true } for _ in 1...attempts { swipeDown() usleep(300_000) if searchField.waitForExistence(timeout: 1), settled() { return true } } return settled() } /// Same race as `reachGuestHome`, but continuing on to tap the /// Profile tab each attempt until the logged-out profile screen's /// "Entrar ou Cadastrar" button appears. Only meaningful for a genuine /// guest session - if a real login persisted (`TokenStore` is /// Keychain-backed, survives app relaunches), this correctly never /// succeeds, since it only recognizes the logged-out signal. Use /// `reachProfileTabRegardlessOfAuthState` for authenticated-flow tests. @discardableResult func reachLoggedOutProfile(attempts: Int = 8) -> Bool { let loginButton = buttons["Entrar ou Cadastrar"] if loginButton.exists { return true } for _ in 1...attempts { swipeDown() usleep(300_000) let profileTab = buttons.matching(identifier: "person.fill").element(boundBy: 1) if profileTab.exists { profileTab.tap() } usleep(500_000) if loginButton.exists { return true } } return loginButton.exists } /// Same swipe-dismiss-then-tap race as `reachLoggedOutProfile`, but /// recognizes *either* the logged-out ("Entrar ou Cadastrar") or /// authenticated ("Sair da Conta") signal as success. Needed because /// an authenticated account with no saved address hits the exact same /// re-presenting-sheet race as guests (`HomeView`'s "no address" branch /// re-shows the address picker on every Home reappearance), just /// rendering `AddressesView` (a real, scrollable address list) instead /// of `PublicLocationPickerView` (short, static content). `swipeDown()` /// alone isn't reliable there - a real `List`/`ScrollView` can consume /// the drag gesture as a scroll instead of a dismiss, unlike the /// guest picker's static content - so this taps the modal's own /// "Back" button (`.appInlineNavigationTitle()`'s `dismiss()`-backed /// button, present on both variants) first, falling back to /// `swipeDown()` only if that button isn't there. @discardableResult func reachProfileTabRegardlessOfAuthState(attempts: Int = 20) -> Bool { let loginButton = buttons["Entrar ou Cadastrar"] let logoutButton = buttons["Sair da Conta"] if loginButton.exists || logoutButton.exists { return true } for _ in 1...attempts { // Exact-match subscript crashes ("multiple matching elements") // if more than one "Back" button is on screen at once (e.g. a // pushed detail screen's own back button plus the address // picker's) - firstMatch tolerates any count. // .isHittable, not just .exists: tapping a Back button that // exists but is mid-transition (e.g. multiple pushed screens // deep, like Profile -> Meus Cartões -> Novo Cartão) throws a // fatal, uncatchable "not hittable" test failure rather than // just failing gracefully - confirmed via a real teardown run. let backButton = buttons.matching(NSPredicate(format: "label == %@", "Back")).firstMatch if backButton.exists, backButton.isHittable { backButton.tap() } else { swipeDown() } usleep(300_000) // Unlike reachLoggedOutProfile, this must also work once // authenticated - Home's header avatar shows the account's // real profile picture instead of a generic "person.fill" icon // once one is set, dropping the match count from 2 to 1 and // making a fixed index resolve to nothing. `.last` tolerates // either count (confirmed via screenshot: a real authenticated // account with a profile picture only ever produces the tab // bar's match). if let profileTab = buttons.matching(identifier: "person.fill").allElementsBoundByIndex.last, profileTab.exists { profileTab.tap() } usleep(500_000) if loginButton.exists || logoutButton.exists { return true } } return loginButton.exists || logoutButton.exists } // MARK: - Authenticated-flow helpers /// Real end-to-end login with the standing QA account (see /// TestFixtures.swift / decisions/2026-08-06-ui-test-account-and-app-attest-bypass.md). /// The OTP field sets `.textContentType(.oneTimeCode)` /// (`appOTPKeyboard()`, `TextFieldCompat.swift`) - the iOS system code /// content type - which two different input methods failed against in /// this environment: `.typeText()` delivered zero characters (keyboard /// showed, field stayed empty), and `UIPasteboard` + the app's own /// "Colar código" paste button delivered one stray, unrelated digit /// (likely Simulator's host-Mac pasteboard sync racing the write). /// Fix: tap the real on-screen numeric keypad's digit keys /// (`app.keys[...]`) directly instead of any text-injection path - /// confirmed reliable end to end (real OTP validation, real routing). @discardableResult func ensureLoggedIn( email: String = UITestAccount.email, phone: String = UITestAccount.phone, otp: String = UITestAccount.otp, timeout: TimeInterval = 30 ) -> Bool { guard reachProfileTabRegardlessOfAuthState() else { return false } if buttons["Sair da Conta"].exists { return true } buttons["Entrar ou Cadastrar"].tap() let entrarButton = buttons["ENTRAR"] guard entrarButton.waitForExistence(timeout: 5) else { return false } entrarButton.tap() let emailField = textFields["seu@email.com"] guard emailField.waitForExistence(timeout: 5) else { return false } emailField.tap() emailField.typeText(email) let phoneField = textFields["(00) 00000-0000"] phoneField.tap() phoneField.typeText(phone) buttons["Receber Código"].tap() guard staticTexts["Verificação"].waitForExistence(timeout: 15) else { return false } textFields.firstMatch.tap() guard keys["6"].waitForExistence(timeout: 5) else { return false } for digit in otp { keys[String(digit)].tap() } return buttons["Home"].waitForExistence(timeout: timeout) } /// Counterpart to `ensureLoggedIn` - call from `tearDown()` in any test /// that logged in, so guest-only tests elsewhere in the same run don't /// inherit an authenticated Keychain session (`TokenStore` persists /// across app relaunches). No-op if already logged out. @discardableResult func logoutIfAuthenticated() -> Bool { guard reachProfileTabRegardlessOfAuthState() else { return false } guard buttons["Sair da Conta"].exists else { return true } buttons["Sair da Conta"].tap() let confirm = buttons["Sair"] guard confirm.waitForExistence(timeout: 3) else { return false } confirm.tap() return buttons["Entrar ou Cadastrar"].waitForExistence(timeout: 5) } }