migration
This commit is contained in:
190
PediFoodsUITests/UITestSupport.swift
Normal file
190
PediFoodsUITests/UITestSupport.swift
Normal file
@@ -0,0 +1,190 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user