From 477a2428f8353f918c91e97bca03136bfe26ae67 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Thu, 30 Jul 2026 16:35:27 +0100 Subject: [PATCH] feat(e2e): add the cart-from-control-link test on all four targets The first shared test that runs on Swift iOS, Kotlin Android, React Native iOS and React Native Android. It drives the `cart` control link and asserts the `cart-checkout-ready` marker, which appears only after the app parsed the link, resolved a variant, created a cart, added the line, and navigated. Adds `e2e/scripts/run_maestro`, the single Maestro invocation every local runner calls, plus `dev swift e2e` and `dev android e2e` so all four targets run locally. Verified locally: React Native iOS 23s, Swift iOS 23s, Kotlin Android 15s, React Native Android 19s. Three deliberate deviations from the plan: - `E2E_CONTROL_LINK` is derived as `://e2e` rather than configured per application. The scheme equals the app id on all four targets, so a second copy would drift from `app_id`. - Maestro runs the workspace root, not `tests`. Maestro resolves the `flows:` glob in `config.yaml` relative to the path on the command line, so `tests` would look for `tests/tests/**` and match nothing. `tests_path` remains for tag discovery. - The flow no longer stops the app before the link. `SceneDelegate` discards a cold-start URL and Android would need launch-intent handling, so one flow line replaces native work on two platforms. The account journey needs a running app anyway. Co-Authored-By: Claude Opus 5 (1M context) Assisted-By: devx/62deda35-c064-4e16-99eb-02b2792eaf51 --- dev.yml | 15 +- e2e/README.md | 128 +++++---- e2e/config/matrix.yml | 1 + e2e/flows/app/bootstrap-cart-from-link.yaml | 50 ++-- e2e/flows/app/launch.yaml | 45 +++ .../e2e_matrix_to_browserstack_run_plan.rb | 2 +- e2e/scripts/run_local_e2e | 267 ++++++++++++++++++ e2e/scripts/run_maestro | 53 ++++ ...2e_matrix_to_browserstack_run_plan_test.rb | 9 +- e2e/test/maestro_test_tags_test.rb | 27 ++ e2e/test/run_local_e2e_test.rb | 145 ++++++++++ e2e/tests/react-native/checkout-guest.yaml | 2 +- .../checkout-hardcoded-buyer-identity.yaml | 2 +- e2e/tests/shared/cart-from-control-link.yaml | 11 + e2e/tests/shared/launch-smoke.yaml | 6 +- .../androiddemo/e2e/E2EController.kt | 11 +- .../androiddemo/e2e/E2EControllerTest.kt | 30 ++ .../src/e2e/__tests__/controller.test.ts | 47 ++- .../react-native/sample/src/e2e/controller.ts | 41 ++- .../sample/src/e2e/useE2ECartBootstrap.ts | 12 +- .../react-native/scripts/e2e_maestro_android | 142 ---------- .../react-native/scripts/e2e_maestro_ios | 143 ---------- .../Sources/E2E/E2EController.swift | 16 +- .../E2E/E2EControllerTests.swift | 45 +++ 24 files changed, 858 insertions(+), 392 deletions(-) create mode 100644 e2e/flows/app/launch.yaml create mode 100755 e2e/scripts/run_local_e2e create mode 100755 e2e/scripts/run_maestro create mode 100644 e2e/test/run_local_e2e_test.rb create mode 100644 e2e/tests/shared/cart-from-control-link.yaml delete mode 100755 platforms/react-native/scripts/e2e_maestro_android delete mode 100755 platforms/react-native/scripts/e2e_maestro_ios diff --git a/dev.yml b/dev.yml index bb345c17c..d13650200 100644 --- a/dev.yml +++ b/dev.yml @@ -253,6 +253,11 @@ commands: desc: Build the android sample app and install it to the booted emulator run: platforms/android/samples/CheckoutKitAndroidDemo/gradlew -p platforms/android/samples/CheckoutKitAndroidDemo installDebug + e2e: + desc: Run the Android sample Maestro tests, filtered by tag + syntax: "[--tags TAG[,TAG...]] [--exclude-tags TAG[,TAG...]]" + run: ./e2e/scripts/run_local_e2e kotlin-android "$@" + test: desc: Run all library and demo app tests run: | @@ -399,6 +404,12 @@ commands: fi cd Samples ../Scripts/xcode_run build CheckoutKitSwiftDemo + + e2e: + desc: Run the Swift sample Maestro tests, filtered by tag + syntax: "[--tags TAG[,TAG...]] [--exclude-tags TAG[,TAG...]]" + run: ./e2e/scripts/run_local_e2e swift-ios "$@" + test: desc: | `dev swift test` - Run all tests for the ShopifyCheckoutKit-Package and the sample apps. @@ -567,11 +578,11 @@ commands: ios: desc: Run the React Native iOS Maestro tests syntax: "[--tags TAG[,TAG...]] [--exclude-tags TAG[,TAG...]]" - run: cd platforms/react-native && ./scripts/e2e_maestro_ios "$@" + run: ./e2e/scripts/run_local_e2e react-native-ios "$@" android: desc: Run the React Native Android Maestro tests syntax: "[--tags TAG[,TAG...]] [--exclude-tags TAG[,TAG...]]" - run: cd platforms/react-native && ./scripts/e2e_maestro_android "$@" + run: ./e2e/scripts/run_local_e2e react-native-android "$@" lint: desc: Run all React Native lint checks (Swift, module, sample) aliases: [style] diff --git a/e2e/README.md b/e2e/README.md index 737698039..e65bee9d7 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -3,45 +3,47 @@ This directory contains Maestro end-to-end flows and configuration for Checkout Kit sample apps. Two complementary setups live here: -- A **local** React Native suite, run with `dev rn e2e`, that exercises guest and - hardcoded buyer identity checkouts from seeded carts through Shopify checkout - and back to the app. Tags select which tests run. +- **Local** runs, one command per target, that build the sample app, install it on + the booted device, and run the tests in `tests/`. Tags select which tests run. - A **CI matrix** that expands applications and OS version tags into BrowserStack Maestro run rows. Every row runs the whole `tests/` folder and tags select what runs inside it. -## Run locally +Local runs call `scripts/run_local_e2e`, which builds and installs the target +before delegating the Maestro invocation to `scripts/run_maestro`. CI applies the +same environment contract through the BrowserStack run plan. -Run `dev up` first to provision the local toolchain. Install Maestro separately -and make sure `maestro --version` succeeds before running these flows. +## Run locally -### React Native checkout smoke (`dev rn e2e`) +Run `dev up` first to provision the local toolchain, including the pinned Maestro +version. The runners resolve that version through `scripts/maestro_bin`, so a +separate Maestro installation is not needed. -Run the matching command from the repo root. +### One command per target -React Native iOS: +Boot a simulator or emulator first, because Maestro drives the device the app runs +on. Then run the matching command from the repo root. -```bash -dev rn e2e ios -``` +| Target | Command | +|---|---| +| Swift iOS | `dev swift e2e` | +| Kotlin Android | `dev android e2e` | +| React Native iOS | `dev rn e2e ios` | +| React Native Android | `dev rn e2e android` | -React Native Android: - -```bash -dev rn e2e android -``` - -Both commands run every test in `tests/`. Narrow a run with `--tags`: +Each command runs the tests in `tests/shared/` and its target namespace under +`tests/`. Narrow a run with `--tags`: ```bash +dev swift e2e --tags cart dev rn e2e ios --tags checkout -dev rn e2e ios --tags smoke -dev rn e2e android --tags cart,checkout +dev android e2e --tags cart,checkout ``` Both options match **any** listed tag, because that is how Maestro filters. `--tags cart,checkout` runs the cart tests and the checkout tests. `--exclude-tags` -skips tests carrying any listed tag and defaults to `flaky,wip`. +skips tests carrying any listed tag. `config.yaml` quarantines `flaky` and `wip` +for every run, so those need no command line option. ### Tags @@ -58,36 +60,32 @@ enforces it. A platform tag marks a capability only one platform has, such as Apple Pay. It must never mark a test that is merely not ported yet. -The React Native commands start Metro if needed, build and launch the target -sample app, then run Maestro. They require the standard storefront `.env` setup, -but the E2E flows seed their own carts through the bootstrap deep link. The -React Native bootstrap link accepts `buyerIdentityMode`, so guest and hardcoded -buyer identity scenarios share the same cart setup path. No manual sample cart +Every command calls `scripts/run_local_e2e`, which selects the device, builds +and installs the target, and then calls `scripts/run_maestro`. React Native targets +also start Metro if needed. All four need the standard storefront `.env` setup, +but the flows seed their own carts through the control link, so no manual cart setup is required. -### Shared launch smoke +React Native E2E runs should use the released native SDK artifacts declared by +the React Native sample configuration, not local in-repo native SDK overrides. -The launch smoke launches a sample app and waits for the shared ready marker -exposed by that app, using the same environment contract used by CI. +### The control link -React Native iOS: +The samples share one command channel: a deep link on the app's own scheme. -```bash -E2E_APP_ID=com.shopify.checkoutkit.reactnativedemo \ -E2E_READY_MARKER=checkout-kit-sample-ready \ -maestro --platform ios test e2e/tests/shared/launch-smoke.yaml ``` - -React Native Android: - -```bash -E2E_APP_ID=com.shopify.checkoutkit.reactnativedemo \ -E2E_READY_MARKER=checkout-kit-sample-ready \ -maestro --platform android test e2e/tests/shared/launch-smoke.yaml +://e2e/? ``` -React Native E2E runs should use the released native SDK artifacts declared by -the React Native sample configuration, not local in-repo native SDK overrides. +The scheme equals the app id on all four targets, so `scripts/run_maestro` derives +`E2E_CONTROL_LINK` rather than taking it as an argument. Commands are `reset`, +`cart` and `signIn`. Each sample parses the link in its own E2E folder and runs the +command through one `E2EController`, so sample code that merchants read holds a +single hook. + +The link goes to an app that already runs. A stopped app would need a second entry +point on every platform, because iOS delivers a cold-start URL through the scene +connection options and Android through the launch intent. ## Matrix @@ -104,7 +102,7 @@ Current applications: - Kotlin Android sample app - Swift iOS sample app -Current OS version tags: +Current OS version tag: - `latest` @@ -145,28 +143,50 @@ ruby e2e/scripts/e2e_matrix_to_browserstack_run_plan count - `config.yaml` configures Maestro for shared platform behavior and quarantines the `flaky` and `wip` tags. - `flows/` contains reusable Maestro subflows for app setup and checkout steps. +- `tests/shared/` holds the tests every target runs through the CI matrix. +- `tests//` holds platform-local tests. The matrix may ignore their tags. +- `tests/shared/launch-smoke.yaml` is the shared launch smoke test. +- `tests/shared/cart-from-control-link.yaml` seeds a cart through the control link + and waits for the cart marker. - `tests/react-native/checkout-guest.yaml` composes the React Native guest checkout smoke test from those subflows. - `tests/react-native/checkout-hardcoded-buyer-identity.yaml` verifies checkout from a bootstrapped cart with hardcoded buyer identity. +- `scripts/run_local_e2e` builds and installs any of the four local targets. +- `scripts/run_maestro` is their single Maestro invocation. It holds the + environment contract and target-specific test-file selection in one place. - `config/matrix.yml`, `lib/e2e_matrix_to_browserstack_run_plan.rb`, and `scripts/` drive the BrowserStack run plan. -- `tests/shared/launch-smoke.yaml` is the shared launch smoke test. + +Maestro resolves the `flows:` glob in `config.yaml` relative to the path on the +command line. BrowserStack passes the workspace root because `scripts/zip_e2e_tests` +puts `config.yaml`, `tests/` and `flows/` side by side there. Local runs instead +pass the shared and target-specific test files selected by `scripts/run_maestro`. ## Shared app contract -Shared flows rely on stable cross-app identifiers. The launch smoke requires each -target app to expose this ready marker: +Shared flows rely on stable cross-app identifiers. Every target app must expose +these markers: + +| Marker | Appears when | +|---|---| +| `checkout-kit-sample-ready` | the app finished launching | +| `cart-checkout-ready` | the cart holds at least one line | + +`cart-checkout-ready` is the assertion for the whole control link path. It appears +only after the app parsed the link, resolved a variant, created a cart, added the +line, and navigated to the cart. -- `checkout-kit-sample-ready` +`flows/app/bootstrap-cart-from-link.yaml` takes `E2E_CART_PARAMS`, the query string +for the `cart` command. A test that does not care about buyer identity omits +`buyerIdentityMode` and keeps the app's configured mode. Future shared flows should add identifiers here before they are used across React Native, Swift, and Android sample apps. ## Scope -These flows catch regressions in the React Native sample app integration -surface: cart bootstrap, buyer identity configuration, checkout presentation, -checkout completion, and return to the sample app. They are not a replacement -for checkout-web browser-based coverage or for future native Swift and Android -sample-app E2E coverage. +These flows catch regressions in the sample app integration surface on all four +targets: cart bootstrap, buyer identity configuration, checkout presentation, +checkout completion, and return to the sample app. They are not a replacement for +checkout-web browser-based coverage. diff --git a/e2e/config/matrix.yml b/e2e/config/matrix.yml index c216f659b..a551be192 100644 --- a/e2e/config/matrix.yml +++ b/e2e/config/matrix.yml @@ -6,6 +6,7 @@ tests_path: tests tags: include: - launch + - cart exclude: - flaky - wip diff --git a/e2e/flows/app/bootstrap-cart-from-link.yaml b/e2e/flows/app/bootstrap-cart-from-link.yaml index 439b69925..86b3efa47 100644 --- a/e2e/flows/app/bootstrap-cart-from-link.yaml +++ b/e2e/flows/app/bootstrap-cart-from-link.yaml @@ -1,21 +1,33 @@ appId: ${E2E_APP_ID} --- -- launchApp: - clearState: true - arguments: - # iOS-only launch arguments; Android ignores them. - AppleLocale: en_US - AppleLanguages: "(en)" -- extendedWaitUntil: - visible: - id: ${E2E_READY_MARKER} - timeout: 10000 -# Reset app data before testing the deep-link cold-start path. The app is -# stopped so openLink is the launch that seeds the cart. -- stopApp -- openLink: "${E2E_CONTROL_LINK}/cart?productIndex=0&quantity=1&buyerIdentityMode=${BUYER_IDENTITY_MODE}" -- waitForAnimationToEnd -- extendedWaitUntil: - visible: - id: cart-checkout-ready - timeout: 10000 +- runFlow: launch.yaml +# The wait above can match a view hierarchy the dying process left behind, which sends the +# link seconds before the new activity exists. Android then drops it, because a deep link +# has no delivery guarantee. One repeat covers that, and the command is safe to repeat: +# every cart command resets the cart before it seeds one. +- retry: + maxRetries: 1 + commands: + # The link goes to the running app. launchApp cleared the state above, so the cart is + # already empty, and a running app is what the account journey needs later: iOS delivers + # a cold-start URL through the scene connect options and Android through the launch + # intent, so a stopped app would need a second entry point on every platform. + # Each test supplies E2E_CART_PARAMS, so tests that do not care about buyer + # identity leave the app's configured mode alone. + - openLink: "${E2E_CONTROL_LINK}/cart?${E2E_CART_PARAMS}" + - waitForAnimationToEnd + # iOS asks the user to confirm before it hands a custom scheme to an app. Android + # does not, so the tap is conditional rather than platform-gated. + - runFlow: + when: + visible: "Open" + commands: + - tapOn: "Open" + - waitForAnimationToEnd + # The cart marker only appears after the controller resolved a variant, created a + # cart, added the line, and navigated. Reaching it proves the whole command path. + # That is two network round trips, so the timeout covers a slow device on a slow shop. + - extendedWaitUntil: + visible: + id: cart-checkout-ready + timeout: 60000 diff --git a/e2e/flows/app/launch.yaml b/e2e/flows/app/launch.yaml new file mode 100644 index 000000000..be7220622 --- /dev/null +++ b/e2e/flows/app/launch.yaml @@ -0,0 +1,45 @@ +appId: ${E2E_APP_ID} +--- +# Clearing state is the default. Set CLEAR_STATE to "false" only when a test needs to +# preserve state from an earlier launch. +- runFlow: + when: + true: ${typeof CLEAR_STATE === 'undefined' || CLEAR_STATE != 'false'} + commands: + - launchApp: + clearState: true + arguments: + # iOS-only launch arguments; Android ignores them. + AppleLocale: en_US + AppleLanguages: "(en)" +- runFlow: + when: + true: ${typeof CLEAR_STATE !== 'undefined' && CLEAR_STATE == 'false'} + commands: + - launchApp: + arguments: + AppleLocale: en_US + AppleLanguages: "(en)" +# Avoid relaunching healthy apps. BrowserStack can report the state-clearing reinstall and +# launch as complete while iOS remains on SpringBoard, so probe briefly before recovering. +- extendedWaitUntil: + visible: + id: ${E2E_READY_MARKER} + timeout: 10000 + optional: true +- runFlow: + when: + notVisible: + id: ${E2E_READY_MARKER} + commands: + - retry: + maxRetries: 1 + commands: + - launchApp: + arguments: + AppleLocale: en_US + AppleLanguages: "(en)" + - extendedWaitUntil: + visible: + id: ${E2E_READY_MARKER} + timeout: 60000 diff --git a/e2e/lib/e2e_matrix_to_browserstack_run_plan.rb b/e2e/lib/e2e_matrix_to_browserstack_run_plan.rb index 211bf18a2..e39088e88 100644 --- a/e2e/lib/e2e_matrix_to_browserstack_run_plan.rb +++ b/e2e/lib/e2e_matrix_to_browserstack_run_plan.rb @@ -218,7 +218,7 @@ def tests_path # BrowserStack runs Maestro against this path inside the uploaded test suite, and Maestro # resolves the `flows:` glob in config.yaml relative to it. scripts/zip_e2e_tests puts # config.yaml, tests/, and flows/ side by side at the suite root, so the root is the - # only path where that glob resolves. + # only path where that glob resolves. The local runners pass the same value. def workspace_path "." end diff --git a/e2e/scripts/run_local_e2e b/e2e/scripts/run_local_e2e new file mode 100755 index 000000000..599d6826a --- /dev/null +++ b/e2e/scripts/run_local_e2e @@ -0,0 +1,267 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +usage() { + if [ -n "${DEV_COMMAND:-}" ]; then + cat <&2 +Usage: $DEV_COMMAND [--tags TAG[,TAG...]] [--exclude-tags TAG[,TAG...]] + +Runs the shared and $TARGET_DESCRIPTION-specific Maestro tests. Tags come from the +taxonomy: journey (launch, cart, checkout, account), cost tier (smoke, full), +quarantine (flaky, wip) and platform capability (ios-only, android-only). + +Options: + --tags TAG[,TAG...] Run only tests carrying any of these tags. + --exclude-tags TAG[,TAG...] Skip tests carrying any of these tags. +EOF + return + fi + + echo "Usage: run_local_e2e {swift-ios|kotlin-android|react-native-ios|react-native-android} [--tags TAG[,TAG...]] [--exclude-tags TAG[,TAG...]]" >&2 +} + +configure_target() { + TARGET="$1" + READY_MARKER="checkout-kit-sample-ready" + IS_REACT_NATIVE=false + + case "$TARGET" in + swift-ios) + PLATFORM="ios" + APP_ID="com.shopify.checkoutkit.swiftdemo" + ROOT_DIR="$REPO_ROOT/platforms/swift" + APP_NAME="CheckoutKitSwiftDemo" + DERIVED_DATA="$ROOT_DIR/Samples/build/e2e-simulator" + DEV_COMMAND="dev swift e2e" + TARGET_DESCRIPTION="Swift sample" + TEST_NAMESPACE="swift" + ;; + kotlin-android) + PLATFORM="android" + APP_ID="com.shopify.checkoutkit.androiddemo" + ROOT_DIR="$REPO_ROOT/platforms/android" + SAMPLE_DIR="$ROOT_DIR/samples/CheckoutKitAndroidDemo" + DEV_COMMAND="dev android e2e" + TARGET_DESCRIPTION="Android sample" + TEST_NAMESPACE="kotlin" + ;; + react-native-ios) + PLATFORM="ios" + APP_ID="com.shopify.checkoutkit.reactnativedemo" + ROOT_DIR="$REPO_ROOT/platforms/react-native" + IS_REACT_NATIVE=true + METRO_LOG="${TMPDIR:-/tmp}/checkout-kit-rn-ios-metro.log" + DEV_COMMAND="dev rn e2e ios" + TARGET_DESCRIPTION="React Native iOS" + TEST_NAMESPACE="react-native" + ;; + react-native-android) + PLATFORM="android" + APP_ID="com.shopify.checkoutkit.reactnativedemo" + ROOT_DIR="$REPO_ROOT/platforms/react-native" + IS_REACT_NATIVE=true + METRO_LOG="${TMPDIR:-/tmp}/checkout-kit-rn-android-metro.log" + DEV_COMMAND="dev rn e2e android" + TARGET_DESCRIPTION="React Native Android" + TEST_NAMESPACE="react-native" + ;; + *) + echo "Unknown target: $TARGET" >&2 + usage + return 1 + ;; + esac +} + +parse_maestro_tag_args() { + while [ "$#" -gt 0 ]; do + case "$1" in + --tags) + INCLUDE_TAGS="${2:-}" + if [ -z "$INCLUDE_TAGS" ]; then + usage + echo "--tags needs a comma separated tag list" >&2 + exit 1 + fi + shift 2 + ;; + --tags=*) + INCLUDE_TAGS="${1#--tags=}" + shift + ;; + --exclude-tags) + EXCLUDE_TAGS="${2:-}" + if [ -z "$EXCLUDE_TAGS" ]; then + usage + echo "--exclude-tags needs a comma separated tag list" >&2 + exit 1 + fi + shift 2 + ;; + --exclude-tags=*) + EXCLUDE_TAGS="${1#--exclude-tags=}" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + usage + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac + done +} + +metro_running() { + curl --silent --fail http://localhost:8081/status | grep -q "packager-status:running" +} + +cleanup() { + [ -n "$METRO_PID" ] && kill "$METRO_PID" 2>/dev/null || true + [ -n "$E2E_ENV_FILE" ] && rm -f "$E2E_ENV_FILE" || true +} + +prepare_e2e_env() { + E2E_ENV_FILE="$(mktemp "${TMPDIR:-/tmp}/checkout-kit-rn-e2e-env.XXXXXX")" + + if [ -f "$ROOT_DIR/sample/.env" ]; then + cp "$ROOT_DIR/sample/.env" "$E2E_ENV_FILE" + printf "\n" >>"$E2E_ENV_FILE" + fi + + printf "CHECKOUT_KIT_E2E_DISABLE_LOGBOX=1\n" >>"$E2E_ENV_FILE" +} + +wait_for_metro() { + for _ in $(seq 1 60); do + metro_running && return 0 + sleep 1 + done + + echo "Timed out waiting for Metro. Last log lines:" >&2 + tail -n 40 "$METRO_LOG" >&2 || true + return 1 +} + +prepare_react_native_environment() { + cd "$ROOT_DIR" + + if ! metro_running; then + /opt/dev/bin/dev react-native start >"$METRO_LOG" 2>&1 & + METRO_PID="$!" + fi + + wait_for_metro + prepare_e2e_env +} + +booted_ios_simulator() { + xcrun simctl list devices booted | + awk '/^-- /{ios = /iOS/; next} ios && match($0, /\(([0-9A-Fa-f-]{36})\)/) { print substr($0, RSTART + 1, 36); exit }' +} + +attached_android_device() { + adb devices | awk '$2 == "device" { print $1; exit }' +} + +resolve_device() { + case "$PLATFORM" in + ios) + DEVICE_ID="$(booted_ios_simulator || true)" + if [ -z "$DEVICE_ID" ]; then + echo "No booted iOS simulator found." >&2 + echo "Boot a simulator first, because Maestro drives the device the app runs on." >&2 + return 1 + fi + echo "Using booted simulator: $DEVICE_ID" + ;; + android) + DEVICE_ID="$(attached_android_device || true)" + if [ -z "$DEVICE_ID" ]; then + echo "No running Android device or emulator found." >&2 + echo "Start an emulator first, because Maestro drives the device the app runs on." >&2 + return 1 + fi + echo "Using device: $DEVICE_ID" + ;; + esac +} + +build_and_install() { + case "$TARGET" in + swift-ios) + "$ROOT_DIR/Scripts/generate_xcode_projects" + cd "$ROOT_DIR/Samples" + xcodebuild build \ + -workspace Samples.xcworkspace \ + -scheme "$APP_NAME" \ + -sdk iphonesimulator \ + -destination "id=$DEVICE_ID" \ + -derivedDataPath "$DERIVED_DATA" \ + -skipPackagePluginValidation \ + -disableAutomaticPackageResolution \ + CODE_SIGNING_ALLOWED=NO | xcbeautify + + APP_PATH="$(find "$DERIVED_DATA/Build/Products" -maxdepth 2 -name "${APP_NAME}.app" -print -quit)" + if [ -z "$APP_PATH" ]; then + echo "The build created no ${APP_NAME}.app bundle." >&2 + return 1 + fi + + xcrun simctl install "$DEVICE_ID" "$APP_PATH" + ;; + kotlin-android) + ANDROID_SERIAL="$DEVICE_ID" "$SAMPLE_DIR/gradlew" \ + -p "$SAMPLE_DIR" :app:installDebug --console=plain + ;; + react-native-ios) + cd "$ROOT_DIR" + ENVFILE="$E2E_ENV_FILE" pnpm sample ios --udid "$DEVICE_ID" + ;; + react-native-android) + cd "$ROOT_DIR" + ENVFILE="$E2E_ENV_FILE" pnpm sample android \ + --device "$DEVICE_ID" \ + --extra-params "--refresh-dependencies" + ;; + esac +} + +main() { + if [ "$#" -eq 0 ]; then + usage + return 1 + fi + + configure_target "$1" + shift + + INCLUDE_TAGS="" + EXCLUDE_TAGS="" + METRO_PID="" + E2E_ENV_FILE="" + + parse_maestro_tag_args "$@" + + "$REPO_ROOT/e2e/scripts/maestro_bin" >/dev/null + trap cleanup EXIT + + resolve_device + [ "$IS_REACT_NATIVE" = true ] && prepare_react_native_environment + build_and_install + + E2E_DEVICE_ID="$DEVICE_ID" \ + "$REPO_ROOT/e2e/scripts/run_maestro" \ + "$PLATFORM" "$APP_ID" "$READY_MARKER" "$INCLUDE_TAGS" "$EXCLUDE_TAGS" "$TEST_NAMESPACE" +} + +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + main "$@" +fi diff --git a/e2e/scripts/run_maestro b/e2e/scripts/run_maestro new file mode 100755 index 000000000..5760d43c6 --- /dev/null +++ b/e2e/scripts/run_maestro @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# +# Runs the shared Maestro suite for one platform and one sample app. +# +# Usage: run_maestro [include_tags] [exclude_tags] +# +# Every platform runner calls this script, so the environment contract has exactly one +# definition. Maestro exits non-zero when a tag or the flows glob matches nothing. +set -euo pipefail + +PLATFORM="${1:?platform (ios|android) is required}" +APP_ID="${2:?app id is required}" +READY_MARKER="${3:?ready marker is required}" +INCLUDE_TAGS="${4:-}" +EXCLUDE_TAGS="${5:-}" +TEST_NAMESPACE="${6:?test namespace is required}" +DEVICE_ID="${E2E_DEVICE_ID:-}" + +E2E_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$E2E_ROOT" + +# Resolve the binary that e2e/.maestro-version pins instead of taking `maestro` from PATH. +# Homebrew installs ahead of everything on PATH and tracks its own release train, and a +# Maestro below 2.0.7 passes every iOS test locally while failing every one of them on the +# real devices that CI uses. +MAESTRO="$("$E2E_ROOT/scripts/maestro_bin")" + +# The deep link scheme equals the app id on every target, so the link is derived rather +# than passed. e2e/lib/e2e_matrix_to_browserstack_run_plan.rb derives it the same way for CI. +CONTROL_LINK="${APP_ID}://e2e" + +MAESTRO_ARGS=(--platform "$PLATFORM" test --config config.yaml) +TEST_DIRECTORIES=(tests/shared) +TEST_FILES=() + +[ -d "tests/$TEST_NAMESPACE" ] && TEST_DIRECTORIES+=("tests/$TEST_NAMESPACE") +while IFS= read -r test_file; do + TEST_FILES+=("$test_file") +done < <(find "${TEST_DIRECTORIES[@]}" -type f -name "*.yaml" | sort) + +[ -n "$INCLUDE_TAGS" ] && MAESTRO_ARGS+=(--include-tags "$INCLUDE_TAGS") +[ -n "$EXCLUDE_TAGS" ] && MAESTRO_ARGS+=(--exclude-tags "$EXCLUDE_TAGS") + +# Without this, Maestro attaches to whichever device it finds first, so a second booted +# simulator or attached device can silently receive commands meant for the one the caller +# just installed the app on. +[ -n "$DEVICE_ID" ] && MAESTRO_ARGS+=(--device "$DEVICE_ID") + +exec "$MAESTRO" "${MAESTRO_ARGS[@]}" \ + -e "E2E_APP_ID=${APP_ID}" \ + -e "E2E_READY_MARKER=${READY_MARKER}" \ + -e "E2E_CONTROL_LINK=${CONTROL_LINK}" \ + "${TEST_FILES[@]}" diff --git a/e2e/test/e2e_matrix_to_browserstack_run_plan_test.rb b/e2e/test/e2e_matrix_to_browserstack_run_plan_test.rb index a697dff2c..c1de16520 100644 --- a/e2e/test/e2e_matrix_to_browserstack_run_plan_test.rb +++ b/e2e/test/e2e_matrix_to_browserstack_run_plan_test.rb @@ -27,7 +27,12 @@ def test_expand_produces_one_run_per_application_and_os_version_tag assert_equal 4, runs.length assert_equal( - ["react-native-ios-latest", "react-native-android-latest", "kotlin-android-latest", "swift-ios-latest"], + [ + "react-native-ios-latest", + "react-native-android-latest", + "kotlin-android-latest", + "swift-ios-latest" + ], runs.map { |run| run.fetch("id") } ) end @@ -42,7 +47,7 @@ def test_runs_carry_default_tags_and_the_other_platform_exclusion ios_run = run_for("swift-ios") android_run = run_for("kotlin-android") - assert_equal ["launch"], ios_run.fetch("include_tags") + assert_equal ["launch", "cart"], ios_run.fetch("include_tags") assert_equal ["flaky", "wip", "android-only"], ios_run.fetch("exclude_tags") assert_equal ["flaky", "wip", "ios-only"], android_run.fetch("exclude_tags") end diff --git a/e2e/test/maestro_test_tags_test.rb b/e2e/test/maestro_test_tags_test.rb index ba3b9c202..cf17b50af 100644 --- a/e2e/test/maestro_test_tags_test.rb +++ b/e2e/test/maestro_test_tags_test.rb @@ -98,7 +98,34 @@ def test_the_workspace_config_quarantines_the_quarantine_tags assert_equal(QUARANTINE_TAGS.sort, (config["excludeTags"] || []).sort) end + def shared_test_files + test_files.select { |path| path.start_with?("tests/shared/") } + end + + def matrix + YAML.safe_load_file(File.join(E2E_ROOT, "config", "matrix.yml"), aliases: true) + end + + # Tests under tests/shared/ run on every target through the CI matrix. Tests under + # tests// are local-only, so the matrix is free to ignore their tags. + def matrix_include_tags + defaults = matrix.fetch("tags", {}).fetch("include", []) + + matrix.fetch("applications", []).flat_map { |application| application.fetch("include_tags", defaults) }.uniq + end + + def test_the_matrix_selects_every_journey_a_shared_test_declares + declared = shared_test_files.flat_map { |path| tags(path) & JOURNEY_TAGS }.uniq + unselected = declared - matrix_include_tags + + assert_empty( + unselected, + "config/matrix.yml includes no application for #{unselected.inspect}, so those shared tests never run in CI" + ) + end + def test_there_is_at_least_one_test_to_check refute_empty(test_files) + refute_empty(shared_test_files) end end diff --git a/e2e/test/run_local_e2e_test.rb b/e2e/test/run_local_e2e_test.rb new file mode 100644 index 000000000..094beab37 --- /dev/null +++ b/e2e/test/run_local_e2e_test.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require "fileutils" +require "minitest/autorun" +require "open3" +require "tmpdir" +require "yaml" + +class RunLocalE2ETest < Minitest::Test + REPO_ROOT = File.expand_path("../..", __dir__) + RUNNER = File.join(REPO_ROOT, "e2e", "scripts", "run_local_e2e") + MAESTRO_RUNNER = File.join(REPO_ROOT, "e2e", "scripts", "run_maestro") + REMOVED_RUNNERS = [ + "e2e/scripts/parse_maestro_tag_args", + "e2e/scripts/run_local_maestro", + "platforms/android/scripts/e2e_maestro", + "platforms/react-native/scripts/e2e_maestro_android", + "platforms/react-native/scripts/e2e_maestro_ios", + "platforms/swift/Scripts/e2e_maestro" + ].freeze + TARGETS = { + "swift-ios" => ["ios", "com.shopify.checkoutkit.swiftdemo", "platforms/swift"], + "kotlin-android" => ["android", "com.shopify.checkoutkit.androiddemo", "platforms/android"], + "react-native-ios" => ["ios", "com.shopify.checkoutkit.reactnativedemo", "platforms/react-native"], + "react-native-android" => ["android", "com.shopify.checkoutkit.reactnativedemo", "platforms/react-native"] + }.freeze + + def test_runner_is_executable_bash + assert File.executable?(RUNNER) + + _output, error, status = Open3.capture3("bash", "-n", RUNNER) + + assert status.success?, error + end + + def test_every_target_configures_its_platform_app_and_workspace + TARGETS.each do |target, expected| + output, error, status = Open3.capture3( + "bash", + "-c", + 'source "$1"; configure_target "$2"; printf "%s\n%s\n%s\n" "$PLATFORM" "$APP_ID" "$ROOT_DIR"', + "run-local-e2e-test", + RUNNER, + target + ) + + assert status.success?, error + platform, app_id, root_dir = output.lines.map(&:chomp) + assert_equal expected, [platform, app_id, root_dir.delete_prefix("#{REPO_ROOT}/")] + end + end + + def test_swift_build_output_uses_xcbeautify + assert_match(/CODE_SIGNING_ALLOWED=NO \|\s+xcbeautify/, File.read(RUNNER)) + end + + def test_dev_commands_dispatch_to_the_central_runner + commands = YAML.safe_load_file(File.join(REPO_ROOT, "dev.yml")).fetch("commands") + + assert_equal './e2e/scripts/run_local_e2e kotlin-android "$@"', commands.dig("android", "subcommands", "e2e", "run") + assert_equal './e2e/scripts/run_local_e2e swift-ios "$@"', commands.dig("swift", "subcommands", "e2e", "run") + assert_equal './e2e/scripts/run_local_e2e react-native-ios "$@"', commands.dig("react-native", "subcommands", "e2e", "subcommands", "ios", "run") + assert_equal './e2e/scripts/run_local_e2e react-native-android "$@"', commands.dig("react-native", "subcommands", "e2e", "subcommands", "android", "run") + end + + def test_runner_owns_tag_argument_parsing + output, error, status = Open3.capture3( + "bash", + "-c", + 'source "$1"; INCLUDE_TAGS=""; EXCLUDE_TAGS=""; parse_maestro_tag_args --tags smoke,cart --exclude-tags flaky; printf "%s\n%s\n" "$INCLUDE_TAGS" "$EXCLUDE_TAGS"', + "run-local-e2e-test", + RUNNER + ) + + assert status.success?, error + assert_equal ["smoke,cart", "flaky"], output.lines.map(&:chomp) + end + + def test_exclude_tags_requires_a_value + _output, error, status = Open3.capture3( + "bash", + "-c", + 'source "$1"; INCLUDE_TAGS=""; EXCLUDE_TAGS=""; parse_maestro_tag_args --exclude-tags', + "run-local-e2e-test", + RUNNER + ) + + refute status.success? + assert_includes error, "--exclude-tags needs a comma separated tag list" + end + + def test_maestro_runs_shared_and_target_specific_test_files + react_native_arguments = maestro_arguments("react-native") + swift_arguments = maestro_arguments("swift") + shared_tests = Dir.glob("e2e/tests/shared/**/*.yaml", base: REPO_ROOT).sort + react_native_tests = Dir.glob("e2e/tests/react-native/**/*.yaml", base: REPO_ROOT).sort + + shared_tests.each do |path| + relative_path = path.delete_prefix("e2e/") + assert_includes react_native_arguments, relative_path + assert_includes swift_arguments, relative_path + end + + react_native_tests.each do |path| + relative_path = path.delete_prefix("e2e/") + assert_includes react_native_arguments, relative_path + refute_includes swift_arguments, relative_path + end + + refute_includes react_native_arguments, "." + refute_includes swift_arguments, "." + end + + def test_target_specific_and_old_named_runners_are_removed + REMOVED_RUNNERS.each do |path| + refute_path_exists File.join(REPO_ROOT, path) + end + end + + private + + def maestro_arguments(test_namespace) + Dir.mktmpdir do |directory| + version = File.read(File.join(REPO_ROOT, "e2e", ".maestro-version")).strip + binary = File.join(directory, version, "bin", "maestro") + FileUtils.mkdir_p(File.dirname(binary)) + File.write(binary, %(#!/usr/bin/env bash\nprintf '%s\\n' "$@"\n)) + FileUtils.chmod("+x", binary) + + output, error, status = Open3.capture3( + { "MAESTRO_VERSIONS_ROOT" => directory }, + MAESTRO_RUNNER, + "ios", + "com.example.app", + "ready-marker", + "", + "", + test_namespace + ) + + assert status.success?, error + output.lines.map(&:chomp) + end + end +end diff --git a/e2e/tests/react-native/checkout-guest.yaml b/e2e/tests/react-native/checkout-guest.yaml index 1d76f63cf..d98b3e055 100644 --- a/e2e/tests/react-native/checkout-guest.yaml +++ b/e2e/tests/react-native/checkout-guest.yaml @@ -6,7 +6,7 @@ tags: env: # Sample app buyer identity configuration - BUYER_IDENTITY_MODE: "guest" + E2E_CART_PARAMS: "productIndex=0&quantity=1&buyerIdentityMode=guest" # Checkout contact fixture EMAIL: "maestro.e2e@shopify.com" diff --git a/e2e/tests/react-native/checkout-hardcoded-buyer-identity.yaml b/e2e/tests/react-native/checkout-hardcoded-buyer-identity.yaml index f02b721c5..386d21710 100644 --- a/e2e/tests/react-native/checkout-hardcoded-buyer-identity.yaml +++ b/e2e/tests/react-native/checkout-hardcoded-buyer-identity.yaml @@ -6,7 +6,7 @@ tags: env: # Sample app buyer identity configuration - BUYER_IDENTITY_MODE: "hardcoded" + E2E_CART_PARAMS: "productIndex=0&quantity=1&buyerIdentityMode=hardcoded" # Checkout payment fixture CARD_NUMBER: "1" diff --git a/e2e/tests/shared/cart-from-control-link.yaml b/e2e/tests/shared/cart-from-control-link.yaml new file mode 100644 index 000000000..6f59c6534 --- /dev/null +++ b/e2e/tests/shared/cart-from-control-link.yaml @@ -0,0 +1,11 @@ +appId: ${E2E_APP_ID} +name: Cart from control link +tags: + - cart + - smoke + +env: + # No buyerIdentityMode: this test asserts cart seeding, not buyer identity. + E2E_CART_PARAMS: "productIndex=0&quantity=1" +--- +- runFlow: ../../flows/app/bootstrap-cart-from-link.yaml diff --git a/e2e/tests/shared/launch-smoke.yaml b/e2e/tests/shared/launch-smoke.yaml index 525235237..90a38ec67 100644 --- a/e2e/tests/shared/launch-smoke.yaml +++ b/e2e/tests/shared/launch-smoke.yaml @@ -4,8 +4,4 @@ tags: - launch - smoke --- -- launchApp -- extendedWaitUntil: - visible: - id: ${E2E_READY_MARKER} - timeout: 60000 +- runFlow: ../../flows/app/launch.yaml diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2EController.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2EController.kt index c6f81f7aa..0c83167b4 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2EController.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2EController.kt @@ -1,5 +1,8 @@ package com.shopify.checkoutkit.androiddemo.e2e +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + interface E2ECommandTarget { suspend fun selectBuyerIdentityMode(mode: E2EBuyerIdentityMode) @@ -16,6 +19,12 @@ interface E2ECommandTarget { class E2EController(private val target: E2ECommandTarget) { + companion object { + // Each incoming deep link creates a new E2EController, so the lock has to live here + // rather than on the instance to serialize across every link the app receives. + private val mutex = Mutex() + } + suspend fun handle(url: String): Boolean { val link = try { E2EControlLink.parse(url) @@ -28,7 +37,7 @@ class E2EController(private val target: E2ECommandTarget) { return false } - perform(link) + mutex.withLock { perform(link) } return true } diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/e2e/E2EControllerTest.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/e2e/E2EControllerTest.kt index bf823eb12..211e45137 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/e2e/E2EControllerTest.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/e2e/E2EControllerTest.kt @@ -1,6 +1,9 @@ package com.shopify.checkoutkit.androiddemo.e2e +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield import org.assertj.core.api.Assertions.assertThat import org.junit.Test @@ -95,6 +98,31 @@ class E2EControllerTest { assertThat(target.calls).containsExactly("report(signIn is not implemented yet)") } + @Test + fun `serializes concurrent handle calls so the second does not start until the first finishes`() = runBlocking { + val target = E2ECommandTargetSpy() + val firstResetStarted = CompletableDeferred() + val releaseFirstReset = CompletableDeferred() + target.onResetCart = { + firstResetStarted.complete(Unit) + releaseFirstReset.await() + } + + val first = launch { E2EController(target).handle("com.shopify.checkoutkit.androiddemo://e2e/reset") } + firstResetStarted.await() + + val second = launch { E2EController(target).handle("com.shopify.checkoutkit.androiddemo://e2e/reset") } + yield() + + assertThat(target.calls).containsExactly("resetCart") + + releaseFirstReset.complete(Unit) + first.join() + second.join() + + assertThat(target.calls).containsExactly("resetCart", "resetCart") + } + private fun handle(path: String, target: E2ECommandTargetSpy) = runBlocking { E2EController(target).handle("com.shopify.checkoutkit.androiddemo://e2e$path") } @@ -104,6 +132,7 @@ private class E2ECommandTargetSpy : E2ECommandTarget { val calls = mutableListOf() var variantIdError: Throwable? = null var addCartLineError: Throwable? = null + var onResetCart: (suspend () -> Unit)? = null override suspend fun selectBuyerIdentityMode(mode: E2EBuyerIdentityMode) { calls.add("selectBuyerIdentityMode(${mode.parameterValue})") @@ -111,6 +140,7 @@ private class E2ECommandTargetSpy : E2ECommandTarget { override suspend fun resetCart() { calls.add("resetCart") + onResetCart?.invoke() } override suspend fun variantId(atProductIndex: Int): String { diff --git a/platforms/react-native/sample/src/e2e/__tests__/controller.test.ts b/platforms/react-native/sample/src/e2e/__tests__/controller.test.ts index 6e6aa73e3..95078db95 100644 --- a/platforms/react-native/sample/src/e2e/__tests__/controller.test.ts +++ b/platforms/react-native/sample/src/e2e/__tests__/controller.test.ts @@ -5,6 +5,7 @@ class E2ECommandTargetSpy implements E2ECommandTarget { calls: string[] = []; variantIdError: Error | null = null; addCartLineError: Error | null = null; + onResetCart: (() => Promise) | null = null; async selectBuyerIdentityMode(mode: BuyerIdentityMode) { this.calls.push(`selectBuyerIdentityMode(${mode})`); @@ -12,6 +13,7 @@ class E2ECommandTargetSpy implements E2ECommandTarget { async resetCart() { this.calls.push('resetCart'); + await this.onResetCart?.(); } async variantId(productIndex: number) { @@ -41,10 +43,21 @@ class E2ECommandTargetSpy implements E2ECommandTarget { } } +function controlLink(path: string) { + return `com.shopify.checkoutkit.reactnativedemo://e2e${path}`; +} + function handle(path: string, target: E2ECommandTargetSpy) { - return new E2EController(target).handle( - `com.shopify.checkoutkit.reactnativedemo://e2e${path}`, - ); + return new E2EController(target).handle(controlLink(path)); +} + +function deferred() { + let resolve!: () => void; + const promise = new Promise(value => { + resolve = value; + }); + + return {promise, resolve}; } describe('E2EController', () => { @@ -134,4 +147,32 @@ describe('E2EController', () => { expect(target.calls).toEqual(['report(signIn is not implemented yet)']); }); + + it('serializes concurrent commands', async () => { + const target = new E2ECommandTargetSpy(); + const controller = new E2EController(target); + const firstResetStarted = deferred(); + const releaseFirstReset = deferred(); + let resetCount = 0; + + target.onResetCart = async () => { + resetCount += 1; + if (resetCount === 1) { + firstResetStarted.resolve(); + await releaseFirstReset.promise; + } + }; + + const first = controller.handle(controlLink('/reset')); + await firstResetStarted.promise; + const second = controller.handle(controlLink('/reset')); + await Promise.resolve(); + + expect(target.calls).toEqual(['resetCart']); + + releaseFirstReset.resolve(); + await Promise.all([first, second]); + + expect(target.calls).toEqual(['resetCart', 'resetCart']); + }); }); diff --git a/platforms/react-native/sample/src/e2e/controller.ts b/platforms/react-native/sample/src/e2e/controller.ts index d2ede28aa..b03753b63 100644 --- a/platforms/react-native/sample/src/e2e/controller.ts +++ b/platforms/react-native/sample/src/e2e/controller.ts @@ -19,7 +19,13 @@ function message(error: unknown) { } export class E2EController { - constructor(private readonly target: E2ECommandTarget) {} + private tail: Promise = Promise.resolve(); + + constructor(private target: E2ECommandTarget) {} + + setTarget(target: E2ECommandTarget) { + this.target = target; + } async handle(url: string): Promise { let link: E2EControlLink | null; @@ -35,40 +41,49 @@ export class E2EController { return false; } - await this.perform(link); + const target = this.target; + await this.enqueue(() => this.perform(link, target)); return true; } - private async perform(link: E2EControlLink) { + private async enqueue(command: () => Promise) { + const task = this.tail.then(command); + this.tail = task.catch(() => undefined); + await task; + } + + private async perform(link: E2EControlLink, target: E2ECommandTarget) { try { switch (link.command) { case 'reset': - await this.target.resetCart(); + await target.resetCart(); break; case 'cart': - await this.seedCart(link); + await this.seedCart(link, target); break; case 'signIn': throw new Error('signIn is not implemented yet'); } } catch (error) { - await this.target.report(message(error)); + await target.report(message(error)); } } - private async seedCart(command: E2ECartCommand) { + private async seedCart( + command: E2ECartCommand, + target: E2ECommandTarget, + ) { if (command.buyerIdentityMode) { - await this.target.selectBuyerIdentityMode(command.buyerIdentityMode); + await target.selectBuyerIdentityMode(command.buyerIdentityMode); } - await this.target.resetCart(); + await target.resetCart(); const variantId = - command.variantId ?? - (await this.target.variantId(command.productIndex ?? 0)); + command.variantId ?? (await target.variantId(command.productIndex ?? 0)); - await this.target.addCartLine(variantId, command.quantity); - await this.target.showCart(); + await target.addCartLine(variantId, command.quantity); + await target.showCart(); } } diff --git a/platforms/react-native/sample/src/e2e/useE2ECartBootstrap.ts b/platforms/react-native/sample/src/e2e/useE2ECartBootstrap.ts index 816521534..a7aba09a2 100644 --- a/platforms/react-native/sample/src/e2e/useE2ECartBootstrap.ts +++ b/platforms/react-native/sample/src/e2e/useE2ECartBootstrap.ts @@ -1,4 +1,4 @@ -import {useCallback, useMemo} from 'react'; +import {useCallback, useMemo, useRef} from 'react'; import {Alert} from 'react-native'; import type {BuyerIdentityMode} from '../auth/types'; import {useCart} from '../context/Cart'; @@ -47,7 +47,11 @@ export function useE2ECartBootstrap({onCartReady}: UseE2ECartBootstrapOptions) { }; }, [clearCart, fetchProducts, onCartReady, seedCart]); - return useCallback((url: string) => new E2EController(target).handle(url), [ - target, - ]); + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new E2EController(target); + } + controllerRef.current.setTarget(target); + + return useCallback((url: string) => controllerRef.current!.handle(url), []); } diff --git a/platforms/react-native/scripts/e2e_maestro_android b/platforms/react-native/scripts/e2e_maestro_android deleted file mode 100755 index 69852b9e7..000000000 --- a/platforms/react-native/scripts/e2e_maestro_android +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -REPO_ROOT="$(cd "$ROOT_DIR/../.." && pwd)" -METRO_LOG="${TMPDIR:-/tmp}/checkout-kit-rn-android-metro.log" -METRO_PID="" -E2E_ENV_FILE="" -APP_ID="com.shopify.checkoutkit.reactnativedemo" -CONTROL_LINK="${APP_ID}://e2e" -READY_MARKER="checkout-kit-sample-ready" -INCLUDE_TAGS="" -EXCLUDE_TAGS="flaky,wip" - -usage() { - cat <&2 -Usage: dev rn e2e android [--tags TAG[,TAG...]] [--exclude-tags TAG[,TAG...]] - -Runs the React Native Android Maestro tests against the released native SDK artifacts. -Without --tags, every test in e2e/tests runs. Tags come from the taxonomy: journey -(launch, cart, checkout, account), cost tier (smoke, full), quarantine (flaky, wip) -and platform capability (ios-only, android-only). - -Options: - --tags TAG[,TAG...] Run only tests carrying any of these tags. - --exclude-tags TAG[,TAG...] Skip tests carrying any of these tags. Defaults to flaky,wip. -EOF -} - -while [ "$#" -gt 0 ]; do - case "$1" in - --tags) - INCLUDE_TAGS="${2:-}" - if [ -z "$INCLUDE_TAGS" ]; then - usage - echo "--tags needs a comma separated tag list" >&2 - exit 1 - fi - shift 2 - ;; - --tags=*) - INCLUDE_TAGS="${1#--tags=}" - shift - ;; - --exclude-tags) - EXCLUDE_TAGS="${2:-}" - shift 2 - ;; - --exclude-tags=*) - EXCLUDE_TAGS="${1#--exclude-tags=}" - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - usage - echo "Unknown option: $1" >&2 - exit 1 - ;; - esac -done - -metro_running() { - curl --silent --fail http://localhost:8081/status | grep -q "packager-status:running" -} - -cleanup() { - if [ -n "$METRO_PID" ]; then - kill "$METRO_PID" 2>/dev/null || true - fi - - if [ -n "$E2E_ENV_FILE" ]; then - rm -f "$E2E_ENV_FILE" - fi -} - -prepare_e2e_env() { - E2E_ENV_FILE="$(mktemp "${TMPDIR:-/tmp}/checkout-kit-rn-e2e-env.XXXXXX")" - - if [ -f "$ROOT_DIR/sample/.env" ]; then - cp "$ROOT_DIR/sample/.env" "$E2E_ENV_FILE" - printf "\n" >>"$E2E_ENV_FILE" - fi - - printf "CHECKOUT_KIT_E2E_DISABLE_LOGBOX=1\n" >>"$E2E_ENV_FILE" -} - -wait_for_metro() { - for _ in $(seq 1 60); do - if metro_running; then - return 0 - fi - sleep 1 - done - - echo "Timed out waiting for Metro. Last log lines:" >&2 - tail -n 40 "$METRO_LOG" >&2 || true - return 1 -} - -# Resolve the pinned Maestro by path. A Maestro from PATH is whatever the machine happens to -# hold, and the local version drifting from the CI version has already hidden a real failure. -resolve_maestro() { - MAESTRO="$("$REPO_ROOT/e2e/scripts/maestro_bin")" -} - -cd "$ROOT_DIR" - -resolve_maestro -trap cleanup EXIT - -if ! metro_running; then - /opt/dev/bin/dev react-native start >"$METRO_LOG" 2>&1 & - METRO_PID="$!" -fi - -wait_for_metro -prepare_e2e_env -ENVFILE="$E2E_ENV_FILE" pnpm sample android --extra-params "--refresh-dependencies" - -( - cd "$REPO_ROOT/e2e" - MAESTRO_ARGS=(--platform android test --config config.yaml) - - if [ -n "$INCLUDE_TAGS" ]; then - MAESTRO_ARGS+=(--include-tags "$INCLUDE_TAGS") - fi - - if [ -n "$EXCLUDE_TAGS" ]; then - MAESTRO_ARGS+=(--exclude-tags "$EXCLUDE_TAGS") - fi - - "$MAESTRO" "${MAESTRO_ARGS[@]}" \ - -e "E2E_APP_ID=${APP_ID}" \ - -e "E2E_READY_MARKER=${READY_MARKER}" \ - -e "E2E_CONTROL_LINK=${CONTROL_LINK}" \ - tests -) diff --git a/platforms/react-native/scripts/e2e_maestro_ios b/platforms/react-native/scripts/e2e_maestro_ios deleted file mode 100755 index 076b21b13..000000000 --- a/platforms/react-native/scripts/e2e_maestro_ios +++ /dev/null @@ -1,143 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -REPO_ROOT="$(cd "$ROOT_DIR/../.." && pwd)" -METRO_LOG="${TMPDIR:-/tmp}/checkout-kit-rn-ios-metro.log" -METRO_PID="" -E2E_ENV_FILE="" -APP_ID="com.shopify.checkoutkit.reactnativedemo" -CONTROL_LINK="${APP_ID}://e2e" -READY_MARKER="checkout-kit-sample-ready" -INCLUDE_TAGS="" -EXCLUDE_TAGS="flaky,wip" - -usage() { - cat <&2 -Usage: dev rn e2e ios [--tags TAG[,TAG...]] [--exclude-tags TAG[,TAG...]] - -Runs the React Native iOS Maestro tests against the released native SDK artifacts. -Without --tags, every test in e2e/tests runs. Tags come from the taxonomy: journey -(launch, cart, checkout, account), cost tier (smoke, full), quarantine (flaky, wip) -and platform capability (ios-only, android-only). - -Options: - --tags TAG[,TAG...] Run only tests carrying any of these tags. - --exclude-tags TAG[,TAG...] Skip tests carrying any of these tags. Defaults to flaky,wip. -EOF -} - -while [ "$#" -gt 0 ]; do - case "$1" in - --tags) - INCLUDE_TAGS="${2:-}" - if [ -z "$INCLUDE_TAGS" ]; then - usage - echo "--tags needs a comma separated tag list" >&2 - exit 1 - fi - shift 2 - ;; - --tags=*) - INCLUDE_TAGS="${1#--tags=}" - shift - ;; - --exclude-tags) - EXCLUDE_TAGS="${2:-}" - shift 2 - ;; - --exclude-tags=*) - EXCLUDE_TAGS="${1#--exclude-tags=}" - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - usage - echo "Unknown option: $1" >&2 - exit 1 - ;; - esac -done - -metro_running() { - curl --silent --fail http://localhost:8081/status | grep -q "packager-status:running" -} - -cleanup() { - if [ -n "$METRO_PID" ]; then - kill "$METRO_PID" 2>/dev/null || true - fi - - if [ -n "$E2E_ENV_FILE" ]; then - rm -f "$E2E_ENV_FILE" - fi -} - -prepare_e2e_env() { - E2E_ENV_FILE="$(mktemp "${TMPDIR:-/tmp}/checkout-kit-rn-e2e-env.XXXXXX")" - - if [ -f "$ROOT_DIR/sample/.env" ]; then - cp "$ROOT_DIR/sample/.env" "$E2E_ENV_FILE" - printf "\n" >>"$E2E_ENV_FILE" - fi - - printf "CHECKOUT_KIT_E2E_DISABLE_LOGBOX=1\n" >>"$E2E_ENV_FILE" -} - -wait_for_metro() { - for _ in $(seq 1 60); do - if metro_running; then - return 0 - fi - sleep 1 - done - - echo "Timed out waiting for Metro. Last log lines:" >&2 - tail -n 40 "$METRO_LOG" >&2 || true - return 1 -} - -# Resolve the pinned Maestro by path. A Maestro from PATH is whatever the machine happens to -# hold, and a version below 2.0.7 passes every iOS test locally while failing every one of -# them on the real devices that CI uses. -resolve_maestro() { - MAESTRO="$("$REPO_ROOT/e2e/scripts/maestro_bin")" -} - -cd "$ROOT_DIR" - -resolve_maestro -trap cleanup EXIT - -if ! metro_running; then - /opt/dev/bin/dev react-native start >"$METRO_LOG" 2>&1 & - METRO_PID="$!" -fi - -wait_for_metro -prepare_e2e_env -ENVFILE="$E2E_ENV_FILE" pnpm sample ios - -( - cd "$REPO_ROOT/e2e" - MAESTRO_ARGS=(--platform ios test --config config.yaml) - - if [ -n "$INCLUDE_TAGS" ]; then - MAESTRO_ARGS+=(--include-tags "$INCLUDE_TAGS") - fi - - if [ -n "$EXCLUDE_TAGS" ]; then - MAESTRO_ARGS+=(--exclude-tags "$EXCLUDE_TAGS") - fi - - "$MAESTRO" "${MAESTRO_ARGS[@]}" \ - -e "E2E_APP_ID=${APP_ID}" \ - -e "E2E_READY_MARKER=${READY_MARKER}" \ - -e "E2E_CONTROL_LINK=${CONTROL_LINK}" \ - tests -) diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EController.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EController.swift index 549434217..f7e940f4f 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EController.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EController.swift @@ -30,6 +30,10 @@ final class E2EController { private let target: E2ECommandTarget + /// Chains every perform(_:) onto the previous one, because @MainActor alone does not + /// stop a second handle(url:) call from interleaving with the first across await points. + private var tail: Task? + init(target: E2ECommandTarget) { self.target = target } @@ -49,11 +53,21 @@ final class E2EController { return false } - await perform(link) + await enqueue(link) return true } + private func enqueue(_ link: E2EControlLink) async { + let previous = tail + let task = Task { + await previous?.value + await self.perform(link) + } + tail = task + await task.value + } + private func perform(_ link: E2EControlLink) async { do { switch link { diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControllerTests.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControllerTests.swift index cbd7c2c71..0cef923ea 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControllerTests.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControllerTests.swift @@ -85,17 +85,61 @@ class E2EControllerTests: XCTestCase { XCTAssertEqual(target.calls, ["report(signIn is not implemented yet)"]) } + func testSerializesConcurrentHandleCallsSoTheSecondDoesNotStartUntilTheFirstFinishes() async { + let target = E2ECommandTargetSpy() + let controller = E2EController(target: target) + let firstResetStarted = Gate() + let releaseFirstReset = Gate() + target.onResetCart = { + await firstResetStarted.open() + await releaseFirstReset.wait() + } + + let first = Task { await controller.handle(url: "com.shopify.checkoutkit.swiftdemo://e2e/reset") } + await firstResetStarted.wait() + + let second = Task { await controller.handle(url: "com.shopify.checkoutkit.swiftdemo://e2e/reset") } + try? await Task.sleep(nanoseconds: 10_000_000) + + XCTAssertEqual(target.calls, ["resetCart"]) + + await releaseFirstReset.open() + _ = await first.value + _ = await second.value + + XCTAssertEqual(target.calls, ["resetCart", "resetCart"]) + } + @discardableResult private func handle(_ path: String, _ target: E2ECommandTargetSpy) async -> Bool { await E2EController(target: target).handle(url: "com.shopify.checkoutkit.swiftdemo://e2e\(path)") } } +private actor Gate { + private var isOpen = false + private var continuation: CheckedContinuation? + + func open() { + isOpen = true + continuation?.resume() + continuation = nil + } + + func wait() async { + if isOpen { + return + } + await withCheckedContinuation { continuation = $0 } + } +} + @MainActor private class E2ECommandTargetSpy: E2ECommandTarget { var calls: [String] = [] var variantIdError: Error? var addCartLineError: Error? + var onResetCart: (() async -> Void)? func selectBuyerIdentityMode(_ mode: BuyerIdentityMode) async { calls.append("selectBuyerIdentityMode(\(mode.rawValue))") @@ -103,6 +147,7 @@ private class E2ECommandTargetSpy: E2ECommandTarget { func resetCart() async { calls.append("resetCart") + await onResetCart?() } func variantId(atProductIndex index: Int) async throws -> String {