diff --git a/.gitea/workflows/beta.yml b/.gitea/workflows/beta.yml index cfe60c2..5f52542 100644 --- a/.gitea/workflows/beta.yml +++ b/.gitea/workflows/beta.yml @@ -20,6 +20,18 @@ jobs: "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/archive/${{ github.sha }}.tar.gz" tar -xzf /tmp/repo.tar.gz --strip-components=1 -C "$GITHUB_WORKSPACE" + - name: Check ASC secrets are present (no values printed) + run: | + for s in ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_CONTENT CI_KEYCHAIN_PASSWORD; do + eval "v=\${${s}:-}" + if [ -n "$v" ]; then echo "$s: SET (${#v} chars)"; else echo "$s: EMPTY"; fi + done + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }} + CI_KEYCHAIN_PASSWORD: ${{ secrets.CI_KEYCHAIN_PASSWORD }} + - name: Write App Store Connect API key run: | cat > fastlane/apikey.json < String { let httpMethod = toHTTPMethod(method) + let body: (any HTTPBody)? = params.map { RawBody(data: $0, contentType: "application/json; charset=UTF-8") } return try await API.shared.request( url: url, - params: params, method: httpMethod, + body: body, headers: headers, - jsonEncoding: true, debug: true ) } diff --git a/PediFoods/Views/Main/StoreDetailView+Components.swift b/PediFoods/Views/Main/StoreDetailView+Components.swift index 379aa93..5044940 100644 --- a/PediFoods/Views/Main/StoreDetailView+Components.swift +++ b/PediFoods/Views/Main/StoreDetailView+Components.swift @@ -345,6 +345,11 @@ extension StoreDetailView { .disabled(isStoreOpen == false) .opacity(isStoreOpen ? 1 : 0.65) .offset(x: 7, y: 7) + // Explicit, unique identifier for UI tests - the inner + // "plus" SF Symbol's implicit/auto-generated identifier is + // ambiguous (the same systemName is reused for CartView's + // quantity stepper) and unreliable to match against. + .accessibilityIdentifier("storeDetailProductAddButton") } } .padding(12) diff --git a/PediFoodsUITests/AuthenticatedProfileNavigationTests.swift b/PediFoodsUITests/AuthenticatedProfileNavigationTests.swift index 631c48b..7079ee5 100644 --- a/PediFoodsUITests/AuthenticatedProfileNavigationTests.swift +++ b/PediFoodsUITests/AuthenticatedProfileNavigationTests.swift @@ -13,9 +13,13 @@ final class AuthenticatedProfileNavigationTests: XCTestCase { continueAfterFailure = false } - override func tearDownWithError() throws { - XCUIApplication().logoutIfAuthenticated() - } + // No per-test logout: the standing QA account's Keychain session is + // meant to persist across authenticated-flow tests, so only the first + // one in a suite run pays for a real OTP round trip. Tests that need a + // guaranteed logged-out state (HomeGuestFlowTests, + // ProfileLoggedOutFlowTests) force it themselves in their own setUp + // instead of relying on every authenticated class to log out after + // itself - see decisions/2026-09-11-ui-test-shared-login-session.md. func testOrdersScreenReachableAndLoadsRealData() throws { let app = XCUIApplication() diff --git a/PediFoodsUITests/AuthenticatedSessionFlowTests.swift b/PediFoodsUITests/AuthenticatedSessionFlowTests.swift index 461d007..e3d3c3d 100644 --- a/PediFoodsUITests/AuthenticatedSessionFlowTests.swift +++ b/PediFoodsUITests/AuthenticatedSessionFlowTests.swift @@ -2,17 +2,14 @@ import XCTest /// Covers a real, authenticated session with the standing QA account (see /// TestFixtures.swift / decisions/2026-08-06-ui-test-account-and-app-attest-bypass.md). -/// Logs out in tearDown so guest-only tests elsewhere in the same run don't -/// inherit an authenticated Keychain session. +/// No per-test logout - the Keychain session is meant to persist across +/// authenticated-flow tests in the same run; see +/// decisions/2026-09-11-ui-test-shared-login-session.md. final class AuthenticatedSessionFlowTests: XCTestCase { override func setUpWithError() throws { continueAfterFailure = false } - override func tearDownWithError() throws { - XCUIApplication().logoutIfAuthenticated() - } - func testLoginReachesAuthenticatedProfile() throws { let app = XCUIApplication() app.launch() diff --git a/PediFoodsUITests/CartCheckoutFlowTests.swift b/PediFoodsUITests/CartCheckoutFlowTests.swift index 0a1fae2..f5ca87e 100644 --- a/PediFoodsUITests/CartCheckoutFlowTests.swift +++ b/PediFoodsUITests/CartCheckoutFlowTests.swift @@ -6,18 +6,99 @@ import XCTest /// 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 } - override func tearDownWithError() throws { - XCUIApplication().logoutIfAuthenticated() - } + // No per-test logout - see decisions/2026-09-11-ui-test-shared-login-session.md. func testAddProductToCartAndReachCheckout() throws { let app = XCUIApplication() app.launch() - try reachCheckoutWithOneItem(app) + try reachCheckoutWithProducts(app) } /// Exercises CheckoutView's payment-method selection and the address @@ -27,7 +108,7 @@ final class CartCheckoutFlowTests: XCTestCase { func testCheckoutPaymentMethodSelectionAndAddressAlterar() throws { let app = XCUIApplication() app.launch() - try reachCheckoutWithOneItem(app) + try reachCheckoutWithProducts(app) XCTAssertTrue(app.staticTexts["MÉTODO DE PAGAMENTO"].waitForExistence(timeout: 5)) @@ -68,11 +149,10 @@ final class CartCheckoutFlowTests: XCTestCase { if dismissed == false { app.swipeDown() } - if app.staticTexts["Finalizar Pedido"].waitForExistence(timeout: 10) == false { - let dir = "/private/tmp/claude-501/-Users-loverde-co-Documents-Loverde-JOBs-Producao-Loverde-Co-LC-RAG-Struct-projects-PediFoods-ios/4a9ec4f7-c3ad-4cf5-8dad-073446f6fd81/scratchpad" - try? app.screenshot().pngRepresentation.write(to: URL(fileURLWithPath: "\(dir)/after_alterar_dismiss.png")) - } - XCTAssertTrue(app.staticTexts["Finalizar Pedido"].exists, "Never returned to Checkout") + XCTAssertTrue( + app.staticTexts["Finalizar Pedido"].waitForExistence(timeout: 10), + "Never returned to Checkout. \(quickDiagnostics(app))" + ) // Deliberately not tapping "Confirmar e Pagar" - see doc comment. } @@ -87,7 +167,7 @@ final class CartCheckoutFlowTests: XCTestCase { func testConfirmarEPagarWithCreditCardOpensCardSelectionWithoutSubmitting() throws { let app = XCUIApplication() app.launch() - try reachCheckoutWithOneItem(app) + 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") @@ -129,7 +209,49 @@ final class CartCheckoutFlowTests: XCTestCase { app.swipeDown() } - private func reachCheckoutWithOneItem(_ app: XCUIApplication) throws { + /// 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 @@ -142,33 +264,32 @@ final class CartCheckoutFlowTests: XCTestCase { // 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 - if storeCard.waitForExistence(timeout: 15) == false { - let dir = "/private/tmp/claude-501/-Users-loverde-co-Documents-Loverde-JOBs-Producao-Loverde-Co-LC-RAG-Struct-projects-PediFoods-ios/4a9ec4f7-c3ad-4cf5-8dad-073446f6fd81/scratchpad" - try? app.screenshot().pngRepresentation.write(to: URL(fileURLWithPath: "\(dir)/no_store_card.png")) - } - XCTAssertTrue(storeCard.exists, "Expected store card never appeared on Home") + XCTAssertTrue( + storeCard.waitForExistence(timeout: 15), + "Expected store card never appeared on Home. \(quickDiagnostics(app))" + ) storeCard.tap() - // A plain (non-addon, non-pizza) product row's "+" control adds - // directly with no sheet. Once a product's quantity is > 0, its - // outer Button's identifier moves off itself - the same badge- - // merging bug already fixed for the tab bar's cart icon - because - // the row's own quantity Text becomes the Button's accessible - // label/identity instead. The QA account's cart has genuinely - // accumulated real quantities across many runs today, so - // app.buttons.matching(identifier: "plus") stopped matching once - // the first several products all had quantity > 0 (confirmed via - // screenshot: the "+" controls were clearly visible on screen - // while the buttons-only query found nothing). The nested Image - // keeps identifier "plus" regardless of quantity, so target that - // directly instead - same fix pattern as the cart-tab icon. - let addButton = app.images.matching(identifier: "plus").firstMatch - if addButton.waitForExistence(timeout: 25) == false { - let dir = "/private/tmp/claude-501/-Users-loverde-co-Documents-Loverde-JOBs-Producao-Loverde-Co-LC-RAG-Struct-projects-PediFoods-ios/4a9ec4f7-c3ad-4cf5-8dad-073446f6fd81/scratchpad" - try? app.screenshot().pngRepresentation.write(to: URL(fileURLWithPath: "\(dir)/store_detail_timeout40.png")) + // 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.. AppStore" by default, which doesn't match the +# project's expected names ("LC Prov PediFoods Dist Profile" / "LC Prov +# PediFoods Dist Push Profile") - Xcode then can't find it and falls +# back to whatever stale profile happens to already be installed under +# the old name, which is exactly the doesn't-include-this-cert error +# force: true alone didn't fix. Also needs one call per target: sigh +# only touches the app_identifier it's given, and the +# NotificationServiceExtension has its own separate bundle id. +private_lane :renew_provisioning_profiles do + get_provisioning_profile( + api_key_path: "fastlane/apikey.json", + app_identifier: "com.br.pedifoods.app", + provisioning_name: "LC Prov PediFoods Dist Profile", + force: true + ) + get_provisioning_profile( + api_key_path: "fastlane/apikey.json", + app_identifier: "com.br.pedifoods.app.NotificationService", + provisioning_name: "LC Prov PediFoods Dist Push Profile", + force: true + ) +end + lane :beta do |options| desc "Build and upload to TestFlight" - get_provisioning_profile(api_key_path: "fastlane/apikey.json") + renew_provisioning_profiles assemble @@ -55,7 +83,7 @@ lane :release do |options| # if you have an apikey.json file (https://developer.apple.com/documentation/appstoreconnectapi/creating-api-keys-for-app-store-connect-api), fastlane can automatically fetch certificates and the ASC authentication information #get_certificates(api_key_path: "fastlane/apikey.json") - get_provisioning_profile(api_key_path: "fastlane/apikey.json") + renew_provisioning_profiles assemble