From e5847b5106b25e10d46aff0752da8a15ef363f7d Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Sun, 17 May 2026 10:59:00 -0400 Subject: [PATCH] Add React Native iOS checkout e2e flow --- e2e/README.md | 75 +++++++- e2e/config.yaml | 2 + e2e/react-native/ios/checkout-completion.yaml | 171 ++++++++++++++++++ platforms/react-native/package.json | 3 +- .../sample/src/screens/CatalogScreen.tsx | 1 + 5 files changed, 249 insertions(+), 3 deletions(-) create mode 100644 e2e/config.yaml create mode 100644 e2e/react-native/ios/checkout-completion.yaml diff --git a/e2e/README.md b/e2e/README.md index 290248e4..5fb58a0e 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -1,3 +1,74 @@ -# Checkout Kit — End-to-end tests +# Checkout Kit, end-to-end tests -Placeholder. End-to-end tests covering Swift, Android, and (later) React Native will be folded into this directory in a follow-up. +Cross-platform e2e flows driven by [Maestro](https://maestro.mobile.dev). + +## Layout + +Tests are grouped by the sample app they exercise. Each sample app lives under +[`platforms//`](../platforms/) and has a matching folder here. + +``` +e2e/ +├── config.yaml Shared Maestro config (all platforms) +├── swift/ Targets the Swift sample (iOS only) +├── android/ Targets the Android sample (Android only) +└── react-native/ Targets the RN sample (cross-platform) + ├── ios/ + └── android/ +``` + +The Swift sample is iOS-only and the Android sample is Android-only by +construction, so they don't need an inner platform split. The React Native +sample ships to both platforms; its flows are split because some assertions +are platform-specific (iOS accessibility-label patterns vs Android resource +strings). + +Folders are created when their first flow lands. Don't pre-create empty +directories. + +## Sample-app appIds + +Use these in the `appId:` header of every flow. Don't invent new bundle ids. + +| Folder | appId | +| ------------------------- | ------------------------------------------------ | +| `swift/` | `com.shopify.example.MobileBuyIntegration` | +| `android/` | `com.shopify.checkout_kit_mobile_buy_integration_sample` | +| `react-native/ios/` | `com.shopify.example.CheckoutKitReactNative` | +| `react-native/android/` | `com.shopify.example.CheckoutKitReactNative` | + +## Running + +Each platform's runner script lives next to its sample app. Build and launch +the sample on a simulator/emulator first, then run the script in a second +terminal. + +| Platform | From | Command | +| ------------------ | ------------------------------- | ------------------ | +| React Native, iOS | `platforms/react-native/` | `pnpm e2e:ios` | +| Swift, iOS | TBD | TBD | +| Android (native) | TBD | TBD | +| RN, Android | TBD | TBD | + +Maestro itself is a system CLI, not an npm dependency. Install once with: + +``` +curl -fsSL "https://get.maestro.mobile.dev" | bash +``` + +## Adding a flow + +1. Drop a new `.yaml` under the right folder. +2. Set `appId:` from the table above. +3. Keep timeouts in the existing tiers (in-app interactions ~10s, network + transitions ~30s, cold starts and checkout first-paint ~60s). +4. If the flow needs an npm script wrapper, add an `e2e:` script to + the matching `package.json` next to existing scripts. The script should + point at the folder, not an individual file, so the whole folder runs. + +## Required sample-app accessibility + +Maestro flows rely on testIDs / accessibility labels in the sample apps. When +adding a flow, prefer querying by `id:` (stable, controlled by us) over +`text:` (fragile, depends on storefront copy). If a tappable element doesn't +have an id, add one to the sample first, in a separate commit. diff --git a/e2e/config.yaml b/e2e/config.yaml new file mode 100644 index 00000000..f2c5805c --- /dev/null +++ b/e2e/config.yaml @@ -0,0 +1,2 @@ +ios: + snapshotKeyHonorModalViews: true diff --git a/e2e/react-native/ios/checkout-completion.yaml b/e2e/react-native/ios/checkout-completion.yaml new file mode 100644 index 00000000..ecfe0b87 --- /dev/null +++ b/e2e/react-native/ios/checkout-completion.yaml @@ -0,0 +1,171 @@ +appId: com.shopify.example.CheckoutKitReactNative +name: Checkout reaches Pay now +tags: + - ios + - checkout + +# Override these for store-specific product and shipping-address data. +env: + PRODUCT_INDEX: ${PRODUCT_INDEX || "1"} + COUNTRY: ${COUNTRY || "United States"} + ADDRESS_LINE1: ${ADDRESS_LINE1 || "350 5th Ave"} + CITY: ${CITY || "New York"} + POSTAL_CODE: ${POSTAL_CODE || "10118"} + POSTAL_FIELD: ${POSTAL_FIELD || "ZIP code"} +--- +# Timeout tiers: +# 10000 - in-app interactions (taps, animations) +# 30000 - checkout step transitions (network) +# 60000 - cold starts, first checkout paint + +# Product and cart +- launchApp: + clearState: true + arguments: + AppleLocale: en_US + AppleLanguages: "(en)" +- extendedWaitUntil: + visible: + id: product-0-add-to-cart-button + timeout: 60000 +- scrollUntilVisible: + element: + id: product-${PRODUCT_INDEX}-add-to-cart-button + direction: DOWN + timeout: 10000 + centerElement: true +- tapOn: + id: product-${PRODUCT_INDEX}-add-to-cart-button + enabled: true +- waitForAnimationToEnd: + timeout: 10000 +- tapOn: + id: cart-tab +- extendedWaitUntil: + visible: + id: checkout-button + timeout: 30000 +- tapOn: + id: checkout-button + enabled: true + +# Contact +- extendedWaitUntil: + visible: + text: "^Email( or mobile phone number)?$" + timeout: 60000 +- tapOn: + text: "^Email( or mobile phone number)?$" +- inputText: "maestro.e2e@shopify.com" +- tapOn: "selected" +- tapOn: + text: "^First name( \\(optional\\))?$" +- inputText: "Maestro" +- tapOn: "selected" +- tapOn: + text: "^Last name$" +- inputText: "Shopify" +- tapOn: "selected" + +# Shipping address +- scrollUntilVisible: + element: + text: "Country/Region" + direction: DOWN + timeout: 10000 +- tapOn: + text: "Country/Region" + index: 1 +- waitForAnimationToEnd: + timeout: 3000 +- scrollUntilVisible: + element: + text: "^${COUNTRY}$" + direction: UP + timeout: 10000 + visibilityPercentage: 50 + centerElement: true + optional: true +- runFlow: + when: + notVisible: "^${COUNTRY}$" + commands: + - scrollUntilVisible: + element: + text: "^${COUNTRY}$" + direction: DOWN + timeout: 30000 + visibilityPercentage: 50 + centerElement: true +- tapOn: + text: "^${COUNTRY}$" +- waitForAnimationToEnd: + timeout: 3000 + +- scrollUntilVisible: + element: + text: "Address" + direction: DOWN + timeout: 10000 +- tapOn: + text: "Address" + index: -1 +- eraseText: 80 +- scrollUntilVisible: + element: + text: "^${POSTAL_FIELD}$" + direction: DOWN + timeout: 10000 + centerElement: true +- inputText: "${ADDRESS_LINE1} ${CITY} ${POSTAL_CODE}" +- extendedWaitUntil: + visible: ".*${ADDRESS_LINE1}, ${CITY}.*${POSTAL_CODE}.*${COUNTRY}.*" + timeout: 30000 +- waitForAnimationToEnd: + timeout: 2000 +- tapOn: + text: ".*${ADDRESS_LINE1}, ${CITY}.*${POSTAL_CODE}.*${COUNTRY}.*" + index: 0 + retryTapIfNoChange: true +- waitForAnimationToEnd: + timeout: 5000 +- extendedWaitUntil: + visible: "^${POSTAL_CODE}$" + timeout: 15000 +- tapOn: "selected" +- waitForAnimationToEnd: + timeout: 5000 +# iOS intentionally stops at Pay now because this store/address path can show +# a checkout-web "Shipping not available" banner after submit. + +# Payment +- scrollUntilVisible: + element: + text: "^Field container for: Card number$" + direction: DOWN + timeout: 30000 + centerElement: true +- tapOn: + text: "^Field container for: Card number$" +- inputText: "4242424242424242" +- tapOn: "selected" +- tapOn: "Expiration date (MM / YY)" +- inputText: "1230" +- tapOn: "selected" +- tapOn: "Field container for: Security code" +- inputText: "123" +- tapOn: "selected" +- scrollUntilVisible: + element: + text: "^Field container for: Name on card$" + direction: DOWN + timeout: 30000 + centerElement: true +- scrollUntilVisible: + element: + text: "^Pay now$" + direction: DOWN + timeout: 30000 +- extendedWaitUntil: + visible: "^Pay now$" + timeout: 30000 diff --git a/platforms/react-native/package.json b/platforms/react-native/package.json index 06cd6aee..6acad1ac 100644 --- a/platforms/react-native/package.json +++ b/platforms/react-native/package.json @@ -24,7 +24,8 @@ "snapshot": "./scripts/create_snapshot", "compare-snapshot": "./scripts/compare_snapshot", "turbo": "turbo", - "test": "jest" + "test": "jest", + "e2e:ios": "maestro --platform ios test --config ../../e2e/config.yaml ../../e2e/react-native/ios" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/platforms/react-native/sample/src/screens/CatalogScreen.tsx b/platforms/react-native/sample/src/screens/CatalogScreen.tsx index 90ad13dd..0e1bd9c3 100644 --- a/platforms/react-native/sample/src/screens/CatalogScreen.tsx +++ b/platforms/react-native/sample/src/screens/CatalogScreen.tsx @@ -157,6 +157,7 @@ function Product({ ) : ( variant?.id && onAddToCart(variant.id)}> Add to cart