import XCTest /// Covers Store Detail -> add to cart -> Cart -> Checkout, the biggest /// remaining zero-coverage surface (CheckoutView.swift alone is ~5000 /// lines). Requires the standing QA account to have a real saved address /// so Home shows a real store list - see /// decisions/2026-08-06-ui-test-account-and-app-attest-bypass.md. final class CartCheckoutFlowTests: XCTestCase { /// Two earlier diagnostic attempts for the timeouts below both failed /// silently: plain `print()` never reached fastlane's xcodebuild log /// (its formatter only relays lines matching its own known patterns), /// and a file written via `NSTemporaryDirectory()` was never found by /// a later CI step's `cat` either - because a UI test target's code /// runs as `PediFoodsUITests-Runner`, an app *inside the simulator*, /// sandboxed to that simulator device's own container filesystem; /// `NSTemporaryDirectory()` there resolves to a path under /// `.../CoreSimulator/Devices//data/...`, invisible to the /// host shell's `$TMPDIR` a later workflow step reads from. /// /// A `XCTAssertTrue` failure *message* is the one diagnostic channel /// already confirmed working end to end every single run (the /// "Store Detail's product list never loaded" text itself always /// showed up). Its `message` parameter is `@autoclosure`, evaluated /// only on actual failure - so building a short live-state summary /// and interpolating it into the message costs nothing when the /// assertion passes, and needs no file access or CI plumbing at all. private func quickDiagnostics(_ app: XCUIApplication) -> String { var signals: [String] = [] if app.staticTexts["Loja fechada no momento."].exists { signals.append("closed-store snackbar visible") } if app.activityIndicators.firstMatch.exists { signals.append("activity indicator still spinning") } if app.alerts.firstMatch.exists { signals.append("alert showing: \(app.alerts.firstMatch.label)") } let errorText = app.staticTexts.matching(NSPredicate(format: "label CONTAINS[c] %@", "erro")).firstMatch if errorText.exists { signals.append("error text visible: \(errorText.label)") } // A bare `\.label` key path literal is illegal here - `label` is // @MainActor-isolated, and a key path must be formable from any // isolation context. A closure isn't subject to that restriction. let visibleTexts = app.staticTexts.allElementsBoundByIndex .prefix(15) .map { $0.label } .joined(separator: " | ") signals.append("visible static texts: [\(visibleTexts)]") return signals.joined(separator: "; ") } /// The cart is on-device state (`CartState`/`SessionStateStore`, see /// `AppState.swift`), not server-side - it persists across app /// relaunches on this simulator just like the login session (see /// decisions/2026-09-11-ui-test-shared-login-session.md), and keeps /// accumulating real quantities across every manual and automated run /// against the "MARIBA" test store. There's no direct "empty cart" UI /// action - the "Trocar de loja?" alert's "Limpar carrinho e /// adicionar" (`StoreDetailView.swift`) only fires for a genuinely /// different store (`shouldAskForStoreSwitch`, /// `StoreDetailView+Logic.swift`), which this suite never triggers /// since every test targets the same store. So: drain the cart via /// its own per-item "minus" control, once per class (not once per /// test - each drain can take several taps), so every run starts from /// a known-empty cart instead of an ever-growing one. override class func setUp() { super.setUp() let app = XCUIApplication() app.launch() app.ensureLoggedIn() let cartTab = app.images.matching(identifier: "cart.fill").firstMatch guard cartTab.waitForExistence(timeout: 10) else { return } cartTab.tap() guard app.staticTexts["Meu Carrinho"].waitForExistence(timeout: 10) else { return } // No "set quantity" input exists in CartView - only per-tap // increment/decrement - so draining is inherently one tap per // unit. Capped rather than unbounded so a genuinely stuck cart // fails fast instead of hanging the whole class. let minusButton = app.images.matching(identifier: "minus").firstMatch var remainingTaps = 500 while app.staticTexts["Seu carrinho está vazio"].exists == false, remainingTaps > 0 { guard minusButton.waitForExistence(timeout: 3) else { break } minusButton.tap() usleep(150_000) remainingTaps -= 1 } } override func setUpWithError() throws { continueAfterFailure = false } // No per-test logout - see decisions/2026-09-11-ui-test-shared-login-session.md. func testAddProductToCartAndReachCheckout() throws { let app = XCUIApplication() app.launch() try reachCheckoutWithProducts(app) } /// Exercises CheckoutView's payment-method selection and the address /// picker's "Alterar" entry point without ever tapping "Confirmar e /// Pagar" - deliberately not submitting a real order (per explicit /// user direction: cover the screen, don't create real order data). func testCheckoutPaymentMethodSelectionAndAddressAlterar() throws { let app = XCUIApplication() app.launch() try reachCheckoutWithProducts(app) XCTAssertTrue(app.staticTexts["MÉTODO DE PAGAMENTO"].waitForExistence(timeout: 5)) // PIX is the default selection; only tap an alternative if this // store's real catalog actually offers one - don't assume Cartão // de Crédito exists (in-app payment methods depend on real store // config, already found to vary - see the standing-account state // notes in decisions/2026-08-06-ui-test-account-and-app-attest-bypass.md). let creditCardRow = app.buttons.matching(NSPredicate(format: "label CONTAINS[c] %@", "Cartão de Crédito")).firstMatch if creditCardRow.exists { creditCardRow.tap() } XCTAssertTrue(app.staticTexts["TIPO DE ENTREGA"].waitForExistence(timeout: 5)) app.buttons["Alterar"].tap() XCTAssertTrue( app.staticTexts["Meus Endereços"].waitForExistence(timeout: 10), "Alterar never opened the address picker" ) // Dismiss without changing anything, back to Checkout. .isHittable, // not just .exists: a Back button that exists but is mid-transition // throws a fatal, uncatchable "not hittable" failure on tap - same // issue already fixed in UITestSupport.reachProfileTabRegardlessOfAuthState. // Poll briefly since the push-in animation can still be settling // right after "Meus Endereços" first becomes visible. let backButton = app.buttons.matching(NSPredicate(format: "label == %@", "Back")).firstMatch var dismissed = false for _ in 0..<10 { if backButton.exists, backButton.isHittable { backButton.tap() dismissed = true break } usleep(200_000) } if dismissed == false { app.swipeDown() } XCTAssertTrue( app.staticTexts["Finalizar Pedido"].waitForExistence(timeout: 10), "Never returned to Checkout. \(quickDiagnostics(app))" ) // Deliberately not tapping "Confirmar e Pagar" - see doc comment. } /// With "Cartão de Crédito" selected, "Confirmar e Pagar" does NOT /// submit an order immediately - `CheckoutView+Logic.handleConfirmPaymentTap()` /// returns early and opens `CardSelectionSheet` instead (real order /// creation only happens if a saved card is then picked and confirmed /// inside that sheet, which this test does not do). Confirmed by /// reading the source before relying on it - this is a real, /// deliberate app behavior, not an assumption. func testConfirmarEPagarWithCreditCardOpensCardSelectionWithoutSubmitting() throws { let app = XCUIApplication() app.launch() try reachCheckoutWithProducts(app) let creditCardRow = app.buttons.matching(NSPredicate(format: "label CONTAINS[c] %@", "Cartão de Crédito")).firstMatch XCTAssertTrue(creditCardRow.waitForExistence(timeout: 5), "This store is expected to offer Cartão de Crédito") creditCardRow.tap() app.buttons["Confirmar e Pagar"].tap() XCTAssertTrue(app.staticTexts["Selecionar Cartão"].waitForExistence(timeout: 10), "Never reached the card selection sheet") // "Adicionar novo cartão" opens PaymentCardView (CheckoutView.swift's // own card-entry form - a different struct from AddCardFormView.swift, // reached from Profile -> Meus Cartões, needs its own coverage) as a // sheet on top. Only fills nothing and dismisses - no card saved, no // order created. app.buttons["Adicionar novo cartão"].tap() XCTAssertTrue( app.staticTexts["Novo Cartão"].waitForExistence(timeout: 10), "Adicionar novo cartão never opened PaymentCardView" ) dismissViaCloseButton(app) XCTAssertTrue(app.staticTexts["Selecionar Cartão"].waitForExistence(timeout: 10), "Never returned to card selection") dismissViaCloseButton(app) XCTAssertTrue(app.staticTexts["Finalizar Pedido"].waitForExistence(timeout: 10), "Never returned to Checkout") } /// Dismisses via the topmost sheet's "x" close button /// (`identifier: "xmark"`) - shared by `CardSelectionSheet` and /// `PaymentCardView`. `.firstMatch` tolerates a lower sheet's own /// close button still existing underneath in the accessibility tree. private func dismissViaCloseButton(_ app: XCUIApplication) { let closeButton = app.buttons.matching(identifier: "xmark").firstMatch for _ in 0..<10 { if closeButton.exists, closeButton.isHittable { closeButton.tap() return } usleep(200_000) } app.swipeDown() } /// Taps the Nth product row's add button (0-based, top to bottom) and /// handles both real outcomes on this catalog: a product with add-ons /// (e.g. "Alcatra", the first product) opens `ProductDetailSheet` /// ("Detalhes") and needs its own confirm tap; a plain product (no /// `addonGroups`) adds directly with no sheet at all /// (`StoreDetailView+Components.swift`). Which products have add-ons /// is real catalog data, not something this suite controls, so both /// paths must be handled rather than assumed - asserting "no sheet /// appeared" for a product that genuinely has add-ons is exactly the /// wrong-diagnosis mistake this suite already made once for this same /// screen (see decisions/2026-09-11-ui-test-shared-login-session.md). /// `ProductDetailSheet` requires no addon selection to confirm - /// `quantity` defaults to 1 and addons default to none, so its main /// action button ("Atualizar • ") is always tappable as-is. private func addProduct(atIndex index: Int, in app: XCUIApplication) { let addButtons = app.buttons.matching(identifier: "storeDetailProductAddButton") let button = addButtons.element(boundBy: index) XCTAssertTrue( button.waitForExistence(timeout: 25), "Product add button at index \(index) never appeared. \(quickDiagnostics(app))" ) button.tap() if app.staticTexts["Detalhes"].waitForExistence(timeout: 3) { let confirmButton = app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Atualizar")).firstMatch XCTAssertTrue( confirmButton.waitForExistence(timeout: 5), "ProductDetailSheet's confirm button never appeared for product at index \(index). \(quickDiagnostics(app))" ) confirmButton.tap() XCTAssertFalse( app.staticTexts["Detalhes"].waitForExistence(timeout: 3), "ProductDetailSheet never dismissed after confirming product at index \(index)" ) } } /// Adds the first `count` products from Store Detail's list, top to /// bottom, then reaches Checkout. Defaults to 3 so both the add-on /// flow (first product) and the plain-add flow (later products) are /// both exercised on every run, not just whichever one the first /// product happens to be. private func reachCheckoutWithProducts(_ app: XCUIApplication, count: Int = 3) throws { XCTAssertTrue(app.ensureLoggedIn(), "Login never completed") // ensureLoggedIn can land on whichever tab it detected the // authenticated session from (e.g. Profile, if already logged in // from a previous test) rather than Home - switch explicitly. XCTAssertTrue(app.buttons["Home"].waitForExistence(timeout: 10)) app.buttons["Home"].tap() // The store card is one merged tappable element (name + category + // distance + status all combine into its accessibility label), not // a plain static text. let storeCard = app.buttons.matching(NSPredicate(format: "label CONTAINS[c] %@", "MARIBA")).firstMatch XCTAssertTrue( storeCard.waitForExistence(timeout: 15), "Expected store card never appeared on Home. \(quickDiagnostics(app))" ) storeCard.tap() // The product row's add button carries an explicit // `.accessibilityIdentifier("storeDetailProductAddButton")` // (StoreDetailView+Components.swift) - added after discovering // that matching on the inner "plus" SF Symbol's implicit/ // auto-generated identifier was unreliable: `quickDiagnostics`'s // visible-text dump showed a real product ("Alcatra", R$ 14,00) // clearly rendered on screen while `images.matching(identifier: // "plus")` still found nothing, and the same "plus" systemName is // separately reused by CartView's quantity stepper, making it an // ambiguous identifier to search by in the first place. let addButtons = app.buttons.matching(identifier: "storeDetailProductAddButton") XCTAssertTrue( addButtons.firstMatch.waitForExistence(timeout: 25), "Store Detail's product list never loaded. \(quickDiagnostics(app))" ) let actualCount = min(count, addButtons.count) for index in 0..