diff --git a/e2e/README.md b/e2e/README.md index 0973a1c1..1dba4b6d 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -1,12 +1,75 @@ -# Checkout Kit End-to-End Tests +# Checkout Kit, end-to-end tests -This directory is reserved for cross-platform end-to-end tests. There is no runnable e2e suite checked in yet. +Cross-platform e2e flows driven by [Maestro](https://maestro.mobile.dev). -Planned coverage: +## Layout -- Swift checkout presentation and protocol lifecycle. -- Android checkout presentation and protocol lifecycle. -- React Native wrapper behavior. -- Web component open/close and `checkout:*` events. +Tests are grouped by the sample app they exercise. Each sample app lives under +[`platforms//`](../platforms/) and has a matching folder here. -Until this directory contains test code, use the platform test suites and sample apps described in each platform README. +``` +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: animation settles ~3s, local in-page + interactions and optional probes ~5s, sample-app checkout transitions ~15s, + and cold starts, checkout first-paint, and final submit ~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..cf1076dc --- /dev/null +++ b/e2e/config.yaml @@ -0,0 +1,4 @@ +platform: + ios: + # Lets Maestro inspect elements presented inside iOS checkout modal views. + 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..0a795ebe --- /dev/null +++ b/e2e/react-native/ios/checkout-completion.yaml @@ -0,0 +1,248 @@ +appId: com.shopify.example.CheckoutKitReactNative +name: Checkout submits and shows result +tags: + - ios + - checkout + +env: + PRODUCT_INDEX: "0" + + # Checkout contact fixture + EMAIL: "maestro.e2e@shopify.com" + FIRST_NAME: "Maestro" + LAST_NAME: "Shopify" + + # Checkout shipping fixture + COUNTRY_LABEL: "United States" + ADDRESS_LINE1: "700 S Flower St" + CITY: "Los Angeles" + STATE_FIELD_LABEL: "State" + STATE_LABEL: "California" + POSTAL_CODE: "90017" + POSTAL_FIELD_LABEL: "ZIP code" + + # Checkout payment fixture + CARD_NUMBER: "1" + CARD_EXPIRY: "1230" + CARD_SECURITY_CODE: "123" + + # Accepted successful checkout states for this smoke test. + POST_SUBMIT_RESULT_PATTERN: ".*(Thank you|Your order|Order confirmed|confirmation).*" +--- +# Timeout tiers: +# 3000 - animation settles +# 5000 - local in-page interactions and optional probes +# 15000 - sample-app checkout transitions +# 60000 - cold starts, first checkout paint, final submit + +# Product and cart +- launchApp: + clearState: true + arguments: + AppleLocale: en_US + AppleLanguages: "(en)" +- extendedWaitUntil: + visible: + id: product-${PRODUCT_INDEX}-add-to-cart-button + timeout: 60000 +- scrollUntilVisible: + element: + id: product-${PRODUCT_INDEX}-add-to-cart-button + direction: DOWN + timeout: 5000 + centerElement: true +- tapOn: + id: product-${PRODUCT_INDEX}-add-to-cart-button + enabled: true +- waitForAnimationToEnd: + timeout: 3000 +- runFlow: + when: + visible: + id: header-cart-icon + commands: + - tapOn: + id: header-cart-icon +- runFlow: + when: + notVisible: + id: checkout-button + commands: + - tapOn: + id: cart-tab +- extendedWaitUntil: + visible: + id: checkout-button + timeout: 15000 +- 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: "${EMAIL}" +- tapOn: "selected" +- tapOn: + text: "^First name( \\(optional\\))?$" +- inputText: "${FIRST_NAME}" +- tapOn: "selected" +- tapOn: + text: "^Last name$" +- inputText: "${LAST_NAME}" +- tapOn: "selected" + +# Shipping address +- scrollUntilVisible: + element: + text: "Country/Region" + direction: DOWN + timeout: 5000 +- tapOn: + text: "Country/Region" + index: 1 +- waitForAnimationToEnd: + timeout: 3000 +- scrollUntilVisible: + element: + text: "^${COUNTRY_LABEL}$" + direction: UP + timeout: 5000 + visibilityPercentage: 10 + optional: true +- scrollUntilVisible: + element: + text: "^${COUNTRY_LABEL}$" + direction: DOWN + timeout: 5000 + visibilityPercentage: 10 + optional: true +- tapOn: + text: "^${COUNTRY_LABEL}$" +- waitForAnimationToEnd: + timeout: 3000 + +- scrollUntilVisible: + element: + text: "Address" + direction: DOWN + timeout: 5000 +- tapOn: + text: "Address" + index: -1 +- eraseText: 80 +- inputText: "${ADDRESS_LINE1}" +- tapOn: "selected" +- scrollUntilVisible: + element: + text: "^City$" + direction: DOWN + timeout: 5000 + centerElement: true +- tapOn: + text: "^City$" + index: -1 +- eraseText: 80 +- inputText: "${CITY}" +- tapOn: "selected" +- scrollUntilVisible: + element: + text: "^${STATE_FIELD_LABEL}$" + direction: DOWN + timeout: 5000 + centerElement: true +- tapOn: + text: "^${STATE_FIELD_LABEL}$" + index: -1 +- waitForAnimationToEnd: + timeout: 3000 +- scrollUntilVisible: + element: + text: "^${STATE_LABEL}$" + direction: UP + timeout: 5000 + visibilityPercentage: 100 + optional: true +- scrollUntilVisible: + element: + text: "^${STATE_LABEL}$" + direction: DOWN + timeout: 5000 + visibilityPercentage: 100 + optional: true +- tapOn: + text: "^${STATE_LABEL}$" +- waitForAnimationToEnd: + timeout: 3000 +- extendedWaitUntil: + notVisible: "Select a state" + timeout: 5000 +- extendedWaitUntil: + visible: "^${STATE_LABEL}$" + timeout: 5000 +- scrollUntilVisible: + element: + text: "^${POSTAL_FIELD_LABEL}$" + direction: DOWN + timeout: 5000 + centerElement: true +- tapOn: + text: "^${POSTAL_FIELD_LABEL}$" + index: -1 +- eraseText: 80 +- inputText: "${POSTAL_CODE}" +- tapOn: "selected" +- extendedWaitUntil: + visible: "^${POSTAL_CODE}$" + timeout: 5000 +- waitForAnimationToEnd: + timeout: 3000 + +# Payment +- scrollUntilVisible: + element: + text: "^Field container for: Card number$" + direction: DOWN + timeout: 5000 + centerElement: true + optional: true +- runFlow: + when: + visible: "^Field container for: Card number$" + commands: + - tapOn: + text: "^Field container for: Card number$" + - inputText: "${CARD_NUMBER}" + - tapOn: "selected" + - tapOn: "Expiration date (MM / YY)" + - inputText: "${CARD_EXPIRY}" + - tapOn: "selected" + - tapOn: "Field container for: Security code" + - inputText: "${CARD_SECURITY_CODE}" + - tapOn: "selected" + - scrollUntilVisible: + element: + text: "^Field container for: Name on card$" + direction: DOWN + timeout: 5000 + centerElement: true +- scrollUntilVisible: + element: + text: "^(Pay now|Complete order)$" + direction: DOWN + timeout: 5000 + centerElement: true +- tapOn: + text: "^(Pay now|Complete order)$" + enabled: true +- extendedWaitUntil: + visible: "${POST_SUBMIT_RESULT_PATTERN}" + timeout: 60000 +- tapOn: "close" +- extendedWaitUntil: + visible: "^Your cart is empty\\.$" + timeout: 15000 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/hooks/useCheckoutEventHandlers.ts b/platforms/react-native/sample/src/hooks/useCheckoutEventHandlers.ts index b159906b..5a6c535f 100644 --- a/platforms/react-native/sample/src/hooks/useCheckoutEventHandlers.ts +++ b/platforms/react-native/sample/src/hooks/useCheckoutEventHandlers.ts @@ -15,7 +15,10 @@ interface EventHandlers { onClickLink?: (url: string) => void; } -export function useShopifyProtocolEventHandlers(name?: string): ProtocolHandlers { +export function useShopifyProtocolEventHandlers( + name?: string, + additionalHandlers: Partial = {}, +): ProtocolHandlers { const log = createDebugLogger(name ?? ''); // Keep the sample subscribed to every public protocol event automatically. @@ -26,6 +29,11 @@ export function useShopifyProtocolEventHandlers(name?: string): ProtocolHandlers >((handlers, method) => { handlers[method] = payload => { log(method, payload); + ( + additionalHandlers[method as keyof ProtocolHandlers] as + | ((payload: unknown) => void) + | undefined + )?.(payload); }; return handlers; }, {}) as ProtocolHandlers; diff --git a/platforms/react-native/sample/src/screens/CartScreen.tsx b/platforms/react-native/sample/src/screens/CartScreen.tsx index 18a569a9..0bae41f9 100644 --- a/platforms/react-native/sample/src/screens/CartScreen.tsx +++ b/platforms/react-native/sample/src/screens/CartScreen.tsx @@ -13,6 +13,7 @@ import { import Icon from 'react-native-vector-icons/Entypo'; import { + CheckoutProtocol, useShopifyCheckout, AcceleratedCheckoutButtons, ApplePayLabel, @@ -33,8 +34,15 @@ import { function CartScreen(): React.JSX.Element { const ShopifyCheckout = useShopifyCheckout(); const [refreshing, setRefreshing] = React.useState(false); - const {cartId, checkoutURL, totalQuantity, removeFromCart, addingToCart} = - useCart(); + const { + cartId, + checkoutURL, + totalQuantity, + removeFromCart, + addingToCart, + clearCart, + } = useCart(); + const checkoutCompletedRef = React.useRef(false); const {queries} = useShopify(); const {appConfig} = useConfig(); // Separate handler instances so debug logs are labelled with the actual @@ -44,6 +52,11 @@ function CartScreen(): React.JSX.Element { const sheetEventHandlers = useShopifyEventHandlers('Cart - CheckoutSheet'); const sheetProtocolEventHandlers = useShopifyProtocolEventHandlers( 'Cart - CheckoutSheet Protocol', + { + [CheckoutProtocol.complete]: () => { + checkoutCompletedRef.current = true; + }, + }, ); const acceleratedCheckoutEventHandlers = useShopifyEventHandlers( 'Cart - AcceleratedCheckoutButtons', @@ -82,8 +95,18 @@ function CartScreen(): React.JSX.Element { ShopifyCheckout.present( checkoutURL, { - onClose: () => sheetEventHandlers.onCancel?.(), - onFail: error => sheetEventHandlers.onFail?.(error), + onClose: () => { + sheetEventHandlers.onCancel?.(); + + if (checkoutCompletedRef.current) { + checkoutCompletedRef.current = false; + clearCart(); + } + }, + onFail: error => { + checkoutCompletedRef.current = false; + sheetEventHandlers.onFail?.(error); + }, }, sheetProtocolEventHandlers, ); diff --git a/platforms/react-native/sample/src/screens/CatalogScreen.tsx b/platforms/react-native/sample/src/screens/CatalogScreen.tsx index ed103d40..5b936cb9 100644 --- a/platforms/react-native/sample/src/screens/CatalogScreen.tsx +++ b/platforms/react-native/sample/src/screens/CatalogScreen.tsx @@ -134,6 +134,7 @@ function Product({ ) : ( variant?.id && onAddToCart(variant.id)}> Add to cart