diff --git a/.env.example b/.env.example index c0f22f8bc..664e625d8 100644 --- a/.env.example +++ b/.env.example @@ -13,8 +13,8 @@ STOREFRONT_ACCESS_TOKEN=your-public-storefront-access-token # Optional Apple Pay merchant identifier used by accelerated checkout flows. STOREFRONT_MERCHANT_IDENTIFIER= -# Apple team that signs the Swift sample. Leave blank to keep the Shopify team. -# Set your own team id to build the sample on a device. +# Apple team that signs the Swift samples. Leave blank to keep the Shopify team. +# Set your own team id to build the samples on a device. DEVELOPMENT_TEAM= # Storefront API version @@ -25,15 +25,27 @@ CUSTOMER_ACCOUNT_API_CLIENT_ID= CUSTOMER_ACCOUNT_API_SHOP_ID= CUSTOMER_ACCOUNT_API_VERSION=2026-04 -# Buyer identity defaults used by sample apps +# Customer account used by the Maestro account journey (optional). +# CI supplies these from Bitrise secrets. Leave both blank to skip the account tests. +E2E_CUSTOMER_ACCOUNT_EMAIL= +E2E_CUSTOMER_ACCOUNT_CODE= + +# User agent suffix the samples add to the customer account login web view (optional). +# CI supplies this from Bitrise secrets. Leave it blank for normal sample use. +CUSTOM_USER_AGENT= + +# Buyer identity defaults used by sample apps. +# Keep the address in the E2E shop's own country. A foreign address changes the billing +# country, the province list, the postal field label and the currency, so the checkout +# flows then look for fields that the form does not show. EMAIL=checkout-kit@example.com -ADDRESS_1=650 King Street +ADDRESS_1=700 S Flower St ADDRESS_2=Shopify HQ -CITY=Toronto +CITY=Los Angeles COMPANY=Shopify -COUNTRY=CA +COUNTRY=US FIRST_NAME=Evelyn LAST_NAME=Hartley -PROVINCE=ON -ZIP=M5V 1M7 -PHONE=+14165550100 +PROVINCE=CA +ZIP=90017 +PHONE=+12135550142 diff --git a/.github/workflows/android-test.yml b/.github/workflows/android-test.yml index f92ce2b66..b7cf7ac0b 100644 --- a/.github/workflows/android-test.yml +++ b/.github/workflows/android-test.yml @@ -122,16 +122,6 @@ jobs: run: ./gradlew assembleDebug working-directory: platforms/android/samples/CheckoutKitAndroidDemo - - name: Upload sample test reports - if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: android-sample-test-reports - path: | - platforms/android/samples/CheckoutKitAndroidDemo/**/build/reports/tests/ - platforms/android/samples/CheckoutKitAndroidDemo/**/build/test-results/ - if-no-files-found: warn - lint: runs-on: ubuntu-latest timeout-minutes: 20 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b360a66f..5b7aa3f5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,6 +113,8 @@ jobs: - 'e2e/lib/**' - 'e2e/test/**' - 'e2e/scripts/**' + - 'e2e/tests/**' + - 'e2e/config.yaml' - 'e2e/config/matrix.yml' - '.ci/changed-file-filters.yml' - '.github/workflows/ci.yml' diff --git a/.github/workflows/swift-build-and-test-samples.yml b/.github/workflows/swift-build-and-test-samples.yml index 17042fa6d..0cc601740 100644 --- a/.github/workflows/swift-build-and-test-samples.yml +++ b/.github/workflows/swift-build-and-test-samples.yml @@ -11,6 +11,6 @@ jobs: build-and-test-samples: uses: ./.github/workflows/swift-test-workflow.yml with: - test-path: ./Scripts/build_samples && ./Scripts/test_samples + test-path: ./Scripts/build_and_test_samples job-name: Build & Test Sample Apps setup-storefront-env: true diff --git a/dev.yml b/dev.yml index 2e954423a..941d0aea3 100644 --- a/dev.yml +++ b/dev.yml @@ -233,6 +233,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: ./platforms/android/scripts/e2e_maestro "$@" + test: desc: Run all library and demo app tests run: | @@ -367,6 +372,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: ./platforms/swift/Scripts/e2e_maestro "$@" + test: desc: | `dev swift test` - Run all tests for the ShopifyCheckoutKit-Package and the sample apps. @@ -502,19 +513,19 @@ commands: cd sample/android USE_LOCAL_SDK=1 ./gradlew :shopify_checkout-kit-react-native:test --refresh-dependencies e2e: - desc: Run React Native sample Maestro checkout smoke flows - syntax: "{ios|android} [--guest] [--hardcoded-buyer-identity]" + desc: Run React Native sample Maestro tests, filtered by tag + syntax: "{ios|android} [--tags TAG[,TAG...]] [--exclude-tags TAG[,TAG...]]" run: | - echo "Usage: dev rn e2e {ios|android} [--guest] [--hardcoded-buyer-identity]" >&2 + echo "Usage: dev rn e2e {ios|android} [--tags TAG[,TAG...]] [--exclude-tags TAG[,TAG...]]" >&2 exit 1 subcommands: ios: - desc: Run the React Native iOS Maestro checkout smoke flows - syntax: "[--guest] [--hardcoded-buyer-identity]" + 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 "$@" android: - desc: Run the React Native Android Maestro checkout smoke flows - syntax: "[--guest] [--hardcoded-buyer-identity]" + 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 "$@" lint: desc: Run all React Native lint checks (Swift, module, sample) diff --git a/e2e/BITRISE.md b/e2e/BITRISE.md index 2542344d6..6bf36e283 100644 --- a/e2e/BITRISE.md +++ b/e2e/BITRISE.md @@ -112,9 +112,24 @@ The launch smoke suite sends only non-sensitive Maestro environment values to Br - `E2E_APP_ID` - `E2E_READY_MARKER` +- `E2E_CONTROL_LINK` Do not pass storefront tokens or customer data through BrowserStack Maestro environment variables without explicit review, because those values are visible in BrowserStack dashboards. +## Account journey secrets + +The account journey signs a test customer in, so it needs two more Maestro values. Both are reviewed exceptions to the rule above, because the BrowserStack dashboard is private to this organization and the values never enter the repository. + +| Variable | Where it is used | Purpose | +| ---------------------------- | -------------------------------------- | ---------------------------------------------------------------- | +| `E2E_CUSTOMER_ACCOUNT_EMAIL` | Maestro environment | Address the sign-in flow types on the hosted login page. | +| `E2E_CUSTOMER_ACCOUNT_CODE` | Maestro environment | Verification code the sign-in flow types. | +| `CUSTOM_USER_AGENT` | Sample app build, through `.env` | Marks the login web view as a test client. The code fails without it. | + +Add all three as secret Bitrise environment variables. Leave them out of any log, any pull request, and any file in this repository. `scripts/setup_storefront_env` reads `CUSTOM_USER_AGENT` from the root `.env` and writes it into each generated sample configuration, all of which are ignored by git. + +A run with these values missing skips the account tests instead of failing, so a fork without the secrets still gets a green suite. + ## GitHub reporting The `e2e-report` workflow creates commit statuses, Check Runs, and sticky PR comments using the short-lived token generated by the Bitrise GitHub App. diff --git a/e2e/FLAKES.md b/e2e/FLAKES.md new file mode 100644 index 000000000..cabaeca07 --- /dev/null +++ b/e2e/FLAKES.md @@ -0,0 +1,57 @@ +# E2E flake register + +A flake is a test that gives a different result from the same code. Each entry below +records one, with the evidence that proves it. Clear every open entry before release. + +Add an entry when a run fails and a re-run of the same commit passes. Record the target, +the symptom, the date, and the artifact directory. Delete the entry when the fix lands. + +## Open flakes + +| # | Target | Symptom | Seen | Suspected cause | +|---|---|---|---|---| +| F1 | React Native iOS | `launch-smoke` fails within 10 seconds. The driver answers `viewHierarchy` with HTTP 500 and `kAXErrorInvalidUIElement`, or Maestro reports `App crashed or stopped`. The screenshot shows the splash screen and the crash log directory holds no report. | 2026-07-31, 2 runs of 3 | Maestro reads the accessibility tree while the app still builds it. `extendedWaitUntil` retries a missing element, but it does not retry a driver error. | +| F2 | React Native Android | The first `cart-checkout-ready` assert in `bootstrap-cart-from-link.yaml` fails after 53 seconds. The retry in that flow then passes. | 2026-07-31, 1 run of 2 | Metro builds the bundle on request, so the first cart create starts late. | +| F3 | React Native Android on BrowserStack | `checkout-hardcoded-buyer-identity` fails on one commit and passes on the next, with no change to the test or the sample. PR #561 build `0f61b12e` fails after 152 seconds. PR #562 build `0f61b12e` passes. | 2026-07-31, 1 run of 2 | Unknown. Read the Maestro log for both sessions before you treat it as a flake, because [B2](#browserstack-only-failures) may explain both. | + +## BrowserStack-only failures + +These fail on every BrowserStack run and pass on every local run, so they are not flakes. +They block the whole matrix. Remove this section when the matrix reports green. + +| # | Target | Symptom | Suspected cause | +|---|---|---|---| +| B1 | Swift iOS and React Native iOS | Every test that opens the control link fails 1 second after launch. `launch-smoke` passes, so the app starts and the ready marker appears. | Fixed, and proved by two probe builds. BrowserStack ran Maestro 1.39.10, which drives iOS `openLink` through `xcrun simctl openurl`. That command accepts a simulator only, so a real device answers `Invalid device` and exit code 148. BrowserStack added iOS `openLink` in 2.0.7, so the build now pins `maestroVersion`. Maestro 2.4.0 alone still fails, because it implements iOS `clearState` by uninstalling the app and the BrowserStack reinstall reports success without restoring the app. Eleven measured builds separate the two faults: eight with `clearState` failed on five different units, and three without it passed, two of them on units that had just failed. The flag is therefore deterministic, not flaky, and a retry cannot recover it. With both changes `cart-from-control-link` passes in 17 seconds and `openLink` returns in 36 milliseconds through the XCTest driver. | +| B2 | Kotlin Android and React Native Android | `checkout-hardcoded-buyer-identity` fails 2 to 6 minutes in. `launch-smoke`, `cart-from-control-link` and `checkout-present-and-close` all pass, so the control link and the cart work. | Fixed. Two separate faults. First, the flow matched `^First name$` and the field reads `First name (optional)`, so the tap timed out. Second, Bitrise holds no address secrets, so `scripts/setup_storefront_env` fell back to `Toronto, ON, M5V 1M7, CA`. A Canadian address sets the billing country, the province list, the postal field label and the currency, and the flow expects `ZIP code`. The defaults are now the US fixture. Both targets now pass. | +| B3 | Kotlin Android and React Native Android | `checkout-customer-account` fails after 69 seconds, at `Assert that "^Email( address)?$" is visible`. | Confirmed, and it is a missing secret rather than a defect. The sign-in web view opens `http://null/oauth/authorize` with an empty `client_id`, because Bitrise supplies no `CUSTOMER_ACCOUNT_API_CLIENT_ID` and no `CUSTOMER_ACCOUNT_API_SHOP_ID`. The sample reads both at build time. `account_enabled?` now requires all four account values, so the run excludes the account tag until someone adds the two secrets. | +| B4 | Kotlin Android | `checkout-guest` fails after 127 seconds, at `Scrolling DOWN until "Country/Region" is visible`. React Native Android passes the same test in 358 seconds. | Confirmed, and it is a sample defect rather than a BrowserStack fault, so it reproduces anywhere. The Android sample gives a guest cart a Canadian buyer identity: `CartRepository.kt:102` returns `CartBuyerIdentityInput(countryCode = CA)`. Swift returns `CartInput(lines:)` and React Native returns `{}`, so both take the shop market. Checkout then renders a Canadian form and totals in CAD, while the shared fixture is a United States address. The label node matches the text but reports `height=0` and the same bounds through all six swipes, so the scroll can never satisfy it. The React Native log proves the contrast: `United States` is already visible there, so the country picker step is skipped. | + +## Runner limits, not product faults + +| # | Symptom | Cause | +|---|---|---| +| R1 | A second Maestro run fails with `Connection refused: localhost:7001`. | The iOS driver and the Android driver both bind port 7001. Run one target at a time. | + +## Traps that give a false result + +These produce a wrong verdict rather than an unstable one. The flows already avoid them. +Keep them out of new flows. + +- `visibilityPercentage` under 100 rounds down to zero. `scrollUntilVisible` then stops + before it moves, and the tap that follows lands on whatever already sat there. Use + `centerElement: true`. +- Android `hideKeyboard` sends a Back press when no keyboard is open, which closes the + checkout sheet. Only run `dismiss-active-field.yaml` after an `inputText`. +- Maestro reports a tap that changes nothing as `COMPLETED`. The log line reads + `Nothing changed in the UI`. Assert the result of a tap, never the tap itself. +- A runner that does not set `E2E_DEVICE_ID` lets Maestro pick the device. It can pick the + other platform, and the run then reports the wrong target. +- An unsigned iOS build carries no `application-identifier`, so every keychain write returns + `-34018` and the sample drops the customer from the cart. Checkout then opens as a guest + and the account test fails far from the cause. Build the sample with `CODE_SIGN_IDENTITY=-`. + +## Evidence + +Maestro writes one directory per run to `~/.maestro/tests//`. It holds +`maestro.log`, the failure screenshot, and one commands file per test. Read `maestro.log`, +because the commands file carries no order and no status. diff --git a/e2e/README.md b/e2e/README.md index 254db92f1..f0e91daff 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -3,95 +3,111 @@ This directory contains Maestro end-to-end flows and configuration for Checkout Kit sample apps. Two complementary setups live here: -- A **local** React Native checkout smoke 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. -- A **CI matrix** that expands applications, OS version tags, and suites into - BrowserStack Maestro run rows, starting with a shared launch smoke. +- **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. + +Both paths call `scripts/run_maestro`, so they share one environment contract. ## Run locally Run `dev up` first to provision the local toolchain. Install Maestro separately and make sure `maestro --version` succeeds before running these flows. -### React Native checkout smoke (`dev rn e2e`) +### One command per target -Run the matching command from the repo root. +Boot a simulator or emulator first, because Maestro drives the device the app runs +on. Then run the matching command from the repo root. -React Native 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` | -```bash -dev rn e2e ios -``` - -React Native Android: +Each command runs every test in `tests/`. Narrow a run with `--tags`: ```bash -dev rn e2e android +dev swift e2e --tags cart +dev rn e2e ios --tags checkout +dev android e2e --tags cart,checkout ``` -Run one or more focused React Native scenarios by passing scenario flags: +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. `config.yaml` quarantines `flaky` and `wip` +for every run, so those need no command line option. -```bash -dev rn e2e ios --guest -dev rn e2e ios --hardcoded-buyer-identity -dev rn e2e ios --guest --hardcoded-buyer-identity -dev rn e2e android --guest -dev rn e2e android --hardcoded-buyer-identity -dev rn e2e android --guest --hardcoded-buyer-identity -``` +### Tags -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 -setup is required. +Every test declares tags from this taxonomy. `e2e/test/maestro_test_tags_test.rb` +enforces it. -### Shared launch smoke +| Group | Tags | Rule | +|---|---|---| +| Journey | `launch`, `cart`, `checkout`, `account` | Exactly one per test | +| Cost tier | `smoke`, `full` | Exactly one per test | +| Quarantine | `flaky`, `wip` | Excluded by default, in `config.yaml` | +| Platform capability | `ios-only`, `android-only` | Needs a `# Platform capability:` comment | -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. +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. -React Native iOS: +Every command builds the sample app, installs it, and then calls +`scripts/run_maestro`. The React Native commands 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. -```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 E2E runs should use the released native SDK artifacts declared by +the React Native sample configuration, not local in-repo native SDK overrides. -React Native Android: +### The control link -```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 +The samples share one command channel: a deep link on the app's own scheme. + +``` +://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 -CI runs are described by `config/matrix.yml`. The matrix expands applications, OS -version tags, and suites into a BrowserStack run plan. Because Bitrise has no -built-in matrix support, `e2e/lib/e2e_matrix_to_browserstack_run_plan.rb` -transforms the matrix into a BrowserStack run plan and the pipeline -parallelizes over the resulting rows. +CI runs are described by `config/matrix.yml`. The matrix expands applications and +OS version tags into a BrowserStack run plan. Because Bitrise has no built-in +matrix support, `e2e/lib/e2e_matrix_to_browserstack_run_plan.rb` transforms the +matrix into a BrowserStack run plan and the pipeline parallelizes over the +resulting rows. Current applications: - React Native iOS sample app - React Native Android sample app +- Kotlin Android sample app +- Swift iOS sample app Current OS version tags: - `latest` -Current suites: +Every run executes the whole `tests/` folder. The top-level `tags:` block sets the +default include and exclude lists, and an application may override either one to +adopt a test before the others carry it. Adding a test adds no rows here. -- `tests/shared/launch-smoke.yaml` +The run plan derives `E2E_CONTROL_LINK` as `://e2e`, because the deep link +scheme equals the app id on all four targets. Each run also supplies `E2E_APP_ID` +and `E2E_READY_MARKER`, and the local runners supply the same three names. Validate the matrix: @@ -119,30 +135,56 @@ ruby e2e/scripts/e2e_matrix_to_browserstack_run_plan count ## Files -- `config.yaml` configures Maestro for shared platform behavior. +- `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/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. +- `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/shared/checkout-present-and-close.yaml` opens checkout and closes it. +- `tests/shared/checkout-hardcoded-buyer-identity.yaml` orders from a cart that + already carries the contact and the delivery address. +- `tests/shared/checkout-guest.yaml` orders from an empty identity, so checkout + asks for the contact and the delivery address as well as the payment. +- `tests/shared/checkout-customer-account.yaml` signs a customer in, then orders. + Checkout reads the contact and the delivery address from the account. +- `FLAKES.md` records every unstable test and every trap that gives a false result. +- `scripts/run_maestro` is the single Maestro invocation every local runner calls. + It holds the environment contract and the workspace root rule 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 suite. + +Maestro resolves the `flows:` glob in `config.yaml` relative to the path on the +command line, so both paths pass the workspace root. `scripts/zip_e2e_tests` puts +`config.yaml`, `tests/` and `flows/` side by side, which makes the zip root that +same workspace root. ## 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.yaml b/e2e/config.yaml index 478e48312..113d3a157 100644 --- a/e2e/config.yaml +++ b/e2e/config.yaml @@ -1,6 +1,11 @@ flows: - tests/**/*.yaml +# Quarantined tests stay in the tree and out of every run until someone fixes them. +excludeTags: + - flaky + - wip + platform: ios: # Lets Maestro inspect elements presented inside iOS checkout modal views. diff --git a/e2e/config/matrix.yml b/e2e/config/matrix.yml index 2c697ae04..7ebbea1bf 100644 --- a/e2e/config/matrix.yml +++ b/e2e/config/matrix.yml @@ -1,5 +1,18 @@ version: 1 changed_file_filters: .ci/changed-file-filters.yml +tests_path: tests +# Every run executes the whole tests folder and lets tags decide what runs there. +# An application may override either list to adopt a test ahead of the others. +tags: + include: + - launch + - cart + - checkout + # execute_browserstack_run drops this tag again when the account secrets are absent. + - account + exclude: + - flaky + - wip applications: - id: react-native-ios target: react-native @@ -52,6 +65,3 @@ applications: - ciFilters os_version_tags: - latest -suites: - - id: launch-smoke - execute: tests/shared/launch-smoke.yaml diff --git a/e2e/flows/app/bootstrap-cart-from-link.yaml b/e2e/flows/app/bootstrap-cart-from-link.yaml index 73e346d38..ad075343d 100644 --- a/e2e/flows/app/bootstrap-cart-from-link.yaml +++ b/e2e/flows/app/bootstrap-cart-from-link.yaml @@ -1,21 +1,6 @@ -appId: ${APP_ID} +appId: ${E2E_APP_ID} --- -- launchApp: - clearState: true - arguments: - # iOS-only launch arguments; Android ignores them. - AppleLocale: en_US - AppleLanguages: "(en)" -- extendedWaitUntil: - visible: - id: checkout-kit-sample-ready - 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: "${CART_BOOTSTRAP_BASE_LINK}&buyerIdentityMode=${BUYER_IDENTITY_MODE}" -- waitForAnimationToEnd -- extendedWaitUntil: - visible: - id: cart-checkout-ready - timeout: 10000 +# Most tests want a clean app holding one seeded cart. The account journey needs the two +# halves apart, because it signs in between them, so each half is its own flow. +- runFlow: launch.yaml +- runFlow: seed-cart-from-link.yaml diff --git a/e2e/flows/app/confirm-open-link.yaml b/e2e/flows/app/confirm-open-link.yaml new file mode 100644 index 000000000..157e82ea0 --- /dev/null +++ b/e2e/flows/app/confirm-open-link.yaml @@ -0,0 +1,10 @@ +appId: ${E2E_APP_ID} +--- +# 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 diff --git a/e2e/flows/app/launch.yaml b/e2e/flows/app/launch.yaml new file mode 100644 index 000000000..edd6d2b9e --- /dev/null +++ b/e2e/flows/app/launch.yaml @@ -0,0 +1,20 @@ +appId: ${E2E_APP_ID} +--- +# No clearState. Maestro 2.4.0 implements it on iOS by uninstalling the app, and on a +# BrowserStack real device the reinstall reports success and never restores the app, so the +# launch that follows lands on the home screen. Eight builds carrying the flag failed on +# five different units, and three builds without it passed, two of them on units that had +# just failed. The fault is therefore deterministic, and a retry cannot recover it. +# Seeding the cart resets it anyway, and every test that submits an order names its own +# buyerIdentityMode, so nothing here needs it. +- launchApp: + arguments: + # iOS-only launch arguments; Android ignores them. + AppleLocale: en_US + AppleLanguages: "(en)" +# A cold first launch loads the JavaScript bundle on React Native, so the marker takes +# far longer here than on the native samples. BrowserStack devices are slower again. +- extendedWaitUntil: + visible: + id: ${E2E_READY_MARKER} + timeout: 60000 diff --git a/e2e/flows/app/seed-cart-from-link.yaml b/e2e/flows/app/seed-cart-from-link.yaml new file mode 100644 index 000000000..f3b112fe7 --- /dev/null +++ b/e2e/flows/app/seed-cart-from-link.yaml @@ -0,0 +1,24 @@ +appId: ${E2E_APP_ID} +--- +# The launch wait 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. A running app is what the account journey needs: + # 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 + - runFlow: confirm-open-link.yaml + # 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/sign-in-customer-account.yaml b/e2e/flows/app/sign-in-customer-account.yaml new file mode 100644 index 000000000..b190b5e18 --- /dev/null +++ b/e2e/flows/app/sign-in-customer-account.yaml @@ -0,0 +1,52 @@ +appId: ${E2E_APP_ID} +--- +# The control link opens the sample's login screen, which hosts the Shopify sign-in page +# in a WebView. Every target configures that WebView without persistence, so no session +# survives a launch and every run types the address again. +- openLink: "${E2E_CONTROL_LINK}/signIn" +- waitForAnimationToEnd +- runFlow: confirm-open-link.yaml +- extendedWaitUntil: + visible: + text: "^Email( address)?$" + timeout: 60000 +- tapOn: + text: "^Email( address)?$" +- inputText: ${E2E_CUSTOMER_ACCOUNT_EMAIL} +# The page reads the address as it is typed. An address that owns a Shop account gets a +# "Continue with shop" button, and every other address gets "Continue". Only the code route +# works on all four targets, so this flow takes "Use email instead" whenever the page shows +# it, and falls back to "Continue". +- extendedWaitUntil: + visible: + text: "^(Continue|Use email instead)$" + timeout: 30000 +# The "Continue" branch comes first and reads the same element the second branch taps, so +# only one of the two ever runs. The other order lets a "Continue" on the code page match +# after "Use email instead" has already moved the page on. +- runFlow: + when: + notVisible: "^Use email instead$" + visible: "^Continue$" + commands: + - tapOn: + text: "^Continue$" +- runFlow: + when: + visible: "^Use email instead$" + commands: + - tapOn: + text: "^Use email instead$" +- extendedWaitUntil: + visible: + text: "^6-digit code$" + timeout: 30000 +- tapOn: + text: "^6-digit code$" +- inputText: ${E2E_CUSTOMER_ACCOUNT_CODE} +# The page submits the code itself, and each sample then shows its account screen. Swift +# dismisses the login sheet, Android leaves the login route, and React Native goes back. +- extendedWaitUntil: + visible: + id: account-signed-in-view + timeout: 60000 diff --git a/e2e/flows/checkout/assert-buyer-prefilled.yaml b/e2e/flows/checkout/assert-buyer-prefilled.yaml new file mode 100644 index 000000000..c81c3d3b2 --- /dev/null +++ b/e2e/flows/checkout/assert-buyer-prefilled.yaml @@ -0,0 +1,12 @@ +appId: ${E2E_APP_ID} +--- +# The signed-in buyer is the point of this journey. Checkout reads the contact address and +# the saved delivery address from the account, so both appear before the test types +# anything. A guest cart shows an empty contact field and a delivery form instead, so these +# two assertions separate a real customer cart from a cart that lost its buyer identity. +- extendedWaitUntil: + visible: + text: "${E2E_CUSTOMER_ACCOUNT_EMAIL}" + timeout: 30000 +- assertVisible: + text: "^Ship to$" diff --git a/e2e/flows/checkout/assert-complete.yaml b/e2e/flows/checkout/assert-complete.yaml index 85015b417..b91f2028b 100644 --- a/e2e/flows/checkout/assert-complete.yaml +++ b/e2e/flows/checkout/assert-complete.yaml @@ -1,5 +1,7 @@ -appId: ${APP_ID} +appId: ${E2E_APP_ID} --- +# The payment provider and the order creation both run before the page changes, so this +# wait covers two remote systems rather than a render. - extendedWaitUntil: visible: "${POST_SUBMIT_RESULT_PATTERN}" - timeout: 10000 + timeout: 60000 diff --git a/e2e/flows/checkout/assert-dismissed.yaml b/e2e/flows/checkout/assert-dismissed.yaml new file mode 100644 index 000000000..77ad554a5 --- /dev/null +++ b/e2e/flows/checkout/assert-dismissed.yaml @@ -0,0 +1,14 @@ +appId: ${E2E_APP_ID} +--- +# The close control is the one element that exists only while checkout is on screen, so its +# absence proves dismissal. Asserting on checkout copy instead would be wrong: the React +# Native catalog sells a product called "Email", which the checkout field pattern matches. +# close.yaml lists why the identifier differs per SDK version. Only one of the three is ever +# present, so all three must be absent once the sheet is gone. +- extendedWaitUntil: + notVisible: + id: "shopify_checkout_kit_close_button" + timeout: 30000 +- assertNotVisible: + id: "checkoutKitCloseBtn" +- assertNotVisible: "Close" diff --git a/e2e/flows/checkout/assert-returned-to-empty-cart.yaml b/e2e/flows/checkout/assert-returned-to-empty-cart.yaml index 6ef334dde..c5005b9ea 100644 --- a/e2e/flows/checkout/assert-returned-to-empty-cart.yaml +++ b/e2e/flows/checkout/assert-returned-to-empty-cart.yaml @@ -1,25 +1,15 @@ -appId: ${APP_ID} +appId: ${E2E_APP_ID} --- -# RN E2E runs against released native SDK artifacts. Android 4.0.0-alpha.2 -# still exposes checkoutKitCloseBtn; #333 normalized the source id to -# shopify_checkout_kit_close_button. When RN nativeSdkVersions point at native -# SDK releases containing #333, replace this platform split with that id. -- runFlow: - when: - platform: android - commands: - - tapOn: - id: checkoutKitCloseBtn -- runFlow: - when: - platform: ios - commands: - - tapOn: "Close" +- runFlow: close.yaml +# The four samples return to four different screens after checkout closes, so the flow +# asserts nothing about which screen that is. The cart control belongs to the app chrome +# and stays on screen everywhere, which makes it both the "we are back" signal and the +# way to the cart. - extendedWaitUntil: visible: - id: catalog-tab - timeout: 10000 + id: cart-tab + timeout: 30000 - tapOn: id: cart-tab diff --git a/e2e/flows/checkout/close.yaml b/e2e/flows/checkout/close.yaml new file mode 100644 index 000000000..8bd61d89f --- /dev/null +++ b/e2e/flows/checkout/close.yaml @@ -0,0 +1,27 @@ +appId: ${E2E_APP_ID} +--- +# The close control carries a different identifier per SDK version, not per platform. +# The Swift and Kotlin samples build the in-repo SDK, which uses the normalized id from +# #333. React Native resolves released 4.0.0-alpha.2 artifacts, where Android still +# exposes checkoutKitCloseBtn and iOS exposes only the "Close" label. Delete the two +# fallback branches once nativeSdkVersions point at releases containing #333. +- runFlow: + when: + visible: + id: "shopify_checkout_kit_close_button" + commands: + - tapOn: + id: "shopify_checkout_kit_close_button" +- runFlow: + when: + visible: + id: "checkoutKitCloseBtn" + commands: + - tapOn: + id: "checkoutKitCloseBtn" +- runFlow: + when: + visible: "Close" + commands: + - tapOn: "Close" +- waitForAnimationToEnd diff --git a/e2e/flows/checkout/dismiss-active-field.yaml b/e2e/flows/checkout/dismiss-active-field.yaml index 4b672f5ef..40b8c6f90 100644 --- a/e2e/flows/checkout/dismiss-active-field.yaml +++ b/e2e/flows/checkout/dismiss-active-field.yaml @@ -1,10 +1,19 @@ -appId: ${APP_ID} +appId: ${E2E_APP_ID} --- - runFlow: when: platform: android commands: - hideKeyboard +# iOS keeps the keyboard above the page until the accessory bar closes it. The next field +# then stays behind the keyboard, and the tap that follows lands on a key instead. +- runFlow: + when: + platform: ios + visible: "Done" + commands: + - tapOn: "Done" + - waitForAnimationToEnd - runFlow: when: platform: ios diff --git a/e2e/flows/checkout/fill-billing-address.yaml b/e2e/flows/checkout/fill-billing-address.yaml new file mode 100644 index 000000000..7ce5543f5 --- /dev/null +++ b/e2e/flows/checkout/fill-billing-address.yaml @@ -0,0 +1,38 @@ +appId: ${E2E_APP_ID} +--- +# Checkout offers a "use the shipping address" control only when it can copy the address +# the cart supplied. The samples build their carts through different mutations, so one +# target gets the control and another gets an empty billing form. The scroll below stops +# at whichever the target shows, and the branch fills the form only when there is one. +- scrollUntilVisible: + element: + text: "^(Billing address|Use shipping address as billing address)$" + direction: DOWN + centerElement: true +- runFlow: + when: + visible: "^Billing address$" + commands: + # Country/Region and Province already hold the shop defaults, so this flow leaves + # them alone and supplies an address that suits those defaults. + - tapOn: + text: "^First name( \\(optional\\))?$" + - inputText: "${BILLING_FIRST_NAME}" + - runFlow: dismiss-active-field.yaml + - tapOn: + text: "^Last name$" + - inputText: "${BILLING_LAST_NAME}" + - runFlow: dismiss-active-field.yaml + - tapOn: + text: "^Address$" + - inputText: "${BILLING_ADDRESS_LINE1}" + - runFlow: dismiss-active-field.yaml + - tapOn: + text: "^City$" + - inputText: "${BILLING_CITY}" + - runFlow: dismiss-active-field.yaml + - tapOn: + text: "^${BILLING_POSTAL_FIELD_LABEL}$" + - inputText: "${BILLING_POSTAL_CODE}" + - runFlow: dismiss-active-field.yaml + - waitForAnimationToEnd diff --git a/e2e/flows/checkout/fill-contact.yaml b/e2e/flows/checkout/fill-contact.yaml index 2fdaf65b2..46f0ef4a3 100644 --- a/e2e/flows/checkout/fill-contact.yaml +++ b/e2e/flows/checkout/fill-contact.yaml @@ -1,25 +1,6 @@ -appId: ${APP_ID} +appId: ${E2E_APP_ID} --- -- tapOn: - text: "^Email( or mobile phone number)?$" -- waitForAnimationToEnd -- inputText: "${EMAIL}" -- runFlow: dismiss-active-field.yaml -- extendedWaitUntil: - visible: "^${EMAIL}$" -- tapOn: - text: "^First name( \\(optional\\))?$" -- waitForAnimationToEnd -- inputText: "${FIRST_NAME}" -- runFlow: dismiss-active-field.yaml -- scrollUntilVisible: - element: - text: "^Last name$" - direction: DOWN - visibilityPercentage: 100 - centerElement: true -- tapOn: - text: "^Last name$" -- waitForAnimationToEnd -- inputText: "${LAST_NAME}" -- runFlow: dismiss-active-field.yaml +# A guest types both halves. A signed-in buyer arrives with the email already attached, +# so the account journey runs fill-name.yaml on its own. +- runFlow: fill-email.yaml +- runFlow: fill-name.yaml diff --git a/e2e/flows/checkout/fill-email.yaml b/e2e/flows/checkout/fill-email.yaml new file mode 100644 index 000000000..22516ebbb --- /dev/null +++ b/e2e/flows/checkout/fill-email.yaml @@ -0,0 +1,9 @@ +appId: ${E2E_APP_ID} +--- +- tapOn: + text: "^Email( or mobile phone number)?$" +- waitForAnimationToEnd +- inputText: "${EMAIL}" +- runFlow: dismiss-active-field.yaml +- extendedWaitUntil: + visible: "^${EMAIL}$" diff --git a/e2e/flows/checkout/fill-name.yaml b/e2e/flows/checkout/fill-name.yaml new file mode 100644 index 000000000..b15c75c3c --- /dev/null +++ b/e2e/flows/checkout/fill-name.yaml @@ -0,0 +1,22 @@ +appId: ${E2E_APP_ID} +--- +# A signed-in buyer can arrive with either name already filled, and `inputText` appends +# rather than replaces, so each field is cleared before it is typed. +- tapOn: + text: "^First name( \\(optional\\))?$" +- waitForAnimationToEnd +- eraseText +- inputText: "${FIRST_NAME}" +- runFlow: dismiss-active-field.yaml +- scrollUntilVisible: + element: + text: "^Last name$" + direction: DOWN + visibilityPercentage: 100 + centerElement: true +- tapOn: + text: "^Last name$" +- waitForAnimationToEnd +- eraseText +- inputText: "${LAST_NAME}" +- runFlow: dismiss-active-field.yaml diff --git a/e2e/flows/checkout/fill-payment-card.yaml b/e2e/flows/checkout/fill-payment-card.yaml index a9d47ed95..6f68c89b6 100644 --- a/e2e/flows/checkout/fill-payment-card.yaml +++ b/e2e/flows/checkout/fill-payment-card.yaml @@ -1,4 +1,4 @@ -appId: ${APP_ID} +appId: ${E2E_APP_ID} --- - scrollUntilVisible: element: @@ -9,8 +9,9 @@ appId: ${APP_ID} text: "^Card number$" - waitForAnimationToEnd - inputText: "${CARD_NUMBER}" +# Checkout groups the digits, so the field never reads back the value the test typed. - extendedWaitUntil: - visible: "^${CARD_NUMBER}$" + visible: "^${CARD_NUMBER_DISPLAY}$" - runFlow: dismiss-active-field.yaml - scrollUntilVisible: element: @@ -31,4 +32,12 @@ appId: ${APP_ID} - waitForAnimationToEnd - inputText: "${CARD_SECURITY_CODE}" - runFlow: dismiss-active-field.yaml +- tapOn: + text: "^Name on card$" +- waitForAnimationToEnd +# Checkout copies the delivery name into this field when the delivery form holds one, and +# `inputText` appends. The erase makes the field empty first, whichever way it arrived. +- eraseText +- inputText: "${CARD_HOLDER_NAME}" +- runFlow: dismiss-active-field.yaml - waitForAnimationToEnd diff --git a/e2e/flows/checkout/fill-shipping-address.yaml b/e2e/flows/checkout/fill-shipping-address.yaml index d06090553..c911d1055 100644 --- a/e2e/flows/checkout/fill-shipping-address.yaml +++ b/e2e/flows/checkout/fill-shipping-address.yaml @@ -1,9 +1,13 @@ -appId: ${APP_ID} +appId: ${E2E_APP_ID} --- +# Every scroll below asks for `centerElement`. A `visibilityPercentage` under 100 rounds +# down to zero instead, which lets the scroll stop before it moves, and the tap that +# follows then lands on whatever already sat there. - scrollUntilVisible: element: text: "Country/Region" direction: DOWN + centerElement: true # If the country is already selected and visible, skip this. Otherwise open the # picker, only scroll if needed, then select the configured country. @@ -22,7 +26,7 @@ appId: ${APP_ID} element: text: "^${COUNTRY_LABEL}$" direction: DOWN - visibilityPercentage: 10 + centerElement: true optional: true - runFlow: when: @@ -32,7 +36,7 @@ appId: ${APP_ID} element: text: "^${COUNTRY_LABEL}$" direction: UP - visibilityPercentage: 10 + centerElement: true - tapOn: text: "^${COUNTRY_LABEL}$" - waitForAnimationToEnd @@ -72,7 +76,7 @@ appId: ${APP_ID} element: text: "^${STATE_LABEL}$" direction: DOWN - visibilityPercentage: 10 + centerElement: true optional: true - runFlow: when: @@ -82,12 +86,35 @@ appId: ${APP_ID} element: text: "^${STATE_LABEL}$" direction: UP - visibilityPercentage: 10 + centerElement: true - tapOn: text: "^${STATE_LABEL}$" - waitForAnimationToEnd +- scrollUntilVisible: + element: + text: "^${POSTAL_FIELD_LABEL}$" + direction: DOWN + centerElement: true - tapOn: text: "^${POSTAL_FIELD_LABEL}$" - waitForAnimationToEnd - inputText: "${POSTAL_CODE}" - runFlow: dismiss-active-field.yaml + +# A shop decides whether the delivery address asks for a phone number, and it can make +# that field required. The scroll is optional so a shop without the field still passes. +- scrollUntilVisible: + element: + text: "^Phone$" + direction: DOWN + centerElement: true + optional: true +- runFlow: + when: + visible: "^Phone$" + commands: + - tapOn: + text: "^Phone$" + - waitForAnimationToEnd + - inputText: "${PHONE}" + - runFlow: dismiss-active-field.yaml diff --git a/e2e/flows/checkout/present.yaml b/e2e/flows/checkout/present.yaml index f17a43e15..6ce70df9e 100644 --- a/e2e/flows/checkout/present.yaml +++ b/e2e/flows/checkout/present.yaml @@ -1,4 +1,4 @@ -appId: ${APP_ID} +appId: ${E2E_APP_ID} --- - tapOn: id: checkout-button diff --git a/e2e/flows/checkout/submit.yaml b/e2e/flows/checkout/submit.yaml index bcb82d884..0b1472453 100644 --- a/e2e/flows/checkout/submit.yaml +++ b/e2e/flows/checkout/submit.yaml @@ -1,10 +1,32 @@ -appId: ${APP_ID} +appId: ${E2E_APP_ID} --- +# The keyboard from the last payment field can still cover the foot of the page, so the +# control has to reach the middle of the screen before the tap. `centerElement` does that. +# A `visibilityPercentage` below 100 rounds down to zero, which lets the scroll stop early, +# and a tap that lands on the keyboard does nothing while Maestro still reports it as done. - scrollUntilVisible: element: - text: "^(Pay now|Complete order)$" + text: "^(Pay now|Complete order|Review order)$" direction: DOWN - visibilityPercentage: 90 + centerElement: true + +# A shop that shows shipping options keeps the pay action behind a review step. One button +# carries both labels, so the review tap comes first and the pay tap follows it. +- runFlow: + when: + visible: "^Review order$" + commands: + - tapOn: + text: "^Review order$" + enabled: true + - extendedWaitUntil: + visible: "^(Pay now|Complete order)$" + timeout: 30000 + - scrollUntilVisible: + element: + text: "^(Pay now|Complete order)$" + direction: DOWN + centerElement: true - tapOn: text: "^(Pay now|Complete order)$" enabled: true diff --git a/e2e/lib/e2e_github_reporter.rb b/e2e/lib/e2e_github_reporter.rb index b713190fb..29f8b2bca 100644 --- a/e2e/lib/e2e_github_reporter.rb +++ b/e2e/lib/e2e_github_reporter.rb @@ -91,14 +91,21 @@ def check_run_payload def results_table lines = [] - lines << "| Status | Suite | Target | Platform | OS version tag | Device |" + lines << "| Status | Tags | Target | Platform | OS version tag | Device |" lines << "|---|---|---|---|---|---|" @results.each do |result| - lines << "| #{status_icon(result)} | `#{result["execute"]}` | #{result["target"]} | #{result["platform"]} | #{result["os_version_tag"]} | #{device_cell(result)} |" + lines << "| #{status_icon(result)} | `#{tags_cell(result)}` | #{result["target"]} | #{result["platform"]} | #{result["os_version_tag"]} | #{device_cell(result)} |" end lines end + # `execute` is the tests folder on every row now that tags select the flows, so it carries + # no information. The include tags name what the row actually ran. + def tags_cell(result) + tags = result.fetch("include_tags", []).to_a + tags.empty? ? "all" : tags.join(", ") + end + def completeness_lines blocked? ? blocked_lines : shortfall_lines end @@ -225,8 +232,7 @@ def missing_runs end def missing_run_label(run) - suite = File.basename(run["execute"].to_s, ".*") - "`#{run["application_id"] || run["target"]}` · #{suite} (#{run["platform"]})" + "`#{run["application_id"] || run["target"]}` · #{tags_cell(run)} (#{run["platform"]})" end def missing_count @@ -264,6 +270,7 @@ def failure_details(result) lines << "" lines << "### #{failure_heading(result)}" lines << "" + lines.concat(setup_error_lines(result)) lines << "| Test | Status | Artifacts |" lines << "|---|---|---|" tests = result.fetch("failed_tests", []) @@ -277,9 +284,17 @@ def failure_details(result) lines end + # A row that fails before it starts a build reports no test cases, so the message from + # result.json is the only place the cause appears. + def setup_error_lines(result) + return [] if blank?(result["error"]) + + message = result["error"].to_s.split("\n").map(&:strip).reject(&:empty?).join(" ") + ["> `#{result["error_class"] || "Error"}`: #{message}", ""] + end + def failure_heading(result) - suite = File.basename(result["execute"].to_s, ".*") - "#{os_label(result["platform"])} — #{suite}" + "#{os_label(result["platform"])} — #{result["target"]}" end def artifact_links(testcase, result) diff --git a/e2e/lib/e2e_matrix_to_browserstack_run_plan.rb b/e2e/lib/e2e_matrix_to_browserstack_run_plan.rb index 35c955738..738fe6609 100644 --- a/e2e/lib/e2e_matrix_to_browserstack_run_plan.rb +++ b/e2e/lib/e2e_matrix_to_browserstack_run_plan.rb @@ -12,11 +12,14 @@ # parallelizes over it via `run_at`/`count`. # # Today the numbers make the config and the expansion look equivalent - -# 2 applications + 1 os_version_tag + 1 suite, and 2 * 1 * 1 = 2 runs - so it can look +# 4 applications + 1 os_version_tag, and 4 * 1 = 4 runs - so it can look # like a plain YAML-to-JSON copy. The point is the multiplication, not the copy: # adding a single os_version_tag (e.g. a minimum-supported OS) transparently duplicates -# every application x suite across that OS, turning an additive config change into +# every application across that OS, turning an additive config change into # a multiplicative set of runs without hand-writing each one. +# +# Every run executes the whole tests folder. Tags decide what runs inside it, so a new +# test file adds no rows here - it only needs a tag the matrix already includes. class E2EMatrixToBrowserStackRunPlan # Drift between the matrix and the pipeline graph happens in both directions: a stale # branch predating a newly added target, or a build workflow deleted while the matrix @@ -39,10 +42,8 @@ def initialize(config_path, config, changed_files: nil) def expand ensure_valid! selected_applications.flat_map do |application| - os_version_tags.flat_map do |os_version_tag| - suites.map do |suite| - build_run(application, os_version_tag, suite) - end + os_version_tags.map do |os_version_tag| + build_run(application, os_version_tag) end end end @@ -96,11 +97,11 @@ def validation_errors errors << "version must be 1" unless @config.fetch("version", nil) == 1 validate_collection(errors, "applications", applications) validate_collection(errors, "os_version_tags", os_version_tags) - validate_collection(errors, "suites", suites) validate_changed_file_filters(errors) + validate_tests_path(errors) validate_applications(errors) validate_os_version_tags(errors) - validate_suites(errors) + validate_default_tags(errors) errors end @@ -111,27 +112,43 @@ def ensure_valid! private - def build_run(application, os_version_tag, suite) + def build_run(application, os_version_tag) platform = application.fetch("platform") os_version_tag_id = os_version_tag_id(os_version_tag) - suite_id = suite.fetch("id") application_id = application.fetch("id") + app_id = application.fetch("app_id") { - "id" => "#{application_id}-#{os_version_tag_id}-#{suite_id}", + "id" => "#{application_id}-#{os_version_tag_id}", "application_id" => application_id, "target" => application.fetch("target"), "platform" => platform, "os_version_tag" => os_version_tag_id, "device_selector" => device_selector(platform, os_version_tag), - "app_id" => application.fetch("app_id"), + "app_id" => app_id, + "control_link" => control_link(app_id), "artifact_env" => application.fetch("artifact_env"), - "execute" => suite.fetch("execute"), + "execute" => workspace_path, + "include_tags" => application_tags(application, "include"), + "exclude_tags" => application_tags(application, "exclude"), "ready_marker" => application.fetch("ready_marker"), - "status_context" => "checkout-kit/e2e/#{application_id}/#{os_version_tag_id}/#{suite_id}" + "status_context" => "checkout-kit/e2e/#{application_id}/#{os_version_tag_id}" } end + # The control link scheme is the app id on all four targets, so deriving it keeps + # one source of truth instead of a second copy that drifts. + def control_link(app_id) + "#{app_id}://e2e" + end + + def application_tags(application, kind) + override = application.fetch("#{kind}_tags", nil) + return Array(override) if override + + Array(default_tags.fetch(kind, nil)) + end + def application_matches_changed_files?(application) filter_names = application_changed_file_filter_names(application) return true if filter_names.empty? @@ -185,8 +202,29 @@ def os_version_tags @config.fetch("os_version_tags", []) || [] end - def suites - @config.fetch("suites", []) || [] + def tests_path + @config.fetch("tests_path", "tests") + end + + # 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. The local runners pass the same value. + def workspace_path + @config.fetch("workspace_path", ".") + end + + def default_tags + @config.fetch("tags", {}) || {} + end + + def declared_tags + @declared_tags ||= Dir.glob("#{tests_path}/**/*.yaml", base: e2e_root).flat_map do |path| + header = File.read(File.join(e2e_root, path)).split("\n---\n").first + YAML.safe_load(header)["tags"] || [] + rescue Psych::Exception + [] + end.uniq end def validate_collection(errors, name, collection) @@ -219,6 +257,7 @@ def validate_applications(errors) errors << "application #{id} missing #{key}" if application.fetch(key, "").to_s.empty? end validate_application_changed_files_filters(errors, application) + validate_application_tags(errors, application) platform = application.fetch("platform", nil) errors << "application #{id} platform must be ios or android" unless ["ios", "android"].include?(platform) end @@ -272,21 +311,57 @@ def safe_os_version_tag_id(os_version_tag) nil end - def validate_suites(errors) - return unless suites.is_a?(Array) + def validate_tests_path(errors) + return if File.directory?(File.join(e2e_root, tests_path)) + + errors << "tests_path is not a directory: #{tests_path}" + end - validate_unique_ids(errors, "suite", suites) - suites.each do |suite| - id = suite.fetch("id", "") - errors << "suite missing id" if id.to_s.empty? - execute = suite.fetch("execute", "") - errors << "suite #{id} missing execute" if execute.empty? - next if execute.empty? + # An include tag no test carries produces a green run that tested nothing. + def validate_default_tags(errors) + ["include", "exclude"].each do |kind| + tags = default_tags.fetch(kind, nil) + next if tags.nil? - errors << "suite #{id} execute path does not exist: #{execute}" unless File.exist?(File.join(e2e_root, execute)) + unless tags.is_a?(Array) + errors << "tags #{kind} must be an array" + next + end + + next unless kind == "include" + + errors.concat(unknown_include_tag_errors(tags) { |tag| "tags include '#{tag}' but no test in tests/ carries it" }) end end + def validate_application_tags(errors, application) + id = application.fetch("id", "") + + ["include", "exclude"].each do |kind| + tags = application.fetch("#{kind}_tags", nil) + next if tags.nil? + + unless tags.is_a?(Array) + errors << "application #{id} #{kind}_tags must be an array" + next + end + + next unless kind == "include" + + errors.concat( + unknown_include_tag_errors(tags) do |tag| + "application #{id} include_tags '#{tag}' but no test in tests/ carries it" + end + ) + end + end + + def unknown_include_tag_errors(tags) + return [] if declared_tags.empty? + + (tags - declared_tags).map { |tag| yield(tag) } + end + def validate_unique_ids(errors, label, collection) ids = collection.map { |item| item.fetch("id", nil) } errors << "#{label} ids must be unique" unless ids.compact.uniq.length == ids.compact.length diff --git a/e2e/scripts/execute_browserstack_run b/e2e/scripts/execute_browserstack_run index 5b313beaa..8a8bf0fc5 100755 --- a/e2e/scripts/execute_browserstack_run +++ b/e2e/scripts/execute_browserstack_run @@ -137,6 +137,52 @@ class BrowserStackRunExecutor response end + # BrowserStack rejects a build when one tag appears in both lists, so the account tag has + # to leave the include list at the same moment it joins the exclude list. + def self.resolve_tags(run, account_enabled:) + include_tags = run.fetch("include_tags") + exclude_tags = run.fetch("exclude_tags") + + return {includeTags: include_tags, excludeTags: exclude_tags} if account_enabled + + { + includeTags: include_tags - ["account"], + excludeTags: (exclude_tags + ["account"]).uniq + } + end + + # The repository is public, so every account value lives in Bitrise secrets only. A run + # without them skips the account journey instead of failing inside checkout. Maestro reads + # the first two at run time. The sample app reads the last two at build time, and without + # them the sign-in web view opens http://null/oauth/authorize with an empty client_id. + ACCOUNT_VARIABLES = [ + "E2E_CUSTOMER_ACCOUNT_EMAIL", + "E2E_CUSTOMER_ACCOUNT_CODE", + "CUSTOMER_ACCOUNT_API_CLIENT_ID", + "CUSTOMER_ACCOUNT_API_SHOP_ID" + ].freeze + + def self.account_enabled?(env) + ACCOUNT_VARIABLES.all? { |variable| !env[variable].to_s.strip.empty? } + end + + def self.missing_account_variables(env) + ACCOUNT_VARIABLES.select { |variable| env[variable].to_s.strip.empty? } + end + + def account_enabled? + self.class.account_enabled?(ENV) + end + + def resolve_tags(run) + unless account_enabled? + missing = self.class.missing_account_variables(ENV) + warn("no #{missing.join(", ")}, excluding the account tag") + end + + self.class.resolve_tags(run, account_enabled: account_enabled?) + end + def start_build(run, app_url, test_suite_url, device) body = { app: app_url, @@ -147,9 +193,13 @@ class BrowserStackRunExecutor customBuildName: run.fetch("id"), devices: [device], execute: [run.fetch("execute")], + tags: resolve_tags(run), setEnvVariables: { E2E_APP_ID: run.fetch("app_id"), - E2E_READY_MARKER: run.fetch("ready_marker") + E2E_READY_MARKER: run.fetch("ready_marker"), + E2E_CONTROL_LINK: run.fetch("control_link"), + E2E_CUSTOMER_ACCOUNT_EMAIL: ENV.fetch("E2E_CUSTOMER_ACCOUNT_EMAIL", ""), + E2E_CUSTOMER_ACCOUNT_CODE: ENV.fetch("E2E_CUSTOMER_ACCOUNT_CODE", "") } } response = @client.start_build(run.fetch("platform"), body) @@ -205,6 +255,8 @@ class BrowserStackRunExecutor "target" => run.fetch("target"), "os_version_tag" => run.fetch("os_version_tag"), "execute" => run.fetch("execute"), + "include_tags" => run.fetch("include_tags"), + "exclude_tags" => run.fetch("exclude_tags"), "device_selector" => device.fetch("device_selector"), "resolved_device" => device.fetch("resolved_device"), "resolved_os_version" => device.fetch("resolved_os_version"), @@ -228,6 +280,8 @@ class BrowserStackRunExecutor "target" => run["target"], "os_version_tag" => run["os_version_tag"], "execute" => run["execute"], + "include_tags" => run["include_tags"], + "exclude_tags" => run["exclude_tags"], "device_selector" => run["device_selector"], "resolved_device" => device["resolved_device"] || run["device_selector"], "resolved_os_version" => device["resolved_os_version"], diff --git a/e2e/scripts/run_maestro b/e2e/scripts/run_maestro new file mode 100755 index 000000000..f188b5924 --- /dev/null +++ b/e2e/scripts/run_maestro @@ -0,0 +1,93 @@ +#!/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 E2E_DEVICE_ID when the caller already knows which device holds the installed app. +# Maestro otherwise picks a device itself, and it can pick one the app is missing from. +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:-}" +DEVICE_ID="${E2E_DEVICE_ID:-}" + +E2E_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# 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" + +# Account credentials never live in a flow file. CI exports them from Bitrise secrets. +# Local runs read them from the untracked root .env, which .env.example documents. +ENV_FILE="$(cd "$E2E_ROOT/.." && pwd)/.env" + +read_env_value() { + local key="$1" + + if [ ! -f "$ENV_FILE" ]; then + return 0 + fi + + sed -n "s/^${key}=//p" "$ENV_FILE" | tail -n 1 +} + +CUSTOMER_ACCOUNT_EMAIL="${E2E_CUSTOMER_ACCOUNT_EMAIL:-$(read_env_value E2E_CUSTOMER_ACCOUNT_EMAIL)}" +CUSTOMER_ACCOUNT_CODE="${E2E_CUSTOMER_ACCOUNT_CODE:-$(read_env_value E2E_CUSTOMER_ACCOUNT_CODE)}" + +if [ -z "$CUSTOMER_ACCOUNT_EMAIL" ] || [ -z "$CUSTOMER_ACCOUNT_CODE" ]; then + echo "run_maestro: no customer account credentials, excluding the account tag" >&2 + # The account tag has to leave the include list at the same moment it joins the exclude + # list, because a tag named in both lists makes the run ambiguous. + REQUESTED_TAGS="$INCLUDE_TAGS" + INCLUDE_TAGS="$(printf '%s' "$INCLUDE_TAGS" | tr ',' '\n' | { grep -vx account || true; } | paste -sd, -)" + EXCLUDE_TAGS="${EXCLUDE_TAGS:+${EXCLUDE_TAGS},}account" + + # An empty include list means "run everything", so a caller who asked only for the + # account tag would get the whole suite instead of nothing. + if [ -n "$REQUESTED_TAGS" ] && [ -z "$INCLUDE_TAGS" ]; then + echo "run_maestro: the requested tags need customer account credentials, so nothing runs" >&2 + exit 0 + fi +fi + +MAESTRO_ARGS=(--platform "$PLATFORM") + +if [ -n "$DEVICE_ID" ]; then + MAESTRO_ARGS+=(--device "$DEVICE_ID") +fi + +MAESTRO_ARGS+=(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 + +cd "$E2E_ROOT" + +# Maestro resolves the `flows:` glob in config.yaml relative to the path given here, so +# the workspace root is the only path where tests/**/*.yaml resolves. CI passes the same +# value, because scripts/zip_e2e_tests puts config.yaml beside tests/ and flows/. +exec "$MAESTRO" "${MAESTRO_ARGS[@]}" \ + -e "E2E_APP_ID=${APP_ID}" \ + -e "E2E_READY_MARKER=${READY_MARKER}" \ + -e "E2E_CONTROL_LINK=${CONTROL_LINK}" \ + -e "E2E_CUSTOMER_ACCOUNT_EMAIL=${CUSTOMER_ACCOUNT_EMAIL}" \ + -e "E2E_CUSTOMER_ACCOUNT_CODE=${CUSTOMER_ACCOUNT_CODE}" \ + . diff --git a/e2e/test/browserstack_run_executor_test.rb b/e2e/test/browserstack_run_executor_test.rb index ac0366d95..f3b2e2500 100644 --- a/e2e/test/browserstack_run_executor_test.rb +++ b/e2e/test/browserstack_run_executor_test.rb @@ -7,6 +7,13 @@ class BrowserStackRunExecutorTest < Minitest::Test E2E_ROOT = File.expand_path("..", __dir__) + RUN = { + "include_tags" => %w[launch cart checkout account], + "exclude_tags" => %w[flaky wip] + }.freeze + + IOS_OPEN_LINK_MAESTRO_VERSION = "2.0.7" + def with_version_file(contents) Dir.mktmpdir do |dir| path = File.join(dir, ".maestro-version") @@ -55,4 +62,84 @@ def test_a_blank_override_falls_back_to_the_pin_file assert_equal "2.4.0", version end end + + def test_account_credentials_present_keeps_both_lists + tags = BrowserStackRunExecutor.resolve_tags(RUN, account_enabled: true) + + assert_equal %w[launch cart checkout account], tags.fetch(:includeTags) + assert_equal %w[flaky wip], tags.fetch(:excludeTags) + end + + def test_account_credentials_missing_drops_the_account_tag_from_the_include_list + tags = BrowserStackRunExecutor.resolve_tags(RUN, account_enabled: false) + + assert_equal %w[launch cart checkout], tags.fetch(:includeTags) + assert_equal %w[flaky wip account], tags.fetch(:excludeTags) + end + + def test_account_credentials_missing_never_repeats_a_tag_across_both_lists + tags = BrowserStackRunExecutor.resolve_tags(RUN, account_enabled: false) + + assert_empty(tags.fetch(:includeTags) & tags.fetch(:excludeTags)) + end + + def test_empty_include_list_still_excludes_the_account_tag + run = {"include_tags" => [], "exclude_tags" => []} + tags = BrowserStackRunExecutor.resolve_tags(run, account_enabled: false) + + assert_empty tags.fetch(:includeTags) + assert_equal ["account"], tags.fetch(:excludeTags) + end + + def test_the_pinned_maestro_version_carries_ios_open_link + assert_operator( + Gem::Version.new(BrowserStackRunExecutor.resolve_maestro_version({})), + :>=, + Gem::Version.new(IOS_OPEN_LINK_MAESTRO_VERSION) + ) + end + + def test_the_maestro_version_is_pinned_rather_than_floating + refute_equal "latest", BrowserStackRunExecutor.resolve_maestro_version({}) + end + + def test_an_override_replaces_the_pinned_maestro_version + assert_equal "2.0.7", BrowserStackRunExecutor.resolve_maestro_version({"E2E_MAESTRO_VERSION" => "2.0.7"}) + end + + ACCOUNT_ENV = { + "E2E_CUSTOMER_ACCOUNT_EMAIL" => "buyer@example.com", + "E2E_CUSTOMER_ACCOUNT_CODE" => "000000", + "CUSTOMER_ACCOUNT_API_CLIENT_ID" => "synthetic-client-id", + "CUSTOMER_ACCOUNT_API_SHOP_ID" => "synthetic-shop-id" + }.freeze + + def test_every_account_value_present_enables_the_account_journey + assert BrowserStackRunExecutor.account_enabled?(ACCOUNT_ENV) + end + + def test_a_missing_test_credential_disables_the_account_journey + ACCOUNT_ENV.keys.grep(/^E2E_/).each do |key| + refute( + BrowserStackRunExecutor.account_enabled?(ACCOUNT_ENV.merge(key => "")), + "a blank #{key} must disable the account journey" + ) + end + end + + # The app reads the OAuth client id and shop id at build time. Without them the sign-in + # web view requests http://null/oauth/authorize with an empty client_id, and the test + # fails inside the web view instead of never running. + def test_a_missing_oauth_build_value_disables_the_account_journey + ACCOUNT_ENV.keys.grep(/^CUSTOMER_ACCOUNT_API_/).each do |key| + refute( + BrowserStackRunExecutor.account_enabled?(ACCOUNT_ENV.merge(key => "")), + "a blank #{key} must disable the account journey" + ) + end + end + + def test_a_whitespace_account_value_counts_as_missing + refute BrowserStackRunExecutor.account_enabled?(ACCOUNT_ENV.merge("CUSTOMER_ACCOUNT_API_SHOP_ID" => " ")) + end end diff --git a/e2e/test/e2e_github_reporter_test.rb b/e2e/test/e2e_github_reporter_test.rb index ec7c709cb..089bfb90d 100644 --- a/e2e/test/e2e_github_reporter_test.rb +++ b/e2e/test/e2e_github_reporter_test.rb @@ -35,7 +35,8 @@ def swift_ios_run "target" => "swift", "platform" => "ios", "os_version_tag" => "latest", - "execute" => "tests/shared/launch-smoke.yaml" + "execute" => ".", + "include_tags" => ["launch"] } end @@ -46,7 +47,8 @@ def react_native_ios_run "target" => "react-native", "platform" => "ios", "os_version_tag" => "latest", - "execute" => "tests/shared/launch-smoke.yaml" + "execute" => ".", + "include_tags" => ["launch"] } end @@ -120,14 +122,14 @@ def test_blocked_report_names_the_failed_stage_and_lists_the_skipped_runs assert_includes summary, "> - `e2e-build-react-native-ios` — [build log]" assert_includes summary, "/build/a7111bcd)" assert_includes summary, "> None of the 2 planned runs executed:" - assert_includes summary, "> - `swift-ios` · launch-smoke (ios)" + assert_includes summary, "> - `swift-ios` · launch (ios)" assert_includes summary, "> [Pipeline build](https://app.bitrise.io/app/" end def test_blocked_report_omits_the_empty_tables body = blocked_reporter.comment_body - refute_includes body, "| Status | Suite |" + refute_includes body, "| Status | Tags |" refute_includes body, "## Install this build" refute_includes body, "| SDK | Install |" end @@ -140,7 +142,7 @@ def test_missing_runs_are_named_without_a_stage_roster summary = reporter(results: [], run_plan: [swift_ios_run], expected: 1).markdown_summary assert_includes summary, "did not report" - assert_includes summary, "> - `swift-ios` · launch-smoke (ios)" + assert_includes summary, "> - `swift-ios` · launch (ios)" refute_includes summary, "[!CAUTION]" end @@ -153,7 +155,7 @@ def test_partial_report_keeps_the_table_and_names_the_failed_stage expected: 2 ).markdown_summary - assert_includes summary, "| Status | Suite |" + assert_includes summary, "| Status | Tags |" assert_includes summary, "did not report" assert_includes summary, "> 1 pipeline stage failed:" assert_includes summary, "> - `e2e-execute-browserstack-run` — [build log]" @@ -196,4 +198,79 @@ def test_complete_run_has_no_missing_run_lines refute_includes summary, "did not report" end + + def test_results_table_names_the_tags_instead_of_the_execute_path + result = swift_ios_run.merge( + "passed" => true, + "resolved_device" => "iPhone", + "include_tags" => %w[cart checkout], + "exclude_tags" => %w[flaky] + ) + summary = reporter(results: [result]).markdown_summary + + assert_includes summary, "| Status | Tags | Target | Platform | OS version tag | Device |" + assert_includes summary, "| `cart, checkout` |" + refute_includes summary, "tests/shared/launch-smoke.yaml" + end + + def test_results_table_reads_all_when_the_run_carries_no_include_tags + result = swift_ios_run.merge("passed" => true, "resolved_device" => "iPhone", "include_tags" => []) + summary = reporter(results: [result]).markdown_summary + + assert_includes summary, "| `all` |" + end + + def test_failure_heading_names_the_target_rather_than_the_execute_path + result = swift_ios_run.merge("passed" => false, "execute" => ".", "failed_tests" => []) + summary = reporter(results: [result]).markdown_summary + + assert_includes summary, "### iOS — swift" + refute_includes summary, "### iOS — ." + end + + def test_setup_error_names_the_class_and_the_message + result = swift_ios_run.merge( + "passed" => false, + "status" => "error", + "failed_tests" => [], + "error_class" => "RuntimeError", + "error" => "BrowserStack request failed 422: duplicate tags" + ) + summary = reporter(results: [result]).markdown_summary + + assert_includes summary, "> `RuntimeError`: BrowserStack request failed 422: duplicate tags" + end + + def test_setup_error_message_collapses_to_one_line + result = swift_ios_run.merge( + "passed" => false, + "failed_tests" => [], + "error_class" => "RuntimeError", + "error" => "first line\nsecond line" + ) + summary = reporter(results: [result]).markdown_summary + + assert_includes summary, "> `RuntimeError`: first line second line" + end + + def test_missing_run_label_names_the_tags_rather_than_the_execute_path + planned = swift_ios_run.merge("include_tags" => %w[cart checkout]) + summary = reporter(results: [], run_plan: [planned], expected: 1).markdown_summary + + assert_includes summary, "> - `swift-ios` · cart, checkout (ios)" + end + + def test_missing_run_label_reads_all_without_include_tags + planned = swift_ios_run.merge("include_tags" => []) + summary = reporter(results: [], run_plan: [planned], expected: 1).markdown_summary + + assert_includes summary, "> - `swift-ios` · all (ios)" + end + + def test_failure_without_an_error_field_adds_no_error_line + result = swift_ios_run.merge("passed" => false, "failed_tests" => []) + summary = reporter(results: [result]).markdown_summary + + refute_includes summary, "> `" + end end 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 c9745642e..37e98af04 100644 --- a/e2e/test/e2e_matrix_to_browserstack_run_plan_test.rb +++ b/e2e/test/e2e_matrix_to_browserstack_run_plan_test.rb @@ -18,6 +18,90 @@ def selected_ids(changed_files) plan(changed_files: changed_files).selected_applications.map { |application| application.fetch("id") } end + def run_for(application_id, changed_files: nil) + plan(changed_files: changed_files).expand.find { |run| run.fetch("application_id") == application_id } + end + + def test_expand_produces_one_run_per_application_and_os_version_tag + runs = plan.expand + + assert_equal 4, runs.length + assert_equal( + ["react-native-ios-latest", "react-native-android-latest", "kotlin-android-latest", "swift-ios-latest"], + runs.map { |run| run.fetch("id") } + ) + end + + # config.yaml declares `flows: tests/**/*.yaml`, and Maestro resolves that glob relative to + # the path it is given. Executing "tests" would look for tests/tests/**, matching nothing. + def test_a_run_executes_the_workspace_root_so_the_config_glob_resolves + assert_equal ".", run_for("swift-ios").fetch("execute") + end + + def test_a_run_carries_the_default_tags + run = run_for("swift-ios") + + assert_equal ["launch", "cart", "checkout", "account"], run.fetch("include_tags") + assert_equal ["flaky", "wip"], run.fetch("exclude_tags") + end + + def test_an_application_overrides_the_default_tags + config = base_config + config.fetch("applications").first["include_tags"] = ["launch", "checkout"] + config.fetch("applications").first["exclude_tags"] = ["wip"] + + run = plan(config: config).expand.first + + assert_equal ["launch", "checkout"], run.fetch("include_tags") + assert_equal ["wip"], run.fetch("exclude_tags") + end + + def test_a_control_link_follows_the_app_id_on_every_application + plan.expand.each do |run| + assert_equal "#{run.fetch("app_id")}://e2e", run.fetch("control_link") + end + end + + def test_a_status_context_no_longer_names_a_suite + assert_equal "checkout-kit/e2e/swift-ios/latest", run_for("swift-ios").fetch("status_context") + end + + def test_validation_errors_flags_an_include_tag_no_test_carries + config = base_config + config.fetch("tags")["include"] = ["launch", "teleport"] + + errors = plan(config: config).validation_errors + + assert_includes errors, "tags include 'teleport' but no test in tests/ carries it" + end + + def test_validation_errors_flags_an_application_include_tag_no_test_carries + config = base_config + config.fetch("applications").first["include_tags"] = ["teleport"] + + errors = plan(config: config).validation_errors + + assert_includes errors, "application react-native-ios include_tags 'teleport' but no test in tests/ carries it" + end + + def test_validation_errors_flags_non_array_include_tags + config = base_config + config.fetch("applications").first["include_tags"] = "launch" + + errors = plan(config: config).validation_errors + + assert_includes errors, "application react-native-ios include_tags must be an array" + end + + def test_validation_errors_flags_a_missing_tests_path + config = base_config + config["tests_path"] = "does-not-exist" + + errors = plan(config: config).validation_errors + + assert_includes errors, "tests_path is not a directory: does-not-exist" + end + def test_nil_changed_files_selects_all_applications assert_equal ["react-native-ios", "react-native-android", "kotlin-android", "swift-ios"], selected_ids(nil) end diff --git a/e2e/test/maestro_test_tags_test.rb b/e2e/test/maestro_test_tags_test.rb new file mode 100644 index 000000000..e7c44130b --- /dev/null +++ b/e2e/test/maestro_test_tags_test.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "yaml" + +class MaestroTestTagsTest < Minitest::Test + E2E_ROOT = File.expand_path("..", __dir__) + JOURNEY_TAGS = ["launch", "cart", "checkout", "account"].freeze + COST_TIER_TAGS = ["smoke", "full"].freeze + QUARANTINE_TAGS = ["flaky", "wip"].freeze + PLATFORM_TAGS = ["ios-only", "android-only"].freeze + KNOWN_TAGS = (JOURNEY_TAGS + COST_TIER_TAGS + QUARANTINE_TAGS + PLATFORM_TAGS).freeze + + def test_files + Dir.glob("tests/**/*.yaml", base: E2E_ROOT).sort + end + + def header(path) + YAML.safe_load(File.read(File.join(E2E_ROOT, path)).split("\n---\n").first) + end + + def tags(path) + header(path)["tags"] || [] + end + + def test_every_test_declares_tags + test_files.each do |path| + refute_empty(tags(path), "#{path} declares no tags, so no CI run can select it") + end + end + + def test_every_tag_belongs_to_the_taxonomy + test_files.each do |path| + tags(path).each do |tag| + assert_includes(KNOWN_TAGS, tag, "#{path} uses the unknown tag #{tag}") + end + end + end + + def test_every_test_declares_one_journey + test_files.each do |path| + journeys = tags(path) & JOURNEY_TAGS + + assert_equal(1, journeys.length, "#{path} must declare exactly one journey tag, found #{journeys.inspect}") + end + end + + def test_every_test_declares_one_cost_tier + test_files.each do |path| + tiers = tags(path) & COST_TIER_TAGS + + assert_equal(1, tiers.length, "#{path} must declare exactly one cost tier tag, found #{tiers.inspect}") + end + end + + def test_a_platform_tag_names_the_capability_that_earns_it + test_files.each do |path| + next if (tags(path) & PLATFORM_TAGS).empty? + + body = File.read(File.join(E2E_ROOT, path)) + + assert_match( + /#\s*Platform capability:\s*\S+/, + body, + "#{path} carries a platform tag, so it must comment `# Platform capability: `" + ) + end + end + + def test_the_workspace_config_quarantines_the_quarantine_tags + config = YAML.safe_load(File.read(File.join(E2E_ROOT, "config.yaml"))) + + 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 + + CREDENTIAL_VARIABLES = ["E2E_CUSTOMER_ACCOUNT_EMAIL", "E2E_CUSTOMER_ACCOUNT_CODE"].freeze + + def authored_files + Dir.glob("{tests,flows}/**/*.yaml", base: E2E_ROOT).sort + end + + # This repository is public. The runner reads the account credentials from Bitrise + # secrets or an untracked .env, so no committed file may carry a value for them. + def test_no_authored_file_assigns_a_customer_account_credential + authored_files.each do |path| + body = File.read(File.join(E2E_ROOT, path)) + + CREDENTIAL_VARIABLES.each do |variable| + refute_match( + /^\s*#{variable}\s*:/, + body, + "#{path} assigns #{variable}, which must reach Maestro from the runner instead" + ) + end + end + end + + def env(path) + header(path)["env"] || {} + end + + # BrowserStack runs every test of one target in one session on one device, and the launch + # flow no longer clears app state, so state survives from one test to the next. A test + # that submits an order must therefore state the identity it wants rather than inherit it. + def test_every_order_test_declares_a_buyer_identity_mode + shared_test_files.select { |path| tags(path).include?("full") }.each do |path| + assert_match( + /buyerIdentityMode=\w+/, + env(path)["E2E_CART_PARAMS"].to_s, + "#{path} submits an order, so E2E_CART_PARAMS must set buyerIdentityMode" + ) + end + end + + # Maestro 2.4.0 implements iOS clearState by uninstalling the app. On a BrowserStack real + # device the reinstall reports success and never restores the app, so the next launch + # lands on the home screen. Eight builds with the flag failed on five different units and + # three builds without it passed, so the fault is deterministic rather than flaky. + # Seeding the cart resets it anyway, so the flag buys nothing. + def test_the_launch_flow_never_clears_app_state + commands = File.readlines(File.join(E2E_ROOT, "flows", "app", "launch.yaml")) + .reject { |line| line.strip.start_with?("#") } + .join + + refute_match( + /clearState/, + commands, + "clearState uninstalls the iOS app on BrowserStack and never reinstalls it" + ) + 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/tests/react-native/checkout-guest.yaml b/e2e/tests/react-native/checkout-guest.yaml deleted file mode 100644 index 42b72b4ce..000000000 --- a/e2e/tests/react-native/checkout-guest.yaml +++ /dev/null @@ -1,36 +0,0 @@ -appId: ${APP_ID} -name: React Native checkout - guest - -env: - # Sample app buyer identity configuration - BUYER_IDENTITY_MODE: "guest" - - # 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_SECURITY_CODE: "123" - - # Accepted successful checkout states for this smoke test. - POST_SUBMIT_RESULT_PATTERN: ".*(Thank you|Your order|Order confirmed|confirmation).*" ---- -- runFlow: ../../flows/app/bootstrap-cart-from-link.yaml -- runFlow: ../../flows/checkout/present.yaml -- runFlow: ../../flows/checkout/fill-contact.yaml -- runFlow: ../../flows/checkout/fill-shipping-address.yaml -- runFlow: ../../flows/checkout/fill-payment-card.yaml -- runFlow: ../../flows/checkout/submit.yaml -- runFlow: ../../flows/checkout/assert-complete.yaml -- runFlow: ../../flows/checkout/assert-returned-to-empty-cart.yaml diff --git a/e2e/tests/react-native/checkout-hardcoded-buyer-identity.yaml b/e2e/tests/react-native/checkout-hardcoded-buyer-identity.yaml deleted file mode 100644 index 0e82f742a..000000000 --- a/e2e/tests/react-native/checkout-hardcoded-buyer-identity.yaml +++ /dev/null @@ -1,20 +0,0 @@ -appId: ${APP_ID} -name: React Native checkout - hardcoded buyer identity - -env: - # Sample app buyer identity configuration - BUYER_IDENTITY_MODE: "hardcoded" - - # Checkout payment fixture - CARD_NUMBER: "1" - CARD_SECURITY_CODE: "123" - - # Accepted successful checkout states for this smoke test. - POST_SUBMIT_RESULT_PATTERN: ".*(Thank you|Your order|Order confirmed|confirmation).*" ---- -- runFlow: ../../flows/app/bootstrap-cart-from-link.yaml -- runFlow: ../../flows/checkout/present.yaml -- runFlow: ../../flows/checkout/fill-payment-card.yaml -- runFlow: ../../flows/checkout/submit.yaml -- runFlow: ../../flows/checkout/assert-complete.yaml -- runFlow: ../../flows/checkout/assert-returned-to-empty-cart.yaml 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/checkout-customer-account.yaml b/e2e/tests/shared/checkout-customer-account.yaml new file mode 100644 index 000000000..69a5f10d5 --- /dev/null +++ b/e2e/tests/shared/checkout-customer-account.yaml @@ -0,0 +1,43 @@ +appId: ${E2E_APP_ID} +name: Checkout with a customer account +tags: + - account + # This test signs in and submits a real order, so it belongs to the expensive tier. + - full + +env: + # The cart attaches the signed-in buyer, so checkout reads the contact address and the + # saved delivery address from the account. Only payment is left to type. + E2E_CART_PARAMS: "productIndex=0&quantity=1&buyerIdentityMode=customerAccount" + + # Checkout payment fixture. The shop runs the bogus gateway, which rejects real card + # brands and reads a single digit instead: 1 approves, 2 declines, 3 fails the gateway. + CARD_NUMBER: "1" + CARD_NUMBER_DISPLAY: "1" + CARD_SECURITY_CODE: "123" + CARD_HOLDER_NAME: "Maestro Shopify" + + # Checkout billing fixture. Checkout copies the delivery address when it can, so the + # billing section usually stays closed. These values cover the target that opens it. + BILLING_FIRST_NAME: "Maestro" + BILLING_LAST_NAME: "Shopify" + BILLING_ADDRESS_LINE1: "700 S Flower St" + BILLING_CITY: "Los Angeles" + BILLING_POSTAL_FIELD_LABEL: "ZIP code" + BILLING_POSTAL_CODE: "90017" + + # Accepted successful checkout states for this smoke test. + POST_SUBMIT_RESULT_PATTERN: ".*(Thank you|Your order|Order confirmed|confirmation).*" +--- +# Sign in before the cart exists. The buyer identity is attached when the cart is created, +# so a cart seeded ahead of the session would carry no customer. +- runFlow: ../../flows/app/launch.yaml +- runFlow: ../../flows/app/sign-in-customer-account.yaml +- runFlow: ../../flows/app/seed-cart-from-link.yaml +- runFlow: ../../flows/checkout/present.yaml +- runFlow: ../../flows/checkout/assert-buyer-prefilled.yaml +- runFlow: ../../flows/checkout/fill-payment-card.yaml +- runFlow: ../../flows/checkout/fill-billing-address.yaml +- runFlow: ../../flows/checkout/submit.yaml +- runFlow: ../../flows/checkout/assert-complete.yaml +- runFlow: ../../flows/checkout/assert-returned-to-empty-cart.yaml diff --git a/e2e/tests/shared/checkout-guest.yaml b/e2e/tests/shared/checkout-guest.yaml new file mode 100644 index 000000000..1f138bc16 --- /dev/null +++ b/e2e/tests/shared/checkout-guest.yaml @@ -0,0 +1,60 @@ +appId: ${E2E_APP_ID} +name: Checkout as a guest +tags: + - checkout + # This test submits a real order, so it belongs to the expensive tier. + - full + +env: + # The cart carries no contact and no delivery address, so checkout asks a guest for + # both. That makes this the longest real order, and the widest cover of the form. + E2E_CART_PARAMS: "productIndex=0&quantity=1&buyerIdentityMode=guest" + + # Checkout contact fixture. + EMAIL: "maestro.e2e@shopify.com" + FIRST_NAME: "Maestro" + LAST_NAME: "Shopify" + + # Checkout delivery fixture. This address keeps the shop's own country, so the country + # picker stays closed and only the state picker runs. The two field labels follow the + # country: the United States names them State and ZIP code. + COUNTRY_LABEL: "United States" + ADDRESS_LINE1: "700 S Flower St" + CITY: "Los Angeles" + STATE_FIELD_LABEL: "State" + STATE_LABEL: "California" + POSTAL_FIELD_LABEL: "ZIP code" + POSTAL_CODE: "90017" + # The shop requires a phone number on the delivery address. 555-01xx is the number range + # reserved for fiction, so this value reaches nobody. + PHONE: "213 555 0142" + + # Checkout payment fixture. The shop runs the bogus gateway, which rejects real card + # brands and reads a single digit instead: 1 approves, 2 declines, 3 fails the gateway. + CARD_NUMBER: "1" + CARD_NUMBER_DISPLAY: "1" + CARD_SECURITY_CODE: "123" + CARD_HOLDER_NAME: "Maestro Shopify" + + # Checkout billing fixture. A guest types the delivery address into checkout itself, so + # checkout can copy it and the billing section stays closed. These values cover the + # target that opens the section anyway. + BILLING_FIRST_NAME: "Maestro" + BILLING_LAST_NAME: "Shopify" + BILLING_ADDRESS_LINE1: "700 S Flower St" + BILLING_CITY: "Los Angeles" + BILLING_POSTAL_FIELD_LABEL: "ZIP code" + BILLING_POSTAL_CODE: "90017" + + # Accepted successful checkout states for this smoke test. + POST_SUBMIT_RESULT_PATTERN: ".*(Thank you|Your order|Order confirmed|confirmation).*" +--- +- runFlow: ../../flows/app/bootstrap-cart-from-link.yaml +- runFlow: ../../flows/checkout/present.yaml +- runFlow: ../../flows/checkout/fill-contact.yaml +- runFlow: ../../flows/checkout/fill-shipping-address.yaml +- runFlow: ../../flows/checkout/fill-payment-card.yaml +- runFlow: ../../flows/checkout/fill-billing-address.yaml +- runFlow: ../../flows/checkout/submit.yaml +- runFlow: ../../flows/checkout/assert-complete.yaml +- runFlow: ../../flows/checkout/assert-returned-to-empty-cart.yaml diff --git a/e2e/tests/shared/checkout-hardcoded-buyer-identity.yaml b/e2e/tests/shared/checkout-hardcoded-buyer-identity.yaml new file mode 100644 index 000000000..561cdc5b0 --- /dev/null +++ b/e2e/tests/shared/checkout-hardcoded-buyer-identity.yaml @@ -0,0 +1,40 @@ +appId: ${E2E_APP_ID} +name: Checkout with hardcoded buyer identity +tags: + - checkout + # This test submits a real order, so it belongs to the expensive tier. + - full + +env: + # The cart carries the contact and the delivery address, so checkout asks for payment + # only. That makes this the shortest real order, and the first one to fix when checkout + # breaks. + E2E_CART_PARAMS: "productIndex=0&quantity=1&buyerIdentityMode=hardcoded" + + # Checkout payment fixture. The shop runs the bogus gateway, which rejects real card + # brands and reads a single digit instead: 1 approves, 2 declines, 3 fails the gateway. + CARD_NUMBER: "1" + CARD_NUMBER_DISPLAY: "1" + CARD_SECURITY_CODE: "123" + CARD_HOLDER_NAME: "Maestro Shopify" + + # Checkout billing fixture. The cart supplies a one-time delivery address, which the + # billing section cannot copy, so the test types a billing address of its own. Country + # and state keep the shop defaults, so this address stays in the United States. + BILLING_FIRST_NAME: "Maestro" + BILLING_LAST_NAME: "Shopify" + BILLING_ADDRESS_LINE1: "700 S Flower St" + BILLING_CITY: "Los Angeles" + BILLING_POSTAL_FIELD_LABEL: "ZIP code" + BILLING_POSTAL_CODE: "90017" + + # Accepted successful checkout states for this smoke test. + POST_SUBMIT_RESULT_PATTERN: ".*(Thank you|Your order|Order confirmed|confirmation).*" +--- +- runFlow: ../../flows/app/bootstrap-cart-from-link.yaml +- runFlow: ../../flows/checkout/present.yaml +- runFlow: ../../flows/checkout/fill-payment-card.yaml +- runFlow: ../../flows/checkout/fill-billing-address.yaml +- runFlow: ../../flows/checkout/submit.yaml +- runFlow: ../../flows/checkout/assert-complete.yaml +- runFlow: ../../flows/checkout/assert-returned-to-empty-cart.yaml diff --git a/e2e/tests/shared/checkout-present-and-close.yaml b/e2e/tests/shared/checkout-present-and-close.yaml new file mode 100644 index 000000000..9ce835c1e --- /dev/null +++ b/e2e/tests/shared/checkout-present-and-close.yaml @@ -0,0 +1,16 @@ +appId: ${E2E_APP_ID} +name: Present checkout and close it +tags: + - checkout + - smoke + +env: + # No buyerIdentityMode: this test asserts presentation and dismissal, not identity. + E2E_CART_PARAMS: "productIndex=0&quantity=1" +--- +- runFlow: ../../flows/app/bootstrap-cart-from-link.yaml +- runFlow: ../../flows/checkout/present.yaml +- runFlow: ../../flows/checkout/close.yaml +# The four targets return to different screens, so this test asserts dismissal only. +# The tests that place an order assert what the cart holds afterwards. +- runFlow: ../../flows/checkout/assert-dismissed.yaml diff --git a/e2e/tests/shared/launch-smoke.yaml b/e2e/tests/shared/launch-smoke.yaml index 5f534a86b..525235237 100644 --- a/e2e/tests/shared/launch-smoke.yaml +++ b/e2e/tests/shared/launch-smoke.yaml @@ -1,4 +1,8 @@ appId: ${E2E_APP_ID} +name: Launch smoke +tags: + - launch + - smoke --- - launchApp - extendedWaitUntil: diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/build.gradle b/platforms/android/samples/CheckoutKitAndroidDemo/app/build.gradle index 76fefc336..0728bf374 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/build.gradle +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/build.gradle @@ -39,6 +39,9 @@ def customerAccountApiRedirectUri = properties.getProperty("CUSTOMER_ACCOUNT_API def customerAccountApiGraphQLBaseUrl = properties.getProperty("CUSTOMER_ACCOUNT_API_GRAPHQL_BASE_URL") def customerAccountApiAuthBaseUrl = properties.getProperty("CUSTOMER_ACCOUNT_API_AUTH_BASE_URL") +// User agent suffix the customer account login web view appends. Empty outside CI. +def customUserAgent = propertyOrDefault("CUSTOM_USER_AGENT", "") + if (!customerAccountApiRedirectUri && customerAccountApiShopId) { customerAccountApiRedirectUri = "shop.${customerAccountApiShopId}.app://callback" } @@ -53,7 +56,16 @@ if (!customerAccountApiAuthBaseUrl && customerAccountApiShopId) { // Demo buyer identity (prefill toggle in Settings) def prefillEmail = properties.getProperty("EMAIL", properties.getProperty("PREFILL_EMAIL", "test.buyer@example.com")) -def prefillPhone = properties.getProperty("PHONE", properties.getProperty("PREFILL_PHONE", "+14165550100")) +def prefillPhone = properties.getProperty("PHONE", properties.getProperty("PREFILL_PHONE", "+12135550142")) +def prefillFirstName = propertyOrDefault("FIRST_NAME", "Evelyn") +def prefillLastName = propertyOrDefault("LAST_NAME", "Hartley") +def prefillAddress1 = propertyOrDefault("ADDRESS_1", "700 S Flower St") +def prefillAddress2 = propertyOrDefault("ADDRESS_2", "Shopify HQ") +def prefillCompany = propertyOrDefault("COMPANY", "Shopify") +def prefillCity = propertyOrDefault("CITY", "Los Angeles") +def prefillProvince = propertyOrDefault("PROVINCE", "CA") +def prefillZip = propertyOrDefault("ZIP", "90017") +def prefillCountry = propertyOrDefault("COUNTRY", "US") if (!storefrontDomain || !accessToken) { println("**** Please add a .env file with STOREFRONT_DOMAIN and STOREFRONT_ACCESS_TOKEN set *****") @@ -81,8 +93,18 @@ android { buildConfigField "String", "customerAccountApiRedirectUri", "\"$customerAccountApiRedirectUri\"" buildConfigField "String", "customerAccountApiAuthBaseUrl", "\"$customerAccountApiAuthBaseUrl\"" buildConfigField "String", "customerAccountApiGraphQLBaseUrl", "\"$customerAccountApiGraphQLBaseUrl\"" + buildConfigField "String", "customUserAgent", "\"$customUserAgent\"" buildConfigField "String", "prefillEmail", "\"$prefillEmail\"" buildConfigField "String", "prefillPhone", "\"$prefillPhone\"" + buildConfigField "String", "prefillFirstName", "\"$prefillFirstName\"" + buildConfigField "String", "prefillLastName", "\"$prefillLastName\"" + buildConfigField "String", "prefillAddress1", "\"$prefillAddress1\"" + buildConfigField "String", "prefillAddress2", "\"$prefillAddress2\"" + buildConfigField "String", "prefillCompany", "\"$prefillCompany\"" + buildConfigField "String", "prefillCity", "\"$prefillCity\"" + buildConfigField "String", "prefillProvince", "\"$prefillProvince\"" + buildConfigField "String", "prefillZip", "\"$prefillZip\"" + buildConfigField "String", "prefillCountry", "\"$prefillCountry\"" } signingConfigs { diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/AndroidManifest.xml b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/AndroidManifest.xml index 57a174da6..4da036253 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/AndroidManifest.xml +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/AndroidManifest.xml @@ -54,6 +54,7 @@ android:name=".MainActivity" android:configChanges="orientation|keyboardHidden" android:exported="true" + android:launchMode="singleTask" android:screenOrientation="portrait" tools:ignore="LockedOrientationActivity"> @@ -61,6 +62,13 @@ + + + + + + + diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/CheckoutKitApp.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/CheckoutKitApp.kt index 23a392f16..e98be9b34 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/CheckoutKitApp.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/CheckoutKitApp.kt @@ -52,6 +52,7 @@ import com.shopify.checkout_kit_android_demo.common.navigation.BottomAppBarWithN import com.shopify.checkout_kit_android_demo.common.navigation.CheckoutKitNavHost import com.shopify.checkout_kit_android_demo.common.navigation.Screen import com.shopify.checkout_kit_android_demo.common.ui.theme.CheckoutKitSampleTheme +import com.shopify.checkout_kit_android_demo.e2e.E2ENavigationEffect import com.shopify.checkout_kit_android_demo.e2e.E2ETestIds import com.shopify.checkout_kit_android_demo.logs.LogsViewModel import com.shopify.checkout_kit_android_demo.settings.SettingsUiState @@ -99,6 +100,9 @@ fun CheckoutKitAppRoot( .testTag(E2ETestIds.APP_READY), ) { val navController = rememberNavController() + + E2ENavigationEffect(navController) + var currentScreen by remember { mutableStateOf(Screen.Product) } var presentedCheckoutUrl by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() @@ -138,9 +142,10 @@ fun CheckoutKitAppRoot( ) }, actions = { - IconButton(onClick = { - navController.navigate(Screen.Cart.route) - }) { + IconButton( + onClick = { navController.navigate(Screen.Cart.route) }, + modifier = Modifier.testTag(E2ETestIds.Tabs.CART), + ) { BadgedBox(badge = { if (totalQuantity > 0) { Badge( diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/MainActivity.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/MainActivity.kt index 74f39805c..4763603bf 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/MainActivity.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/MainActivity.kt @@ -1,6 +1,7 @@ package com.shopify.checkout_kit_android_demo import android.Manifest +import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Bundle @@ -14,6 +15,7 @@ import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat +import com.shopify.checkout_kit_android_demo.e2e.E2EControlLinkHandler import timber.log.Timber import timber.log.Timber.DebugTree @@ -45,6 +47,8 @@ class MainActivity : ComponentActivity() { CheckoutKitApp() } + E2EControlLinkHandler.handle(this, intent) + requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> val fileChooserParams = this.fileChooserParams if (isGranted && fileChooserParams != null) { @@ -67,6 +71,12 @@ class MainActivity : ComponentActivity() { } } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + + E2EControlLinkHandler.handle(this, intent) + } + fun onShowFileChooser(filePathCallback: ValueCallback>, fileChooserParams: FileChooserParams): Boolean { this.filePathCallback = filePathCallback if (permissionAlreadyGranted(Manifest.permission.CAMERA)) { diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/cart/CartView.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/cart/CartView.kt index 21fbdc533..8a38cc2e7 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/cart/CartView.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/cart/CartView.kt @@ -31,6 +31,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration @@ -49,6 +50,7 @@ import com.shopify.checkout_kit_android_demo.common.components.MoneyText import com.shopify.checkout_kit_android_demo.common.components.ProgressIndicator import com.shopify.checkout_kit_android_demo.common.ui.theme.horizontalPadding import com.shopify.checkout_kit_android_demo.common.ui.theme.verticalPadding +import com.shopify.checkout_kit_android_demo.e2e.E2ETestIds import com.shopify.checkout_kit_android_demo.settings.data.CheckoutPresentationMode @Composable @@ -240,6 +242,7 @@ private fun CheckoutButton( Button( shape = RectangleShape, onClick = onClick, + modifier = Modifier.testTag(E2ETestIds.Cart.CHECKOUT_BUTTON), ) { Column { Text( @@ -250,6 +253,7 @@ private fun CheckoutButton( modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp) + .testTag(E2ETestIds.Cart.CHECKOUT_READY) ) } } @@ -271,6 +275,7 @@ private fun EmptyCartMessage( ) { Header2( text = stringResource(id = R.string.cart_empty), + modifier = Modifier.testTag(E2ETestIds.Cart.EMPTY_MESSAGE), ) BodyMedium( stringResource(id = R.string.cart_emtpy_description), diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/cart/data/CartRepository.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/cart/data/CartRepository.kt index a948e1c9e..cb3e38676 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/cart/data/CartRepository.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/cart/data/CartRepository.kt @@ -4,6 +4,7 @@ import com.apollographql.apollo.api.Optional import com.shopify.checkout_kit_android_demo.common.ID import com.shopify.checkout_kit_android_demo.common.client.StorefrontApiClient import com.shopify.checkout_kit_android_demo.graphql.type.CartBuyerIdentityInput +import com.shopify.checkout_kit_android_demo.graphql.type.CartDeliveryInput import com.shopify.checkout_kit_android_demo.graphql.type.CartInput import com.shopify.checkout_kit_android_demo.graphql.type.CartLineInput import com.shopify.checkout_kit_android_demo.graphql.type.CartLineUpdateInput @@ -20,17 +21,7 @@ class CartRepository( demoBuyerIdentityEnabled: Boolean, customerAccessToken: String?, ): CartState.Cart { - val input = CartInput( - lines = Optional.present( - listOf( - CartLineInput( - merchandiseId = variantId.id, - quantity = Optional.present(quantity), - ) - ) - ), - buyerIdentity = Optional.present(buyerIdentity(demoBuyerIdentityEnabled, customerAccessToken)), - ) + val input = cartInput(variantId, quantity, demoBuyerIdentityEnabled, customerAccessToken) val data = storefrontApiClient.createCart(input) val cartCreate = data.cartCreate @@ -76,17 +67,53 @@ class CartRepository( } } - private fun buyerIdentity(demoBuyerIdentityEnabled: Boolean, customerAccessToken: String?): CartBuyerIdentityInput { - if (customerAccessToken != null) { - Timber.i("Setting a customer access token in buyer identity") - return CartBuyerIdentityInput(customerAccessToken = Optional.present(customerAccessToken)) + companion object { + internal fun cartInput( + variantId: ID, + quantity: Int, + demoBuyerIdentityEnabled: Boolean, + customerAccessToken: String?, + ) = CartInput( + lines = Optional.present( + listOf( + CartLineInput( + merchandiseId = variantId.id, + quantity = Optional.present(quantity), + ) + ) + ), + buyerIdentity = Optional.present(buyerIdentity(demoBuyerIdentityEnabled, customerAccessToken)), + delivery = delivery(demoBuyerIdentityEnabled, customerAccessToken), + ) + + private fun buyerIdentity( + demoBuyerIdentityEnabled: Boolean, + customerAccessToken: String?, + ): CartBuyerIdentityInput { + if (customerAccessToken != null) { + Timber.i("Setting a customer access token in buyer identity") + return CartBuyerIdentityInput(customerAccessToken = Optional.present(customerAccessToken)) + } + + return if (demoBuyerIdentityEnabled) { + Timber.i("Using demo buyer identity data to prefill checkout") + DemoBuyerIdentity.value + } else { + CartBuyerIdentityInput(countryCode = Optional.present(CountryCode.CA)) + } } - return if (demoBuyerIdentityEnabled) { - Timber.i("Using demo buyer identity data to prefill checkout") - DemoBuyerIdentity.value - } else { - CartBuyerIdentityInput(countryCode = Optional.present(CountryCode.CA)) + // A signed in customer picks from the addresses the account already holds, so only the + // demo identity carries one of its own. + private fun delivery( + demoBuyerIdentityEnabled: Boolean, + customerAccessToken: String?, + ): Optional { + if (customerAccessToken != null || !demoBuyerIdentityEnabled) { + return Optional.Absent + } + + return Optional.present(DemoBuyerIdentity.delivery) } } } diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/cart/data/DemoBuyerIdentity.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/cart/data/DemoBuyerIdentity.kt index 65e70bb30..17f5a4a70 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/cart/data/DemoBuyerIdentity.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/cart/data/DemoBuyerIdentity.kt @@ -2,13 +2,46 @@ package com.shopify.checkout_kit_android_demo.cart.data import com.apollographql.apollo.api.Optional import com.shopify.checkout_kit_android_demo.BuildConfig +import com.shopify.checkout_kit_android_demo.graphql.type.CartAddressInput import com.shopify.checkout_kit_android_demo.graphql.type.CartBuyerIdentityInput +import com.shopify.checkout_kit_android_demo.graphql.type.CartDeliveryAddressInput +import com.shopify.checkout_kit_android_demo.graphql.type.CartDeliveryInput +import com.shopify.checkout_kit_android_demo.graphql.type.CartSelectableAddressInput import com.shopify.checkout_kit_android_demo.graphql.type.CountryCode object DemoBuyerIdentity { + private val countryCode = CountryCode.safeValueOf(BuildConfig.prefillCountry) + internal val value = CartBuyerIdentityInput( email = Optional.present(BuildConfig.prefillEmail), - countryCode = Optional.present(CountryCode.CA), + countryCode = Optional.present(countryCode), phone = Optional.present(BuildConfig.prefillPhone), ) + + internal val delivery = CartDeliveryInput( + addresses = Optional.present( + listOf( + CartSelectableAddressInput( + address = CartAddressInput( + deliveryAddress = Optional.present( + CartDeliveryAddressInput( + address1 = Optional.present(BuildConfig.prefillAddress1), + address2 = Optional.present(BuildConfig.prefillAddress2), + city = Optional.present(BuildConfig.prefillCity), + company = Optional.present(BuildConfig.prefillCompany), + countryCode = Optional.present(countryCode), + firstName = Optional.present(BuildConfig.prefillFirstName), + lastName = Optional.present(BuildConfig.prefillLastName), + phone = Optional.present(BuildConfig.prefillPhone), + provinceCode = Optional.present(BuildConfig.prefillProvince), + zip = Optional.present(BuildConfig.prefillZip), + ) + ) + ), + selected = Optional.present(true), + oneTimeUse = Optional.present(true), + ) + ) + ) + ) } diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/e2e/E2EControlLink.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/e2e/E2EControlLink.kt new file mode 100644 index 000000000..13733e679 --- /dev/null +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/e2e/E2EControlLink.kt @@ -0,0 +1,141 @@ +package com.shopify.checkout_kit_android_demo.e2e + +import java.net.URI +import java.net.URISyntaxException +import java.net.URLDecoder + +enum class E2EBuyerIdentityMode(val parameterValue: String) { + GUEST("guest"), + HARDCODED("hardcoded"), + CUSTOMER_ACCOUNT("customerAccount"), + ; + + companion object { + fun from(parameterValue: String) = entries.firstOrNull { it.parameterValue == parameterValue } + } +} + +sealed interface E2EControlLink { + data object Reset : E2EControlLink + + data class Cart( + val variantId: String? = null, + val productIndex: Int? = null, + val quantity: Int = 1, + val buyerIdentityMode: E2EBuyerIdentityMode? = null, + ) : E2EControlLink + + data object SignIn : E2EControlLink + + companion object { + const val HOST = "e2e" + + private const val SCHEME_SEPARATOR = "://" + private const val PARSE_ORIGIN_SCHEME = "https://" + + fun parse(url: String): E2EControlLink? { + val separatorIndex = url.indexOf(SCHEME_SEPARATOR) + + if (separatorIndex < 0) { + return null + } + + val authorityAndPath = url.substring(separatorIndex + SCHEME_SEPARATOR.length) + val uri = try { + URI(PARSE_ORIGIN_SCHEME + authorityAndPath) + } catch (error: URISyntaxException) { + return null + } + + if (uri.host != HOST) { + return null + } + + val parameters = parameters(uri.rawQuery) + + return when (uri.path.orEmpty().trim('/')) { + "reset" -> reset(parameters) + "cart" -> cart(parameters) + "signIn" -> signIn(parameters) + else -> throw IllegalArgumentException("Unsupported e2e command") + } + } + + private fun reset(parameters: Map): Reset { + require(parameters.isEmpty()) { "reset takes no parameters" } + + return Reset + } + + private fun cart(parameters: Map): Cart { + require(parameters.isNotEmpty()) { "Missing variantId or productIndex" } + + val quantity = quantity(parameters) + val buyerIdentityMode = buyerIdentityMode(parameters) + val variantId = parameters["variantId"] + val productIndexParameter = parameters["productIndex"] + + require(variantId == null || productIndexParameter == null) { + "Use variantId or productIndex, not both" + } + + if (variantId != null) { + require(variantId.isNotEmpty()) { "variantId must not be blank" } + + return Cart(variantId = variantId, quantity = quantity, buyerIdentityMode = buyerIdentityMode) + } + + requireNotNull(productIndexParameter) { "Missing variantId or productIndex" } + + val productIndex = productIndexParameter.toIntOrNull() + + require(productIndex != null && productIndex >= 0) { "productIndex must be a non-negative integer" } + + return Cart(productIndex = productIndex, quantity = quantity, buyerIdentityMode = buyerIdentityMode) + } + + private fun quantity(parameters: Map): Int { + val parameter = parameters["quantity"] ?: return 1 + val quantity = parameter.toIntOrNull() + + require(quantity != null && quantity >= 1) { "quantity must be a positive integer" } + + return quantity + } + + private fun buyerIdentityMode(parameters: Map): E2EBuyerIdentityMode? { + val parameter = parameters["buyerIdentityMode"] ?: return null + + return requireNotNull(E2EBuyerIdentityMode.from(parameter)) { + "buyerIdentityMode must be guest, hardcoded, or customerAccount" + } + } + + private fun signIn(parameters: Map): SignIn { + require(parameters.isEmpty()) { "signIn takes no parameters" } + + return SignIn + } + + private fun parameters(rawQuery: String?): Map { + if (rawQuery.isNullOrEmpty()) { + return emptyMap() + } + + return rawQuery + .split("&") + .filter { it.isNotEmpty() } + .associate { pair -> + val separatorIndex = pair.indexOf('=') + + if (separatorIndex < 0) { + decode(pair) to "" + } else { + decode(pair.substring(0, separatorIndex)) to decode(pair.substring(separatorIndex + 1)) + } + } + } + + private fun decode(value: String) = URLDecoder.decode(value, "UTF-8").trim() + } +} diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/e2e/E2EControlLinkHandler.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/e2e/E2EControlLinkHandler.kt new file mode 100644 index 000000000..daf11a299 --- /dev/null +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/e2e/E2EControlLinkHandler.kt @@ -0,0 +1,16 @@ +package com.shopify.checkout_kit_android_demo.e2e + +import android.content.Intent +import androidx.activity.ComponentActivity +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch + +object E2EControlLinkHandler { + fun handle(activity: ComponentActivity, intent: Intent) { + val url = intent.data?.toString() ?: return + + activity.lifecycleScope.launch { + E2EController(E2ESampleAppTarget()).handle(url) + } + } +} diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/e2e/E2EController.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/e2e/E2EController.kt new file mode 100644 index 000000000..a67fc21ce --- /dev/null +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/e2e/E2EController.kt @@ -0,0 +1,62 @@ +package com.shopify.checkout_kit_android_demo.e2e + +interface E2ECommandTarget { + suspend fun selectBuyerIdentityMode(mode: E2EBuyerIdentityMode) + + suspend fun resetCart() + + suspend fun variantId(atProductIndex: Int): String + + suspend fun addCartLine(variantId: String, quantity: Int) + + suspend fun showCart() + + suspend fun presentSignIn() + + suspend fun report(failure: String) +} + +class E2EController(private val target: E2ECommandTarget) { + + suspend fun handle(url: String): Boolean { + val link = try { + E2EControlLink.parse(url) + } catch (error: IllegalArgumentException) { + target.report(message(error)) + return true + } + + if (link == null) { + return false + } + + perform(link) + + return true + } + + private suspend fun perform(link: E2EControlLink) { + try { + when (link) { + is E2EControlLink.Reset -> target.resetCart() + is E2EControlLink.Cart -> seedCart(link) + is E2EControlLink.SignIn -> target.presentSignIn() + } + } catch (error: Exception) { + target.report(message(error)) + } + } + + private suspend fun seedCart(command: E2EControlLink.Cart) { + command.buyerIdentityMode?.let { target.selectBuyerIdentityMode(it) } + + target.resetCart() + + val variantId = command.variantId ?: target.variantId(command.productIndex ?: 0) + + target.addCartLine(variantId, command.quantity) + target.showCart() + } + + private fun message(error: Throwable) = error.message ?: error.toString() +} diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/e2e/E2ENavigation.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/e2e/E2ENavigation.kt new file mode 100644 index 000000000..9d99b3762 --- /dev/null +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/e2e/E2ENavigation.kt @@ -0,0 +1,26 @@ +package com.shopify.checkout_kit_android_demo.e2e + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.navigation.NavController +import com.shopify.checkout_kit_android_demo.common.navigation.Screen +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow + +object E2ENavigation { + val destinations = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + suspend fun go(screen: Screen) = destinations.emit(screen) +} + +@Composable +fun E2ENavigationEffect(navController: NavController) { + LaunchedEffect(navController) { + E2ENavigation.destinations.collect { screen -> + navController.navigate(screen.route) + } + } +} diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/e2e/E2ESampleAppTarget.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/e2e/E2ESampleAppTarget.kt new file mode 100644 index 000000000..0fa78868d --- /dev/null +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/e2e/E2ESampleAppTarget.kt @@ -0,0 +1,63 @@ +package com.shopify.checkout_kit_android_demo.e2e + +import com.shopify.checkout_kit_android_demo.cart.CartViewModel +import com.shopify.checkout_kit_android_demo.common.ID +import com.shopify.checkout_kit_android_demo.common.navigation.Screen +import com.shopify.checkout_kit_android_demo.products.product.data.ProductRepository +import com.shopify.checkout_kit_android_demo.settings.PreferencesManager +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.suspendCancellableCoroutine +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import timber.log.Timber +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +class E2ESampleAppTarget : E2ECommandTarget, KoinComponent { + private val cartViewModel: CartViewModel by inject() + private val preferencesManager: PreferencesManager by inject() + private val productRepository: ProductRepository by inject() + + override suspend fun selectBuyerIdentityMode(mode: E2EBuyerIdentityMode) { + val enabled = mode == E2EBuyerIdentityMode.HARDCODED + + preferencesManager.setBuyerIdentityDemoEnabled(enabled) + preferencesManager.userPreferencesFlow.first { it.buyerIdentityDemoEnabled == enabled } + } + + override suspend fun resetCart() { + cartViewModel.clearCart() + } + + override suspend fun variantId(atProductIndex: Int): String { + val products = productRepository + .getProducts(numProducts = atProductIndex + 1, numVariants = 1, cursor = null) + .products + + val variantId = products.getOrNull(atProductIndex)?.variants?.firstOrNull()?.id + + return variantId?.id ?: throw IllegalStateException("No product at index $atProductIndex") + } + + override suspend fun addCartLine(variantId: String, quantity: Int) { + suspendCancellableCoroutine { continuation -> + cartViewModel.addToCart(ID(variantId), quantity) { result -> + result + .onSuccess { continuation.resume(Unit) } + .onFailure { continuation.resumeWithException(it) } + } + } + } + + override suspend fun showCart() { + E2ENavigation.go(Screen.Cart) + } + + override suspend fun presentSignIn() { + E2ENavigation.go(Screen.Login) + } + + override suspend fun report(failure: String) { + Timber.e("[E2E] $failure") + } +} diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/e2e/E2ETestIds.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/e2e/E2ETestIds.kt index 4dfad84f2..999eb9f05 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/e2e/E2ETestIds.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/e2e/E2ETestIds.kt @@ -2,4 +2,18 @@ package com.shopify.checkout_kit_android_demo.e2e object E2ETestIds { const val APP_READY = "checkout-kit-sample-ready" + + object Cart { + const val CHECKOUT_READY = "cart-checkout-ready" + const val CHECKOUT_BUTTON = "checkout-button" + const val EMPTY_MESSAGE = "cart-empty-message" + } + + object Tabs { + const val CART = "cart-tab" + } + + object Account { + const val SIGNED_IN_VIEW = "account-signed-in-view" + } } diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/settings/account/AccountView.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/settings/account/AccountView.kt index bc3ada630..09e283396 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/settings/account/AccountView.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/settings/account/AccountView.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController @@ -22,6 +23,7 @@ import com.shopify.checkout_kit_android_demo.common.components.Header3 import com.shopify.checkout_kit_android_demo.common.components.ProgressIndicator import com.shopify.checkout_kit_android_demo.common.components.RemoteImage import com.shopify.checkout_kit_android_demo.common.navigation.Screen +import com.shopify.checkout_kit_android_demo.e2e.E2ETestIds import org.koin.androidx.compose.koinViewModel @Composable @@ -48,7 +50,9 @@ fun AccountView( is UIState.Loaded -> { Column( verticalArrangement = Arrangement.spacedBy(30.dp), - modifier = Modifier.padding(vertical = 20.dp, horizontal = 15.dp) + modifier = Modifier + .padding(vertical = 20.dp, horizontal = 15.dp) + .testTag(E2ETestIds.Account.SIGNED_IN_VIEW) ) { val customer = uiState.customer diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/settings/authentication/LoginView.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/settings/authentication/LoginView.kt index e4d139a2a..c283bb29f 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/settings/authentication/LoginView.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/settings/authentication/LoginView.kt @@ -50,8 +50,10 @@ fun LoginView( } is Status.LoggedIn -> { - // Navigate back to settings when login is complete - navController.navigate(Screen.Settings.route) + // Show the account when login is complete + navController.navigate(Screen.Account.route) { + popUpTo(Screen.Login.route) { inclusive = true } + } } is Status.Error -> { diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/settings/authentication/LoginWebView.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/settings/authentication/LoginWebView.kt index 687082818..342c7ca34 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/settings/authentication/LoginWebView.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/settings/authentication/LoginWebView.kt @@ -8,6 +8,7 @@ import android.webkit.WebViewClient import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.AndroidView +import com.shopify.checkout_kit_android_demo.BuildConfig /** * WebView used to display the login page and intercept authorization code param redirects @@ -27,7 +28,12 @@ fun LoginWebView( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) - settings.apply { javaScriptEnabled = true } + settings.apply { + javaScriptEnabled = true + BuildConfig.customUserAgent.takeIf(String::isNotEmpty)?.let { suffix -> + userAgentString = "$userAgentString $suffix" + } + } webViewClient = AuthenticationWebViewClient(customerAccountApiRedirectUri, onCodeParamIntercepted) } }, diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkout_kit_android_demo/cart/data/CartRepositoryTest.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkout_kit_android_demo/cart/data/CartRepositoryTest.kt new file mode 100644 index 000000000..ed9257ff6 --- /dev/null +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkout_kit_android_demo/cart/data/CartRepositoryTest.kt @@ -0,0 +1,38 @@ +package com.shopify.checkout_kit_android_demo.cart.data + +import com.shopify.checkout_kit_android_demo.common.ID +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class CartRepositoryTest { + + @Test + fun `demo buyer identity supplies the delivery address`() { + val input = cartInput(demoBuyerIdentityEnabled = true, customerAccessToken = null) + + assertThat(input.delivery.getOrThrow()).isEqualTo(DemoBuyerIdentity.delivery) + assertThat(input.buyerIdentity.getOrThrow()).isEqualTo(DemoBuyerIdentity.value) + } + + @Test + fun `a guest supplies no delivery address`() { + val input = cartInput(demoBuyerIdentityEnabled = false, customerAccessToken = null) + + assertThat(input.delivery.getOrNull()).isNull() + } + + @Test + fun `a signed in customer supplies no delivery address`() { + val input = cartInput(demoBuyerIdentityEnabled = true, customerAccessToken = "token") + + assertThat(input.delivery.getOrNull()).isNull() + } + + private fun cartInput(demoBuyerIdentityEnabled: Boolean, customerAccessToken: String?) = + CartRepository.cartInput( + variantId = ID("gid://shopify/ProductVariant/1"), + quantity = 1, + demoBuyerIdentityEnabled = demoBuyerIdentityEnabled, + customerAccessToken = customerAccessToken, + ) +} diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkout_kit_android_demo/cart/data/DemoBuyerIdentityTest.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkout_kit_android_demo/cart/data/DemoBuyerIdentityTest.kt new file mode 100644 index 000000000..a4bec8f7b --- /dev/null +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkout_kit_android_demo/cart/data/DemoBuyerIdentityTest.kt @@ -0,0 +1,44 @@ +package com.shopify.checkout_kit_android_demo.cart.data + +import com.shopify.checkout_kit_android_demo.BuildConfig +import com.shopify.checkout_kit_android_demo.graphql.type.CountryCode +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class DemoBuyerIdentityTest { + + @Test + fun `buyer identity carries the contact details`() { + val identity = DemoBuyerIdentity.value + + assertThat(identity.email.getOrThrow()).isEqualTo(BuildConfig.prefillEmail) + assertThat(identity.phone.getOrThrow()).isEqualTo(BuildConfig.prefillPhone) + assertThat(identity.countryCode.getOrThrow()).isEqualTo(CountryCode.safeValueOf(BuildConfig.prefillCountry)) + } + + @Test + fun `delivery carries one pre-selected one-time address`() { + val addresses = DemoBuyerIdentity.delivery.addresses.getOrThrow()!! + + assertThat(addresses).hasSize(1) + assertThat(addresses.first().selected.getOrThrow()).isTrue() + assertThat(addresses.first().oneTimeUse.getOrThrow()).isTrue() + } + + @Test + fun `delivery address carries every field checkout asks a guest for`() { + val address = DemoBuyerIdentity.delivery.addresses.getOrThrow()!! + .first().address.deliveryAddress.getOrThrow()!! + + assertThat(address.firstName.getOrThrow()).isEqualTo(BuildConfig.prefillFirstName) + assertThat(address.lastName.getOrThrow()).isEqualTo(BuildConfig.prefillLastName) + assertThat(address.address1.getOrThrow()).isEqualTo(BuildConfig.prefillAddress1) + assertThat(address.address2.getOrThrow()).isEqualTo(BuildConfig.prefillAddress2) + assertThat(address.company.getOrThrow()).isEqualTo(BuildConfig.prefillCompany) + assertThat(address.city.getOrThrow()).isEqualTo(BuildConfig.prefillCity) + assertThat(address.provinceCode.getOrThrow()).isEqualTo(BuildConfig.prefillProvince) + assertThat(address.zip.getOrThrow()).isEqualTo(BuildConfig.prefillZip) + assertThat(address.phone.getOrThrow()).isEqualTo(BuildConfig.prefillPhone) + assertThat(address.countryCode.getOrThrow()).isEqualTo(CountryCode.safeValueOf(BuildConfig.prefillCountry)) + } +} diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkout_kit_android_demo/e2e/E2EControlLinkTest.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkout_kit_android_demo/e2e/E2EControlLinkTest.kt new file mode 100644 index 000000000..263dcc4ec --- /dev/null +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkout_kit_android_demo/e2e/E2EControlLinkTest.kt @@ -0,0 +1,143 @@ +package com.shopify.checkout_kit_android_demo.e2e + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.Test + +class E2EControlLinkTest { + @Test + fun `returns null when the link is not a control link`() { + assertThat(E2EControlLink.parse("https://example.com/cart")).isNull() + assertThat(E2EControlLink.parse("com.shopify.checkout_kit_android_demo://products/1")).isNull() + assertThat(E2EControlLink.parse("not a url")).isNull() + } + + @Test + fun `parses every app scheme`() { + val expected = E2EControlLink.Cart(productIndex = 0) + val schemes = listOf( + "com.shopify.checkoutkit.androiddemo", + "com.shopify.checkoutkit.swiftdemo", + "com.shopify.checkoutkit.reactnativedemo", + ) + + schemes.forEach { scheme -> + assertThat(E2EControlLink.parse("$scheme://e2e/cart?productIndex=0")).isEqualTo(expected) + } + } + + @Test + fun `parses a scheme the matrix does not declare`() { + assertThat(E2EControlLink.parse("com.example.anything://e2e/cart?productIndex=0")) + .isEqualTo(E2EControlLink.Cart(productIndex = 0)) + } + + @Test + fun `parses the reset command`() { + assertThat(parse("/reset")).isEqualTo(E2EControlLink.Reset) + } + + @Test + fun `rejects parameters on the reset command`() { + assertRejects("/reset?productIndex=0", "reset takes no parameters") + } + + @Test + fun `rejects unknown commands`() { + assertRejects("", "Unsupported e2e command") + assertRejects("/", "Unsupported e2e command") + assertRejects("/teleport?productIndex=0", "Unsupported e2e command") + assertRejects("/cart/extra?productIndex=0", "Unsupported e2e command") + } + + @Test + fun `rejects cart commands without a product selector`() { + assertRejects("/cart", "Missing variantId or productIndex") + assertRejects("/cart?", "Missing variantId or productIndex") + assertRejects("/cart?quantity=2", "Missing variantId or productIndex") + } + + @Test + fun `rejects cart commands with both product selectors`() { + assertRejects( + "/cart?variantId=gid://shopify/ProductVariant/1&productIndex=0", + "Use variantId or productIndex, not both", + ) + } + + @Test + fun `rejects a blank variant id`() { + assertRejects("/cart?variantId=", "variantId must not be blank") + assertRejects("/cart?variantId=%20", "variantId must not be blank") + } + + @Test + fun `rejects invalid quantities`() { + listOf("", "0", "-1", "1.5", "abc").forEach { quantity -> + assertRejects("/cart?productIndex=0&quantity=$quantity", "quantity must be a positive integer") + } + } + + @Test + fun `rejects invalid product indexes`() { + listOf("", "-1", "1.5", "abc").forEach { productIndex -> + assertRejects("/cart?productIndex=$productIndex", "productIndex must be a non-negative integer") + } + } + + @Test + fun `rejects invalid buyer identity modes`() { + listOf("", "member").forEach { buyerIdentityMode -> + assertRejects( + "/cart?productIndex=0&buyerIdentityMode=$buyerIdentityMode", + "buyerIdentityMode must be guest, hardcoded, or customerAccount", + ) + } + } + + @Test + fun `parses a cart command with a variant id`() { + val link = parse("/cart?variantId=gid://shopify/ProductVariant/1&quantity=2&buyerIdentityMode=guest") + + assertThat(link).isEqualTo( + E2EControlLink.Cart( + variantId = "gid://shopify/ProductVariant/1", + quantity = 2, + buyerIdentityMode = E2EBuyerIdentityMode.GUEST, + ), + ) + } + + @Test + fun `parses a cart command with a product index and the default quantity`() { + val link = parse("/cart?productIndex=3&buyerIdentityMode=hardcoded") + + assertThat(link).isEqualTo( + E2EControlLink.Cart(productIndex = 3, quantity = 1, buyerIdentityMode = E2EBuyerIdentityMode.HARDCODED), + ) + } + + @Test + fun `parses a cart command with a trailing slash`() { + assertThat(parse("/cart/?productIndex=3")).isEqualTo(E2EControlLink.Cart(productIndex = 3)) + } + + @Test + fun `parses a sign in command`() { + assertThat(parse("/signIn")).isEqualTo(E2EControlLink.SignIn) + } + + @Test + fun `rejects sign in parameters`() { + assertRejects("/signIn?email=shopper@example.com", "signIn takes no parameters") + } + + private fun parse(path: String) = E2EControlLink.parse("com.shopify.checkout_kit_android_demo://e2e$path") + + private fun assertRejects(path: String, message: String) { + assertThatThrownBy { parse(path) } + .describedAs(path) + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessage(message) + } +} diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkout_kit_android_demo/e2e/E2EControllerTest.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkout_kit_android_demo/e2e/E2EControllerTest.kt new file mode 100644 index 000000000..7d3167c23 --- /dev/null +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkout_kit_android_demo/e2e/E2EControllerTest.kt @@ -0,0 +1,139 @@ +package com.shopify.checkout_kit_android_demo.e2e + +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class E2EControllerTest { + @Test + fun `ignores links that are not control links`() { + val target = E2ECommandTargetSpy() + + val handled = runBlocking { E2EController(target).handle("https://example.com/cart") } + + assertThat(handled).isFalse() + assertThat(target.calls).isEmpty() + } + + @Test + fun `reports a parse failure`() { + val target = E2ECommandTargetSpy() + + val handled = handle("/teleport", target) + + assertThat(handled).isTrue() + assertThat(target.calls).containsExactly("report(Unsupported e2e command)") + } + + @Test + fun `resets the cart`() { + val target = E2ECommandTargetSpy() + + val handled = handle("/reset", target) + + assertThat(handled).isTrue() + assertThat(target.calls).containsExactly("resetCart") + } + + @Test + fun `seeds the cart from a variant id`() { + val target = E2ECommandTargetSpy() + + handle("/cart?variantId=gid://shopify/ProductVariant/1&quantity=3&buyerIdentityMode=hardcoded", target) + + assertThat(target.calls).containsExactly( + "selectBuyerIdentityMode(hardcoded)", + "resetCart", + "addCartLine(gid://shopify/ProductVariant/1, 3)", + "showCart", + ) + } + + @Test + fun `seeds the cart from a product index`() { + val target = E2ECommandTargetSpy() + + handle("/cart?productIndex=2", target) + + assertThat(target.calls).containsExactly( + "resetCart", + "variantId(atProductIndex: 2)", + "addCartLine(variant-2, 1)", + "showCart", + ) + } + + @Test + fun `selects the buyer identity mode before seeding because selecting it resets the cart`() { + val target = E2ECommandTargetSpy() + + handle("/cart?productIndex=0&buyerIdentityMode=guest", target) + + assertThat(target.calls.first()).isEqualTo("selectBuyerIdentityMode(guest)") + } + + @Test + fun `reports a seed failure and does not show the cart`() { + val target = E2ECommandTargetSpy() + target.variantIdError = IllegalStateException("No product at index 9") + + handle("/cart?productIndex=9", target) + + assertThat(target.calls).containsExactly( + "resetCart", + "variantId(atProductIndex: 9)", + "report(No product at index 9)", + ) + } + + @Test + fun `presents sign in`() { + val target = E2ECommandTargetSpy() + + handle("/signIn", target) + + assertThat(target.calls).containsExactly("presentSignIn") + } + + private fun handle(path: String, target: E2ECommandTargetSpy) = runBlocking { + E2EController(target).handle("com.shopify.checkout_kit_android_demo://e2e$path") + } +} + +private class E2ECommandTargetSpy : E2ECommandTarget { + val calls = mutableListOf() + var variantIdError: Throwable? = null + var addCartLineError: Throwable? = null + + override suspend fun selectBuyerIdentityMode(mode: E2EBuyerIdentityMode) { + calls.add("selectBuyerIdentityMode(${mode.parameterValue})") + } + + override suspend fun resetCart() { + calls.add("resetCart") + } + + override suspend fun variantId(atProductIndex: Int): String { + calls.add("variantId(atProductIndex: $atProductIndex)") + variantIdError?.let { throw it } + + return "variant-$atProductIndex" + } + + override suspend fun addCartLine(variantId: String, quantity: Int) { + calls.add("addCartLine($variantId, $quantity)") + addCartLineError?.let { throw it } + } + + override suspend fun showCart() { + calls.add("showCart") + } + + override suspend fun presentSignIn() { + calls.add("presentSignIn") + } + + override suspend fun report(failure: String) { + calls.add("report($failure)") + } +} diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkout_kit_android_demo/e2e/E2ETestIdsTest.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkout_kit_android_demo/e2e/E2ETestIdsTest.kt index a62f3412f..cc65ff288 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkout_kit_android_demo/e2e/E2ETestIdsTest.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkout_kit_android_demo/e2e/E2ETestIdsTest.kt @@ -8,4 +8,21 @@ class E2ETestIdsTest { fun `app ready marker matches the maestro flows`() { assertThat(E2ETestIds.APP_READY).isEqualTo("checkout-kit-sample-ready") } + + @Test + fun `cart markers match the maestro flows`() { + assertThat(E2ETestIds.Cart.CHECKOUT_READY).isEqualTo("cart-checkout-ready") + assertThat(E2ETestIds.Cart.CHECKOUT_BUTTON).isEqualTo("checkout-button") + assertThat(E2ETestIds.Cart.EMPTY_MESSAGE).isEqualTo("cart-empty-message") + } + + @Test + fun `tab markers match the maestro flows`() { + assertThat(E2ETestIds.Tabs.CART).isEqualTo("cart-tab") + } + + @Test + fun `account markers match the maestro flows`() { + assertThat(E2ETestIds.Account.SIGNED_IN_VIEW).isEqualTo("account-signed-in-view") + } } diff --git a/platforms/android/scripts/e2e_maestro b/platforms/android/scripts/e2e_maestro new file mode 100755 index 000000000..7a27701f9 --- /dev/null +++ b/platforms/android/scripts/e2e_maestro @@ -0,0 +1,92 @@ +#!/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)" +SAMPLE_DIR="$ROOT_DIR/samples/CheckoutKitAndroidDemo" +APP_ID="com.shopify.checkoutkit.androiddemo" +READY_MARKER="checkout-kit-sample-ready" +INCLUDE_TAGS="" +# e2e/config.yaml quarantines flaky and wip for every consumer, so this stays empty +# unless the caller excludes something else. +EXCLUDE_TAGS="" + +usage() { + cat <&2 +Usage: dev android e2e [--tags TAG[,TAG...]] [--exclude-tags TAG[,TAG...]] + +Builds the Android sample app, installs it on the booted emulator, and runs the +Maestro tests in e2e/tests. Without --tags, every test 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. +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 + +require_maestro() { + if ! maestro --version >/dev/null 2>&1; then + echo "Maestro is required to run E2E tests." >&2 + echo "Install Maestro and make sure maestro --version succeeds." >&2 + return 1 + fi +} + +attached_device() { + adb devices | awk '$2 == "device" { print $1; exit }' +} + +require_maestro + +DEVICE_ID="$(attached_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 + exit 1 +fi + +echo "Using device: $DEVICE_ID" + +"$SAMPLE_DIR/gradlew" -p "$SAMPLE_DIR" :app:installDebug --console=plain + +E2E_DEVICE_ID="$DEVICE_ID" \ + "$REPO_ROOT/e2e/scripts/run_maestro" android "$APP_ID" "$READY_MARKER" "$INCLUDE_TAGS" "$EXCLUDE_TAGS" diff --git a/platforms/react-native/sample/android/app/src/main/AndroidManifest.template.xml b/platforms/react-native/sample/android/app/src/main/AndroidManifest.template.xml index e0d538711..66d1050ce 100644 --- a/platforms/react-native/sample/android/app/src/main/AndroidManifest.template.xml +++ b/platforms/react-native/sample/android/app/src/main/AndroidManifest.template.xml @@ -41,7 +41,8 @@ - + + diff --git a/platforms/react-native/sample/src/App.tsx b/platforms/react-native/sample/src/App.tsx index 787104c33..cae226616 100644 --- a/platforms/react-native/sample/src/App.tsx +++ b/platforms/react-native/sample/src/App.tsx @@ -12,6 +12,7 @@ import { NavigationContainer, useNavigation, type NavigationProp, + type NavigatorScreenParams, } from '@react-navigation/native'; import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'; import {createNativeStackNavigator} from '@react-navigation/native-stack'; @@ -79,7 +80,7 @@ export type RootStackParamList = { ProductDetails: {product: ShopifyProduct; variant?: ProductVariant}; Cart: undefined; CartModal: undefined; - Account: undefined; + Account: NavigatorScreenParams; Settings: undefined; }; @@ -459,8 +460,12 @@ function Routes() { const navigateToCart = useCallback(() => { navigation.navigate('Cart'); }, [navigation]); + const navigateToSignIn = useCallback(() => { + navigation.navigate('Account', {screen: 'Login', initial: false}); + }, [navigation]); const handleE2ECartBootstrap = useE2ECartBootstrap({ onCartReady: navigateToCart, + onSignInRequested: navigateToSignIn, }); useEffect(() => { diff --git a/platforms/react-native/sample/src/e2e/__tests__/cartBootstrap.test.ts b/platforms/react-native/sample/src/e2e/__tests__/cartBootstrap.test.ts deleted file mode 100644 index 09811c03d..000000000 --- a/platforms/react-native/sample/src/e2e/__tests__/cartBootstrap.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import {CART_BOOTSTRAP_ROUTE, parseCartBootstrapLink} from '../cartBootstrap'; -import {BuyerIdentityMode} from '../../auth/types'; - -describe('parseCartBootstrapLink', () => { - it('ignores non-cart-bootstrap URLs', () => { - expect(parseCartBootstrapLink('https://example.com/cart')).toBeNull(); - }); - - it('rejects unsupported bootstrap routes', () => { - expect(() => - parseCartBootstrapLink( - 'com.shopify.checkoutkit.reactnativedemo://account?productIndex=0', - ), - ).toThrow('Unsupported cart bootstrap path'); - }); - - it('rejects bootstrap links without a query string', () => { - expect(() => parseCartBootstrapLink(CART_BOOTSTRAP_ROUTE)).toThrow( - 'Missing variantId or productIndex', - ); - }); - - it.each(['0', '-1', '1.5', 'abc'])( - 'rejects invalid quantity %s', - quantity => { - expect(() => - parseCartBootstrapLink( - `${CART_BOOTSTRAP_ROUTE}?productIndex=0&quantity=${quantity}`, - ), - ).toThrow('quantity must be a positive integer'); - }, - ); - - it('rejects links with both variantId and productIndex', () => { - expect(() => - parseCartBootstrapLink( - `${CART_BOOTSTRAP_ROUTE}?variantId=gid://shopify/ProductVariant/1&productIndex=0`, - ), - ).toThrow('Use variantId or productIndex, not both'); - }); - - it('rejects links without variantId or productIndex', () => { - expect(() => parseCartBootstrapLink(`${CART_BOOTSTRAP_ROUTE}?`)).toThrow( - 'Missing variantId or productIndex', - ); - }); - - it.each(['-1', '1.5', 'abc'])( - 'rejects invalid productIndex %s', - productIndex => { - expect(() => - parseCartBootstrapLink( - `${CART_BOOTSTRAP_ROUTE}?productIndex=${productIndex}`, - ), - ).toThrow('productIndex must be a non-negative integer'); - }, - ); - - it.each(['', 'member'])( - 'rejects invalid buyerIdentityMode %s', - buyerIdentityMode => { - expect(() => - parseCartBootstrapLink( - `${CART_BOOTSTRAP_ROUTE}?productIndex=0&buyerIdentityMode=${buyerIdentityMode}`, - ), - ).toThrow( - 'buyerIdentityMode must be guest, hardcoded, or customerAccount', - ); - }, - ); - - it('returns a variantId bootstrap link', () => { - expect( - parseCartBootstrapLink( - `${CART_BOOTSTRAP_ROUTE}?variantId=gid://shopify/ProductVariant/1&quantity=2&buyerIdentityMode=guest`, - ), - ).toEqual({ - variantId: 'gid://shopify/ProductVariant/1', - quantity: 2, - buyerIdentityMode: BuyerIdentityMode.Guest, - }); - }); - - it('returns a productIndex bootstrap link with default quantity', () => { - expect( - parseCartBootstrapLink( - `${CART_BOOTSTRAP_ROUTE}?productIndex=3&buyerIdentityMode=hardcoded`, - ), - ).toEqual({ - productIndex: 3, - quantity: 1, - buyerIdentityMode: BuyerIdentityMode.Hardcoded, - }); - }); - - it('returns a productIndex bootstrap link with a root path', () => { - expect( - parseCartBootstrapLink(`${CART_BOOTSTRAP_ROUTE}/?productIndex=3`), - ).toEqual({ - productIndex: 3, - quantity: 1, - }); - }); -}); diff --git a/platforms/react-native/sample/src/e2e/__tests__/controlLink.test.ts b/platforms/react-native/sample/src/e2e/__tests__/controlLink.test.ts new file mode 100644 index 000000000..5cc6e2579 --- /dev/null +++ b/platforms/react-native/sample/src/e2e/__tests__/controlLink.test.ts @@ -0,0 +1,148 @@ +import {parseControlLink} from '../controlLink'; +import {BuyerIdentityMode} from '../../auth/types'; + +function parse(path: string) { + return parseControlLink(`com.shopify.checkoutkit.reactnativedemo://e2e${path}`); +} + +function expectRejection(path: string, message: string) { + expect(() => parse(path)).toThrow(message); +} + +describe('parseControlLink', () => { + it('returns null when the link is not a control link', () => { + expect(parseControlLink('https://example.com/cart')).toBeNull(); + expect( + parseControlLink('com.shopify.checkoutkit.reactnativedemo://products/1'), + ).toBeNull(); + expect(parseControlLink('not a url')).toBeNull(); + }); + + it('parses every app scheme', () => { + const expected = {command: 'cart', productIndex: 0, quantity: 1}; + const schemes = [ + 'com.shopify.checkoutkit.reactnativedemo', + 'com.shopify.checkoutkit.swiftdemo', + 'com.shopify.checkoutkit.androiddemo', + ]; + + schemes.forEach((scheme) => { + expect(parseControlLink(`${scheme}://e2e/cart?productIndex=0`)).toEqual( + expected, + ); + }); + }); + + it('parses a scheme the matrix does not declare', () => { + expect( + parseControlLink('com.example.anything://e2e/cart?productIndex=0'), + ).toEqual({command: 'cart', productIndex: 0, quantity: 1}); + }); + + it('parses the reset command', () => { + expect(parse('/reset')).toEqual({command: 'reset'}); + }); + + it('rejects parameters on the reset command', () => { + expectRejection('/reset?productIndex=0', 'reset takes no parameters'); + }); + + it.each(['', '/', '/teleport?productIndex=0', '/cart/extra?productIndex=0'])( + 'rejects the unknown command %s', + path => { + expectRejection(path, 'Unsupported e2e command'); + }, + ); + + it.each(['/cart', '/cart?', '/cart?quantity=2'])( + 'rejects the cart command %s without a product selector', + path => { + expectRejection(path, 'Missing variantId or productIndex'); + }, + ); + + it('rejects cart commands with both product selectors', () => { + expectRejection( + '/cart?variantId=gid://shopify/ProductVariant/1&productIndex=0', + 'Use variantId or productIndex, not both', + ); + }); + + it.each(['/cart?variantId=', '/cart?variantId=%20'])( + 'rejects the blank variantId in %s', + path => { + expectRejection(path, 'variantId must not be blank'); + }, + ); + + it.each(['', '0', '-1', '1.5', 'abc'])( + 'rejects the invalid quantity %s', + quantity => { + expectRejection( + `/cart?productIndex=0&quantity=${quantity}`, + 'quantity must be a positive integer', + ); + }, + ); + + it.each(['', '-1', '1.5', 'abc'])( + 'rejects the invalid productIndex %s', + productIndex => { + expectRejection( + `/cart?productIndex=${productIndex}`, + 'productIndex must be a non-negative integer', + ); + }, + ); + + it.each(['', 'member'])( + 'rejects the invalid buyerIdentityMode %s', + buyerIdentityMode => { + expectRejection( + `/cart?productIndex=0&buyerIdentityMode=${buyerIdentityMode}`, + 'buyerIdentityMode must be guest, hardcoded, or customerAccount', + ); + }, + ); + + it('parses a cart command with a variant id', () => { + expect( + parse( + '/cart?variantId=gid://shopify/ProductVariant/1&quantity=2&buyerIdentityMode=guest', + ), + ).toEqual({ + command: 'cart', + variantId: 'gid://shopify/ProductVariant/1', + quantity: 2, + buyerIdentityMode: BuyerIdentityMode.Guest, + }); + }); + + it('parses a cart command with a product index and the default quantity', () => { + expect(parse('/cart?productIndex=3&buyerIdentityMode=hardcoded')).toEqual({ + command: 'cart', + productIndex: 3, + quantity: 1, + buyerIdentityMode: BuyerIdentityMode.Hardcoded, + }); + }); + + it('parses a cart command with a trailing slash', () => { + expect(parse('/cart/?productIndex=3')).toEqual({ + command: 'cart', + productIndex: 3, + quantity: 1, + }); + }); + + it('parses a sign in command', () => { + expect(parse('/signIn')).toEqual({command: 'signIn'}); + }); + + it('rejects sign in parameters', () => { + expectRejection( + '/signIn?email=shopper@example.com', + 'signIn takes no parameters', + ); + }); +}); diff --git a/platforms/react-native/sample/src/e2e/__tests__/controller.test.ts b/platforms/react-native/sample/src/e2e/__tests__/controller.test.ts new file mode 100644 index 000000000..7e9c9cf2b --- /dev/null +++ b/platforms/react-native/sample/src/e2e/__tests__/controller.test.ts @@ -0,0 +1,141 @@ +import {E2EController, type E2ECommandTarget} from '../controller'; +import type {BuyerIdentityMode} from '../../auth/types'; + +class E2ECommandTargetSpy implements E2ECommandTarget { + calls: string[] = []; + variantIdError: Error | null = null; + addCartLineError: Error | null = null; + + async selectBuyerIdentityMode(mode: BuyerIdentityMode) { + this.calls.push(`selectBuyerIdentityMode(${mode})`); + } + + async resetCart() { + this.calls.push('resetCart'); + } + + async variantId(productIndex: number) { + this.calls.push(`variantId(atProductIndex: ${productIndex})`); + + if (this.variantIdError) { + throw this.variantIdError; + } + + return `variant-${productIndex}`; + } + + async presentSignIn() { + this.calls.push('presentSignIn'); + } + + async addCartLine(variantId: string, quantity: number) { + this.calls.push(`addCartLine(${variantId}, ${quantity})`); + + if (this.addCartLineError) { + throw this.addCartLineError; + } + } + + async showCart() { + this.calls.push('showCart'); + } + + async report(failure: string) { + this.calls.push(`report(${failure})`); + } +} + +function handle(path: string, target: E2ECommandTargetSpy) { + return new E2EController(target).handle( + `com.shopify.checkoutkit.reactnativedemo://e2e${path}`, + ); +} + +describe('E2EController', () => { + it('ignores links that are not control links', async () => { + const target = new E2ECommandTargetSpy(); + + const handled = await new E2EController(target).handle( + 'https://example.com/cart', + ); + + expect(handled).toBe(false); + expect(target.calls).toEqual([]); + }); + + it('reports a parse failure', async () => { + const target = new E2ECommandTargetSpy(); + + const handled = await handle('/teleport', target); + + expect(handled).toBe(true); + expect(target.calls).toEqual(['report(Unsupported e2e command)']); + }); + + it('resets the cart', async () => { + const target = new E2ECommandTargetSpy(); + + const handled = await handle('/reset', target); + + expect(handled).toBe(true); + expect(target.calls).toEqual(['resetCart']); + }); + + it('seeds the cart from a variant id', async () => { + const target = new E2ECommandTargetSpy(); + + await handle( + '/cart?variantId=gid://shopify/ProductVariant/1&quantity=3&buyerIdentityMode=hardcoded', + target, + ); + + expect(target.calls).toEqual([ + 'selectBuyerIdentityMode(hardcoded)', + 'resetCart', + 'addCartLine(gid://shopify/ProductVariant/1, 3)', + 'showCart', + ]); + }); + + it('seeds the cart from a product index', async () => { + const target = new E2ECommandTargetSpy(); + + await handle('/cart?productIndex=2', target); + + expect(target.calls).toEqual([ + 'resetCart', + 'variantId(atProductIndex: 2)', + 'addCartLine(variant-2, 1)', + 'showCart', + ]); + }); + + it('selects the buyer identity mode before seeding because selecting it resets the cart', async () => { + const target = new E2ECommandTargetSpy(); + + await handle('/cart?productIndex=0&buyerIdentityMode=guest', target); + + expect(target.calls[0]).toBe('selectBuyerIdentityMode(guest)'); + }); + + it('reports a seed failure and does not show the cart', async () => { + const target = new E2ECommandTargetSpy(); + target.variantIdError = new Error('No product at index 9'); + + await handle('/cart?productIndex=9', target); + + expect(target.calls).toEqual([ + 'resetCart', + 'variantId(atProductIndex: 9)', + 'report(No product at index 9)', + ]); + }); + + it('presents sign in', async () => { + const target = new E2ECommandTargetSpy(); + + await handle('/signIn', target); + + expect(target.calls).toEqual(['presentSignIn']); + }); +}); diff --git a/platforms/react-native/sample/src/e2e/cartBootstrap.ts b/platforms/react-native/sample/src/e2e/cartBootstrap.ts deleted file mode 100644 index 2148770c8..000000000 --- a/platforms/react-native/sample/src/e2e/cartBootstrap.ts +++ /dev/null @@ -1,103 +0,0 @@ -import {BuyerIdentityMode} from '../auth/types'; - -export const CART_BOOTSTRAP_SCHEME = 'com.shopify.checkoutkit.reactnativedemo:'; -const CART_BOOTSTRAP_HOST = 'cart'; -export const CART_BOOTSTRAP_ROUTE = `${CART_BOOTSTRAP_SCHEME}//cart`; -const CART_BOOTSTRAP_PARSE_ORIGIN = `https://${CART_BOOTSTRAP_HOST}`; -const CART_BOOTSTRAP_ROOT_PATH = '/'; - -export type CartBootstrapLink = { - variantId?: string; - productIndex?: number; - quantity: number; - buyerIdentityMode?: BuyerIdentityMode; -}; - -function isBuyerIdentityMode(value: string): value is BuyerIdentityMode { - return Object.values(BuyerIdentityMode).includes(value as BuyerIdentityMode); -} - -export function parseCartBootstrapLink(url: string): CartBootstrapLink | null { - if (!url.startsWith(CART_BOOTSTRAP_SCHEME)) { - return null; - } - - if (!url.startsWith(CART_BOOTSTRAP_ROUTE)) { - throw new Error('Unsupported cart bootstrap path'); - } - - const routeSuffix = url.slice(CART_BOOTSTRAP_ROUTE.length); - - if ( - routeSuffix && - !routeSuffix.startsWith('?') && - !routeSuffix.startsWith('/') - ) { - throw new Error('Unsupported cart bootstrap path'); - } - - let parsedUrl: URL; - try { - // React Native's URL host/path parsing only works for http(s) URLs. - parsedUrl = new URL(`${CART_BOOTSTRAP_PARSE_ORIGIN}${routeSuffix}`); - } catch { - throw new Error('Unsupported cart bootstrap path'); - } - - if ( - parsedUrl.hostname !== CART_BOOTSTRAP_HOST || - parsedUrl.pathname !== CART_BOOTSTRAP_ROOT_PATH - ) { - throw new Error('Unsupported cart bootstrap path'); - } - - if (!parsedUrl.search) { - throw new Error('Missing variantId or productIndex'); - } - - const searchParams = parsedUrl.searchParams; - const variantId = searchParams.get('variantId')?.trim(); - const productIndexParam = searchParams.get('productIndex')?.trim(); - const buyerIdentityModeParam = searchParams.get('buyerIdentityMode')?.trim(); - let buyerIdentityMode: BuyerIdentityMode | undefined; - - const quantityParam = searchParams.get('quantity') ?? '1'; - const quantity = Number(quantityParam); - - if (!Number.isInteger(quantity) || quantity < 1) { - throw new Error('quantity must be a positive integer'); - } - - if (searchParams.has('buyerIdentityMode')) { - if ( - !buyerIdentityModeParam || - !isBuyerIdentityMode(buyerIdentityModeParam) - ) { - throw new Error( - 'buyerIdentityMode must be guest, hardcoded, or customerAccount', - ); - } - - buyerIdentityMode = buyerIdentityModeParam; - } - - if (variantId && productIndexParam) { - throw new Error('Use variantId or productIndex, not both'); - } - - if (variantId) { - return {variantId, quantity, buyerIdentityMode}; - } - - if (!productIndexParam) { - throw new Error('Missing variantId or productIndex'); - } - - const productIndex = Number(productIndexParam); - - if (!Number.isInteger(productIndex) || productIndex < 0) { - throw new Error('productIndex must be a non-negative integer'); - } - - return {productIndex, quantity, buyerIdentityMode}; -} diff --git a/platforms/react-native/sample/src/e2e/controlLink.ts b/platforms/react-native/sample/src/e2e/controlLink.ts new file mode 100644 index 000000000..a881cc80a --- /dev/null +++ b/platforms/react-native/sample/src/e2e/controlLink.ts @@ -0,0 +1,162 @@ +import {BuyerIdentityMode} from '../auth/types'; + +const CONTROL_LINK_HOST = 'e2e'; +const SCHEME_SEPARATOR = '://'; +const PARSE_ORIGIN_SCHEME = 'https://'; + +export type E2EResetCommand = { + command: 'reset'; +}; + +export type E2ECartCommand = { + command: 'cart'; + variantId?: string; + productIndex?: number; + quantity: number; + buyerIdentityMode?: BuyerIdentityMode; +}; + +export type E2ESignInCommand = { + command: 'signIn'; +}; + +export type E2EControlLink = + | E2EResetCommand + | E2ECartCommand + | E2ESignInCommand; + +type Parameters = Map; + +function isBuyerIdentityMode(value: string): value is BuyerIdentityMode { + return Object.values(BuyerIdentityMode).includes(value as BuyerIdentityMode); +} + +function parameterMap(searchParams: URLSearchParams): Parameters { + const parameters: Parameters = new Map(); + + searchParams.forEach((value, name) => { + parameters.set(name, value.trim()); + }); + + return parameters; +} + +function parseQuantity(parameters: Parameters): number { + const parameter = parameters.get('quantity'); + + if (parameter === undefined) { + return 1; + } + + const quantity = Number(parameter); + + if (parameter === '' || !Number.isInteger(quantity) || quantity < 1) { + throw new Error('quantity must be a positive integer'); + } + + return quantity; +} + +function parseBuyerIdentityMode( + parameters: Parameters, +): BuyerIdentityMode | undefined { + const parameter = parameters.get('buyerIdentityMode'); + + if (parameter === undefined) { + return undefined; + } + + if (!isBuyerIdentityMode(parameter)) { + throw new Error( + 'buyerIdentityMode must be guest, hardcoded, or customerAccount', + ); + } + + return parameter; +} + +function parseCart(parameters: Parameters): E2ECartCommand { + if (parameters.size === 0) { + throw new Error('Missing variantId or productIndex'); + } + + const quantity = parseQuantity(parameters); + const buyerIdentityMode = parseBuyerIdentityMode(parameters); + const variantId = parameters.get('variantId'); + const productIndexParameter = parameters.get('productIndex'); + + if (variantId !== undefined && productIndexParameter !== undefined) { + throw new Error('Use variantId or productIndex, not both'); + } + + if (variantId !== undefined) { + if (variantId === '') { + throw new Error('variantId must not be blank'); + } + + return {command: 'cart', variantId, quantity, buyerIdentityMode}; + } + + if (productIndexParameter === undefined) { + throw new Error('Missing variantId or productIndex'); + } + + const productIndex = Number(productIndexParameter); + + if ( + productIndexParameter === '' || + !Number.isInteger(productIndex) || + productIndex < 0 + ) { + throw new Error('productIndex must be a non-negative integer'); + } + + return {command: 'cart', productIndex, quantity, buyerIdentityMode}; +} + +function parseSignIn(parameters: Parameters): E2ESignInCommand { + if (parameters.size > 0) { + throw new Error('signIn takes no parameters'); + } + + return {command: 'signIn'}; +} + +export function parseControlLink(url: string): E2EControlLink | null { + const separatorIndex = url.indexOf(SCHEME_SEPARATOR); + + if (separatorIndex < 0) { + return null; + } + + const authorityAndPath = url.slice(separatorIndex + SCHEME_SEPARATOR.length); + let parsedUrl: URL; + + try { + // React Native's URL host and path parsing only works for http(s) URLs. + parsedUrl = new URL(`${PARSE_ORIGIN_SCHEME}${authorityAndPath}`); + } catch { + return null; + } + + if (parsedUrl.hostname !== CONTROL_LINK_HOST) { + return null; + } + + const parameters = parameterMap(parsedUrl.searchParams); + + switch (parsedUrl.pathname.replace(/^\/+|\/+$/g, '')) { + case 'reset': + if (parameters.size > 0) { + throw new Error('reset takes no parameters'); + } + + return {command: 'reset'}; + case 'cart': + return parseCart(parameters); + case 'signIn': + return parseSignIn(parameters); + default: + throw new Error('Unsupported e2e command'); + } +} diff --git a/platforms/react-native/sample/src/e2e/controller.ts b/platforms/react-native/sample/src/e2e/controller.ts new file mode 100644 index 000000000..97d53c33d --- /dev/null +++ b/platforms/react-native/sample/src/e2e/controller.ts @@ -0,0 +1,76 @@ +import type {BuyerIdentityMode} from '../auth/types'; +import { + parseControlLink, + type E2ECartCommand, + type E2EControlLink, +} from './controlLink'; + +export interface E2ECommandTarget { + selectBuyerIdentityMode(mode: BuyerIdentityMode): Promise; + resetCart(): Promise; + variantId(productIndex: number): Promise; + addCartLine(variantId: string, quantity: number): Promise; + showCart(): Promise; + presentSignIn(): Promise; + report(failure: string): Promise; +} + +function message(error: unknown) { + return error instanceof Error ? error.message : 'Unknown error'; +} + +export class E2EController { + constructor(private readonly target: E2ECommandTarget) {} + + async handle(url: string): Promise { + let link: E2EControlLink | null; + + try { + link = parseControlLink(url); + } catch (error) { + await this.target.report(message(error)); + return true; + } + + if (!link) { + return false; + } + + await this.perform(link); + + return true; + } + + private async perform(link: E2EControlLink) { + try { + switch (link.command) { + case 'reset': + await this.target.resetCart(); + break; + case 'cart': + await this.seedCart(link); + break; + case 'signIn': + await this.target.presentSignIn(); + break; + } + } catch (error) { + await this.target.report(message(error)); + } + } + + private async seedCart(command: E2ECartCommand) { + if (command.buyerIdentityMode) { + await this.target.selectBuyerIdentityMode(command.buyerIdentityMode); + } + + await this.target.resetCart(); + + const variantId = + command.variantId ?? + (await this.target.variantId(command.productIndex ?? 0)); + + await this.target.addCartLine(variantId, command.quantity); + await this.target.showCart(); + } +} diff --git a/platforms/react-native/sample/src/e2e/useE2ECartBootstrap.ts b/platforms/react-native/sample/src/e2e/useE2ECartBootstrap.ts index e75eecd8c..f43103101 100644 --- a/platforms/react-native/sample/src/e2e/useE2ECartBootstrap.ts +++ b/platforms/react-native/sample/src/e2e/useE2ECartBootstrap.ts @@ -1,64 +1,60 @@ -import {useCallback} from 'react'; +import {useCallback, useMemo} from 'react'; import {Alert} from 'react-native'; +import type {BuyerIdentityMode} from '../auth/types'; import {useCart} from '../context/Cart'; import useShopify from '../hooks/useShopify'; -import {parseCartBootstrapLink, type CartBootstrapLink} from './cartBootstrap'; +import {E2EController, type E2ECommandTarget} from './controller'; type UseE2ECartBootstrapOptions = { onCartReady: () => void; + onSignInRequested: () => void; }; -function errorMessage(error: unknown) { - return error instanceof Error ? error.message : 'Unknown error'; -} - -export function useE2ECartBootstrap({onCartReady}: UseE2ECartBootstrapOptions) { - const {seedCart} = useCart(); +export function useE2ECartBootstrap({ + onCartReady, + onSignInRequested, +}: UseE2ECartBootstrapOptions) { + const {seedCart, clearCart} = useCart(); const {queries} = useShopify(); const [fetchProducts] = queries.products; - return useCallback( - async (url: string) => { - let cartBootstrapLink: CartBootstrapLink | null = null; - - try { - cartBootstrapLink = parseCartBootstrapLink(url); - } catch (error) { - Alert.alert('Invalid cart bootstrap link', errorMessage(error)); - return true; - } - - if (!cartBootstrapLink) { - return false; - } - - try { - let {variantId} = cartBootstrapLink; + const target = useMemo(() => { + let selectedBuyerIdentityMode: BuyerIdentityMode | undefined; + + return { + async selectBuyerIdentityMode(mode) { + selectedBuyerIdentityMode = mode; + }, + async resetCart() { + clearCart(); + }, + async variantId(productIndex) { + const {data} = await fetchProducts(); + const product = data?.products.edges[productIndex]?.node; + const variantId = product?.variants.edges[0]?.node.id; if (!variantId) { - const {data} = await fetchProducts(); - const product = - data?.products.edges[cartBootstrapLink.productIndex ?? 0]?.node; - - variantId = product?.variants.edges[0]?.node.id; + throw new Error(`No product at index ${productIndex}`); } - if (!variantId) { - throw new Error('Cart bootstrap product variant was not found'); - } - - await seedCart( - variantId, - cartBootstrapLink.quantity, - cartBootstrapLink.buyerIdentityMode, - ); + return variantId; + }, + async addCartLine(variantId, quantity) { + await seedCart(variantId, quantity, selectedBuyerIdentityMode); + }, + async showCart() { onCartReady(); - } catch (error) { - Alert.alert('Cart bootstrap failed', errorMessage(error)); - } - - return true; - }, - [fetchProducts, onCartReady, seedCart], - ); + }, + async presentSignIn() { + onSignInRequested(); + }, + async report(failure) { + Alert.alert('E2E command failed', failure); + }, + }; + }, [clearCart, fetchProducts, onCartReady, onSignInRequested, seedCart]); + + return useCallback((url: string) => new E2EController(target).handle(url), [ + target, + ]); } diff --git a/platforms/react-native/sample/src/screens/LoginScreen.tsx b/platforms/react-native/sample/src/screens/LoginScreen.tsx index f11be9a96..834c02a84 100644 --- a/platforms/react-native/sample/src/screens/LoginScreen.tsx +++ b/platforms/react-native/sample/src/screens/LoginScreen.tsx @@ -1,5 +1,6 @@ import React, {useCallback, useMemo, useState} from 'react'; import {ActivityIndicator, StyleSheet, View} from 'react-native'; +import Config from 'react-native-config'; import {WebView} from 'react-native-webview'; import type {ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes'; import type {NativeStackScreenProps} from '@react-navigation/native-stack'; @@ -80,6 +81,7 @@ function LoginScreen({navigation}: Props) { source={{uri: authorizationURL}} onShouldStartLoadWithRequest={handleNavigationRequest} originWhitelist={['https://*', `${callbackScheme}://*`]} + applicationNameForUserAgent={Config.CUSTOM_USER_AGENT || undefined} incognito={true} style={styles.webview} /> diff --git a/platforms/react-native/scripts/e2e_maestro_android b/platforms/react-native/scripts/e2e_maestro_android index 3d7325086..092cba4a3 100755 --- a/platforms/react-native/scripts/e2e_maestro_android +++ b/platforms/react-native/scripts/e2e_maestro_android @@ -9,43 +9,49 @@ METRO_LOG="${TMPDIR:-/tmp}/checkout-kit-rn-android-metro.log" METRO_PID="" E2E_ENV_FILE="" APP_ID="com.shopify.checkoutkit.reactnativedemo" -CART_BOOTSTRAP_BASE_LINK="${APP_ID}://cart?productIndex=0&quantity=1" -MAESTRO_FLOWS=() +READY_MARKER="checkout-kit-sample-ready" +INCLUDE_TAGS="" +# e2e/config.yaml quarantines flaky and wip for every consumer, so this stays empty +# unless the caller excludes something else. +EXCLUDE_TAGS="" usage() { cat <&2 -Usage: dev rn e2e android [--guest] [--hardcoded-buyer-identity] +Usage: dev rn e2e android [--tags TAG[,TAG...]] [--exclude-tags TAG[,TAG...]] -Runs all React Native Android Maestro checkout smoke flows against the released native SDK artifacts. -Pass one or more focused scenario flags to run only those flows. +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: - --guest Run only the guest checkout smoke flow. - --hardcoded-buyer-identity Run only the hardcoded buyer identity smoke flow. + --tags TAG[,TAG...] Run only tests carrying any of these tags. + --exclude-tags TAG[,TAG...] Skip tests carrying any of these tags. EOF } -add_maestro_flow() { - local flow="$1" - - if [ "${#MAESTRO_FLOWS[@]}" -gt 0 ]; then - for existing_flow in "${MAESTRO_FLOWS[@]}"; do - if [ "$existing_flow" = "$flow" ]; then - return +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 - done - fi - - MAESTRO_FLOWS+=("$flow") -} - -for arg in "$@"; do - case "$arg" in - --guest) - add_maestro_flow "tests/react-native/checkout-guest.yaml" + shift 2 + ;; + --tags=*) + INCLUDE_TAGS="${1#--tags=}" + shift ;; - --hardcoded-buyer-identity) - add_maestro_flow "tests/react-native/checkout-hardcoded-buyer-identity.yaml" + --exclude-tags) + EXCLUDE_TAGS="${2:-}" + shift 2 + ;; + --exclude-tags=*) + EXCLUDE_TAGS="${1#--exclude-tags=}" + shift ;; -h|--help) usage @@ -53,19 +59,12 @@ for arg in "$@"; do ;; *) usage - echo "Unknown option: $arg" >&2 + echo "Unknown option: $1" >&2 exit 1 ;; esac done -if [ "${#MAESTRO_FLOWS[@]}" -eq 0 ]; then - MAESTRO_FLOWS=( - "tests/react-native/checkout-guest.yaml" - "tests/react-native/checkout-hardcoded-buyer-identity.yaml" - ) -fi - metro_running() { curl --silent --fail http://localhost:8081/status | grep -q "packager-status:running" } @@ -104,17 +103,32 @@ wait_for_metro() { 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")" +# Fail before the sample build rather than after it, because that build takes minutes. +# e2e/scripts/run_maestro resolves the same pinned binary when it runs the suite. 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. +require_maestro() { + "$REPO_ROOT/e2e/scripts/maestro_bin" >/dev/null +} + +attached_device() { + adb devices | awk '$2 == "device" { print $1; exit }' } cd "$ROOT_DIR" -resolve_maestro +require_maestro trap cleanup EXIT +DEVICE_ID="$(attached_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 + exit 1 +fi + +echo "Using device: $DEVICE_ID" + if ! metro_running; then /opt/dev/bin/dev react-native start >"$METRO_LOG" 2>&1 & METRO_PID="$!" @@ -122,14 +136,7 @@ fi wait_for_metro prepare_e2e_env -ENVFILE="$E2E_ENV_FILE" pnpm sample android --extra-params "--refresh-dependencies" - -( - cd "$REPO_ROOT/e2e" - for flow in "${MAESTRO_FLOWS[@]}"; do - "$MAESTRO" --platform android test --config config.yaml \ - -e "APP_ID=${APP_ID}" \ - -e "CART_BOOTSTRAP_BASE_LINK=${CART_BOOTSTRAP_BASE_LINK}" \ - "$flow" - done -) +ENVFILE="$E2E_ENV_FILE" pnpm sample android --device "$DEVICE_ID" --extra-params "--refresh-dependencies" + +E2E_DEVICE_ID="$DEVICE_ID" \ + "$REPO_ROOT/e2e/scripts/run_maestro" android "$APP_ID" "$READY_MARKER" "$INCLUDE_TAGS" "$EXCLUDE_TAGS" diff --git a/platforms/react-native/scripts/e2e_maestro_ios b/platforms/react-native/scripts/e2e_maestro_ios index c717f94fb..f74d3b282 100755 --- a/platforms/react-native/scripts/e2e_maestro_ios +++ b/platforms/react-native/scripts/e2e_maestro_ios @@ -9,43 +9,49 @@ METRO_LOG="${TMPDIR:-/tmp}/checkout-kit-rn-ios-metro.log" METRO_PID="" E2E_ENV_FILE="" APP_ID="com.shopify.checkoutkit.reactnativedemo" -CART_BOOTSTRAP_BASE_LINK="${APP_ID}://cart?productIndex=0&quantity=1" -MAESTRO_FLOWS=() +READY_MARKER="checkout-kit-sample-ready" +INCLUDE_TAGS="" +# e2e/config.yaml quarantines flaky and wip for every consumer, so this stays empty +# unless the caller excludes something else. +EXCLUDE_TAGS="" usage() { cat <&2 -Usage: dev rn e2e ios [--guest] [--hardcoded-buyer-identity] +Usage: dev rn e2e ios [--tags TAG[,TAG...]] [--exclude-tags TAG[,TAG...]] -Runs all React Native iOS Maestro checkout smoke flows against the released native SDK artifacts. -Pass one or more focused scenario flags to run only those flows. +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: - --guest Run only the guest checkout smoke flow. - --hardcoded-buyer-identity Run only the hardcoded buyer identity smoke flow. + --tags TAG[,TAG...] Run only tests carrying any of these tags. + --exclude-tags TAG[,TAG...] Skip tests carrying any of these tags. EOF } -add_maestro_flow() { - local flow="$1" - - if [ "${#MAESTRO_FLOWS[@]}" -gt 0 ]; then - for existing_flow in "${MAESTRO_FLOWS[@]}"; do - if [ "$existing_flow" = "$flow" ]; then - return +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 - done - fi - - MAESTRO_FLOWS+=("$flow") -} - -for arg in "$@"; do - case "$arg" in - --guest) - add_maestro_flow "tests/react-native/checkout-guest.yaml" + shift 2 + ;; + --tags=*) + INCLUDE_TAGS="${1#--tags=}" + shift ;; - --hardcoded-buyer-identity) - add_maestro_flow "tests/react-native/checkout-hardcoded-buyer-identity.yaml" + --exclude-tags) + EXCLUDE_TAGS="${2:-}" + shift 2 + ;; + --exclude-tags=*) + EXCLUDE_TAGS="${1#--exclude-tags=}" + shift ;; -h|--help) usage @@ -53,19 +59,12 @@ for arg in "$@"; do ;; *) usage - echo "Unknown option: $arg" >&2 + echo "Unknown option: $1" >&2 exit 1 ;; esac done -if [ "${#MAESTRO_FLOWS[@]}" -eq 0 ]; then - MAESTRO_FLOWS=( - "tests/react-native/checkout-guest.yaml" - "tests/react-native/checkout-hardcoded-buyer-identity.yaml" - ) -fi - metro_running() { curl --silent --fail http://localhost:8081/status | grep -q "packager-status:running" } @@ -104,18 +103,36 @@ wait_for_metro() { 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")" +# Fail before the sample build rather than after it, because that build takes minutes. +# e2e/scripts/run_maestro resolves the same pinned binary when it runs the suite. 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. +require_maestro() { + "$REPO_ROOT/e2e/scripts/maestro_bin" >/dev/null +} + +# Selects by runtime header rather than by device name, because a simulator may carry +# any name. simctl groups booted devices under "-- iOS --" headers, so this +# takes the first device under an iOS header and ignores watchOS and tvOS. +booted_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 }' } cd "$ROOT_DIR" -resolve_maestro +require_maestro trap cleanup EXIT +SIMULATOR_ID="$(booted_simulator || true)" +if [ -z "$SIMULATOR_ID" ]; then + echo "No booted iOS simulator found." >&2 + echo "Boot a simulator first, because Maestro drives the device the app runs on." >&2 + exit 1 +fi + +echo "Using booted simulator: $SIMULATOR_ID" + if ! metro_running; then /opt/dev/bin/dev react-native start >"$METRO_LOG" 2>&1 & METRO_PID="$!" @@ -123,14 +140,7 @@ fi wait_for_metro prepare_e2e_env -ENVFILE="$E2E_ENV_FILE" pnpm sample ios - -( - cd "$REPO_ROOT/e2e" - for flow in "${MAESTRO_FLOWS[@]}"; do - "$MAESTRO" --platform ios test --config config.yaml \ - -e "APP_ID=${APP_ID}" \ - -e "CART_BOOTSTRAP_BASE_LINK=${CART_BOOTSTRAP_BASE_LINK}" \ - "$flow" - done -) +ENVFILE="$E2E_ENV_FILE" pnpm sample ios --udid "$SIMULATOR_ID" + +E2E_DEVICE_ID="$SIMULATOR_ID" \ + "$REPO_ROOT/e2e/scripts/run_maestro" ios "$APP_ID" "$READY_MARKER" "$INCLUDE_TAGS" "$EXCLUDE_TAGS" diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/SceneDelegate.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/SceneDelegate.swift index 329b44c0d..1ef8fde2c 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/SceneDelegate.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/SceneDelegate.swift @@ -101,6 +101,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // Cart (UI Kit) swiftUICartController.tabBarItem.image = UIImage(systemName: "cart") swiftUICartController.tabBarItem.title = "Cart" + swiftUICartController.tabBarItem.accessibilityIdentifier = E2ETestIds.Tabs.cart swiftUICartController.navigationItem.title = "Cart (SwiftUI)" // Account @@ -242,6 +243,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { if CustomerAccountManager.shared.handleCallback(url: url) { return } + + Task { await E2EController.shared.handle(url: url.absoluteString) } } func scene(_: UIScene, continue userActivity: NSUserActivity) { diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EControlLink.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EControlLink.swift new file mode 100644 index 000000000..e7d8dfbff --- /dev/null +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EControlLink.swift @@ -0,0 +1,160 @@ +import Foundation + +enum E2EControlLinkError: LocalizedError, Equatable, Hashable { + case unsupportedCommand + case unexpectedParameters(command: String) + case missingProductSelector + case ambiguousProductSelector + case blankVariantId + case invalidQuantity + case invalidProductIndex + case invalidBuyerIdentityMode + + var errorDescription: String? { + switch self { + case .unsupportedCommand: + return "Unsupported e2e command" + case let .unexpectedParameters(command): + return "\(command) takes no parameters" + case .missingProductSelector: + return "Missing variantId or productIndex" + case .ambiguousProductSelector: + return "Use variantId or productIndex, not both" + case .blankVariantId: + return "variantId must not be blank" + case .invalidQuantity: + return "quantity must be a positive integer" + case .invalidProductIndex: + return "productIndex must be a non-negative integer" + case .invalidBuyerIdentityMode: + return "buyerIdentityMode must be guest, hardcoded, or customerAccount" + } + } +} + +enum E2EControlLink: Equatable { + case reset + case cart(CartCommand) + case signIn + + struct CartCommand: Equatable { + var variantId: String? + var productIndex: Int? + var quantity: Int = 1 + var buyerIdentityMode: BuyerIdentityMode? + } + + static let host = "e2e" + + private static let schemeSeparator = "://" + private static let parseOriginScheme = "https://" + + static func parse(_ url: String) throws -> E2EControlLink? { + guard let separator = url.range(of: schemeSeparator) else { + return nil + } + + let authorityAndPath = String(url[separator.upperBound...]) + + guard let components = URLComponents(string: parseOriginScheme + authorityAndPath), + components.host == host + else { + return nil + } + + let parameters = Parameters(components.queryItems) + + switch components.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) { + case "reset": + guard parameters.isEmpty else { + throw E2EControlLinkError.unexpectedParameters(command: "reset") + } + return .reset + case "cart": + return try .cart(cartCommand(from: parameters)) + case "signIn": + guard parameters.isEmpty else { + throw E2EControlLinkError.unexpectedParameters(command: "signIn") + } + return .signIn + default: + throw E2EControlLinkError.unsupportedCommand + } + } + + private static func cartCommand(from parameters: Parameters) throws -> CartCommand { + guard !parameters.isEmpty else { + throw E2EControlLinkError.missingProductSelector + } + + let quantity = try quantity(from: parameters) + let buyerIdentityMode = try buyerIdentityMode(from: parameters) + + if parameters.contains("variantId"), parameters.contains("productIndex") { + throw E2EControlLinkError.ambiguousProductSelector + } + + if let variantId = parameters.value("variantId") { + guard !variantId.isEmpty else { + throw E2EControlLinkError.blankVariantId + } + return CartCommand(variantId: variantId, quantity: quantity, buyerIdentityMode: buyerIdentityMode) + } + + guard let productIndexParameter = parameters.value("productIndex") else { + throw E2EControlLinkError.missingProductSelector + } + + guard let productIndex = Int(productIndexParameter), productIndex >= 0 else { + throw E2EControlLinkError.invalidProductIndex + } + + return CartCommand(productIndex: productIndex, quantity: quantity, buyerIdentityMode: buyerIdentityMode) + } + + private static func quantity(from parameters: Parameters) throws -> Int { + guard let parameter = parameters.value("quantity") else { + return 1 + } + + guard let quantity = Int(parameter), quantity >= 1 else { + throw E2EControlLinkError.invalidQuantity + } + + return quantity + } + + private static func buyerIdentityMode(from parameters: Parameters) throws -> BuyerIdentityMode? { + guard let parameter = parameters.value("buyerIdentityMode") else { + return nil + } + + guard let buyerIdentityMode = BuyerIdentityMode(rawValue: parameter) else { + throw E2EControlLinkError.invalidBuyerIdentityMode + } + + return buyerIdentityMode + } + + private struct Parameters { + private let values: [String: String] + + init(_ queryItems: [URLQueryItem]?) { + values = (queryItems ?? []).reduce(into: [:]) { result, item in + result[item.name] = (item.value ?? "").trimmingCharacters(in: .whitespaces) + } + } + + var isEmpty: Bool { + values.isEmpty + } + + func contains(_ name: String) -> Bool { + values[name] != nil + } + + func value(_ name: String) -> String? { + values[name] + } + } +} diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EController.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EController.swift new file mode 100644 index 000000000..1b7920865 --- /dev/null +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EController.swift @@ -0,0 +1,94 @@ +import Foundation + +@MainActor +protocol E2ECommandTarget { + func selectBuyerIdentityMode(_ mode: BuyerIdentityMode) async + func resetCart() async + func variantId(atProductIndex index: Int) async throws -> String + func addCartLine(variantId: String, quantity: Int) async throws + func showCart() async + func presentSignIn() async + func report(failure message: String) async +} + +enum E2EControllerError: LocalizedError, Equatable { + case productIndexOutOfRange(Int) + + var errorDescription: String? { + switch self { + case let .productIndexOutOfRange(index): + return "No product at index \(index)" + } + } +} + +@MainActor +final class E2EController { + static let shared = E2EController(target: E2ESampleAppTarget()) + + private let target: E2ECommandTarget + + init(target: E2ECommandTarget) { + self.target = target + } + + @discardableResult + func handle(url: String) async -> Bool { + let link: E2EControlLink? + + do { + link = try E2EControlLink.parse(url) + } catch { + await target.report(failure: E2EController.message(for: error)) + return true + } + + guard let link else { + return false + } + + await perform(link) + + return true + } + + private func perform(_ link: E2EControlLink) async { + do { + switch link { + case .reset: + await target.resetCart() + case let .cart(command): + try await seedCart(command) + case .signIn: + await target.presentSignIn() + } + } catch { + await target.report(failure: E2EController.message(for: error)) + } + } + + private func seedCart(_ command: E2EControlLink.CartCommand) async throws { + if let buyerIdentityMode = command.buyerIdentityMode { + await target.selectBuyerIdentityMode(buyerIdentityMode) + } + + await target.resetCart() + + let variantId = try await variantId(for: command) + + try await target.addCartLine(variantId: variantId, quantity: command.quantity) + await target.showCart() + } + + private func variantId(for command: E2EControlLink.CartCommand) async throws -> String { + if let variantId = command.variantId { + return variantId + } + + return try await target.variantId(atProductIndex: command.productIndex ?? 0) + } + + private static func message(for error: Error) -> String { + (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + } +} diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ESampleAppTarget.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ESampleAppTarget.swift new file mode 100644 index 000000000..3d0394c4b --- /dev/null +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ESampleAppTarget.swift @@ -0,0 +1,65 @@ +import ApolloAPI +import Foundation +import UIKit + +@MainActor +struct E2ESampleAppTarget: E2ECommandTarget { + func selectBuyerIdentityMode(_ mode: BuyerIdentityMode) async { + appConfiguration.buyerIdentityMode = mode + } + + func resetCart() async { + CartManager.shared.resetCart() + } + + func variantId(atProductIndex index: Int) async throws -> String { + let network = Network.shared + + let query = Storefront.GetProductsQuery( + first: .some(Int32(index + 1)), + country: network.countryCode, + language: network.languageCode + ) + + let response = try await network.apollo.fetch(query: query) + let products = response.data?.products.nodes ?? [] + + guard index < products.count, let variantId = products[index].variants.nodes.first?.id else { + throw E2EControllerError.productIndexOutOfRange(index) + } + + return variantId + } + + func addCartLine(variantId: String, quantity: Int) async throws { + let cart = try await CartManager.shared.performCartLinesAdd(variant: variantId) + + guard quantity > 1, let lineId = cart.lines.nodes.first?.id else { + return + } + + _ = try await CartManager.shared.performCartLinesUpdate(id: lineId, quantity: quantity) + } + + func showCart() async { + let sceneDelegate = UIApplication.shared.connectedScenes + .compactMap { $0.delegate as? SceneDelegate } + .first + + sceneDelegate?.navigateTo(.cart) + } + + func presentSignIn() async { + E2ESignInRequest.shared.request() + + let sceneDelegate = UIApplication.shared.connectedScenes + .compactMap { $0.delegate as? SceneDelegate } + .first + + sceneDelegate?.navigateTo(.account) + } + + func report(failure message: String) async { + print("[E2E] \(message)") + } +} diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ESignInRequest.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ESignInRequest.swift new file mode 100644 index 000000000..e0f233139 --- /dev/null +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ESignInRequest.swift @@ -0,0 +1,16 @@ +import Foundation + +@MainActor +final class E2ESignInRequest: ObservableObject { + static let shared = E2ESignInRequest() + + @Published private(set) var isPending = false + + func request() { + isPending = true + } + + func fulfil() { + isPending = false + } +} diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ETestIds.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ETestIds.swift index 0835e4d03..7343ea251 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ETestIds.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ETestIds.swift @@ -1,3 +1,17 @@ enum E2ETestIds { static let appReady = "checkout-kit-sample-ready" + + enum Cart { + static let checkoutReady = "cart-checkout-ready" + static let checkoutButton = "checkout-button" + static let emptyMessage = "cart-empty-message" + } + + enum Tabs { + static let cart = "cart-tab" + } + + enum Account { + static let signedInView = "account-signed-in-view" + } } diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Lib/InfoDictionary.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Lib/InfoDictionary.swift index 23b542334..0a6d06c4a 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Lib/InfoDictionary.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Lib/InfoDictionary.swift @@ -13,6 +13,9 @@ final class InfoDictionary: Sendable { let customerAccountApiClientId: String? let customerAccountApiShopId: String? + /// User agent suffix the customer account login web view appends. Empty outside CI. + let customUserAgent: String? + var customerAccountApiRedirectUri: String? { guard let shopId = customerAccountApiShopId, !shopId.isEmpty else { return nil @@ -64,5 +67,6 @@ final class InfoDictionary: Sendable { // Customer Account API configuration (optional) customerAccountApiClientId = infoPlist["CustomerAccountApiClientId"] as? String customerAccountApiShopId = infoPlist["CustomerAccountApiShopId"] as? String + customUserAgent = (infoPlist["CustomUserAgent"] as? String).flatMap { $0.isEmpty ? nil : $0 } } } diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Lib/KeychainHelper.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Lib/KeychainHelper.swift index f47adfb94..6aa393cb6 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Lib/KeychainHelper.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Lib/KeychainHelper.swift @@ -51,6 +51,9 @@ final class KeychainHelper { ] let status = SecItemAdd(query as CFDictionary, nil) + if status != errSecSuccess { + logger.error("Failed to save \(key) to keychain, OSStatus: \(status)") + } return status == errSecSuccess } @@ -94,9 +97,15 @@ final class KeychainHelper { func getTokens() -> OAuthTokenResult? { guard let data = read(key: tokensKey) else { + logger.debug("No token data found in keychain") + return nil + } + do { + return try JSONDecoder().decode(OAuthTokenResult.self, from: data) + } catch { + logger.error("Failed to decode keychain tokens: \(error)") return nil } - return try? JSONDecoder().decode(OAuthTokenResult.self, from: data) } func clearTokens() { diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/AccountView.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/AccountView.swift index fe555e2a2..0b07e0081 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/AccountView.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/AccountView.swift @@ -2,6 +2,7 @@ import SwiftUI struct AccountView: View { @ObservedObject var accountManager = CustomerAccountManager.shared + @ObservedObject private var e2eSignInRequest = E2ESignInRequest.shared @State private var showingLogin = false var body: some View { @@ -18,6 +19,12 @@ struct AccountView: View { .sheet(isPresented: $showingLogin) { LoginSheetView() } + .onReceive(e2eSignInRequest.$isPending) { isPending in + guard isPending else { return } + + showingLogin = true + e2eSignInRequest.fulfil() + } } } @@ -65,6 +72,7 @@ struct AuthenticatedAccountView: View { .padding(.bottom, 32) } .background(Color(.systemGroupedBackground)) + .accessibilityIdentifier(E2ETestIds.Account.signedInView) } } diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartView.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartView.swift index b65b48c0a..cad515071 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartView.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartView.swift @@ -73,6 +73,7 @@ struct CartView: View { HStack { Text("Check out") .fontWeight(.bold) + .accessibilityIdentifier(E2ETestIds.Cart.checkoutReady) Spacer() if let amount = cartManager.cart?.cost.totalAmount, let total = MoneyV2(amount: amount.amount, currencyCode: amount.currencyCode).formattedString() @@ -89,7 +90,7 @@ struct CartView: View { ) .disabled(isBusy) .foregroundColor(.white) - .accessibilityIdentifier("checkoutSheetButton") + .accessibilityIdentifier(E2ETestIds.Cart.checkoutButton) } .padding(.horizontal, 20) .padding(.bottom, 20) @@ -174,6 +175,7 @@ struct EmptyState: View { .padding(.bottom, 6) Text("Your cart is empty.") .font(.caption) + .accessibilityIdentifier(E2ETestIds.Cart.emptyMessage) } } } diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/CustomerAccounts/CustomerAccountLoginView.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/CustomerAccounts/CustomerAccountLoginView.swift index 7e1bd1ed1..32066137e 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/CustomerAccounts/CustomerAccountLoginView.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/CustomerAccounts/CustomerAccountLoginView.swift @@ -11,6 +11,10 @@ struct CustomerAccountLoginView: UIViewRepresentable { let configuration = WKWebViewConfiguration() configuration.websiteDataStore = .nonPersistent() + if let customUserAgent = InfoDictionary.shared.customUserAgent { + configuration.applicationNameForUserAgent = customUserAgent + } + let webView = WKWebView(frame: .zero, configuration: configuration) webView.navigationDelegate = context.coordinator webView.load(URLRequest(url: authorizationURL)) diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControlLinkTests.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControlLinkTests.swift new file mode 100644 index 000000000..f19f2a4fd --- /dev/null +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControlLinkTests.swift @@ -0,0 +1,134 @@ +@testable import CheckoutKitSwiftDemo +import XCTest + +class E2EControlLinkTests: XCTestCase { + func testReturnsNilWhenTheLinkIsNotAControlLink() throws { + XCTAssertNil(try E2EControlLink.parse("https://example.com/cart")) + XCTAssertNil(try E2EControlLink.parse("com.shopify.checkoutkit.swiftdemo://products/1")) + XCTAssertNil(try E2EControlLink.parse("not a url")) + } + + func testParsesEveryAppScheme() throws { + let expected = E2EControlLink.cart(.init(productIndex: 0, quantity: 1)) + let schemes = [ + "com.shopify.checkoutkit.swiftdemo", + "com.shopify.checkoutkit.androiddemo", + "com.shopify.checkoutkit.reactnativedemo" + ] + + for scheme in schemes { + XCTAssertEqual(try E2EControlLink.parse("\(scheme)://e2e/cart?productIndex=0"), expected) + } + } + + func testParsesASchemeTheMatrixDoesNotDeclare() throws { + let expected = E2EControlLink.cart(.init(productIndex: 0, quantity: 1)) + + XCTAssertEqual(try E2EControlLink.parse("com.example.anything://e2e/cart?productIndex=0"), expected) + } + + func testParsesTheResetCommand() throws { + XCTAssertEqual(try parse("/reset"), .reset) + } + + func testRejectsParametersOnTheResetCommand() { + assertThrows(.unexpectedParameters(command: "reset"), "/reset?productIndex=0") + } + + func testRejectsUnknownCommands() { + assertThrows(.unsupportedCommand, "") + assertThrows(.unsupportedCommand, "/") + assertThrows(.unsupportedCommand, "/teleport?productIndex=0") + assertThrows(.unsupportedCommand, "/cart/extra?productIndex=0") + } + + func testRejectsCartCommandsWithoutAProductSelector() { + assertThrows(.missingProductSelector, "/cart") + assertThrows(.missingProductSelector, "/cart?") + assertThrows(.missingProductSelector, "/cart?quantity=2") + } + + func testRejectsCartCommandsWithBothProductSelectors() { + assertThrows(.ambiguousProductSelector, "/cart?variantId=gid://shopify/ProductVariant/1&productIndex=0") + } + + func testRejectsABlankVariantId() { + assertThrows(.blankVariantId, "/cart?variantId=") + assertThrows(.blankVariantId, "/cart?variantId=%20") + } + + func testRejectsInvalidQuantities() { + for quantity in ["", "0", "-1", "1.5", "abc"] { + assertThrows(.invalidQuantity, "/cart?productIndex=0&quantity=\(quantity)") + } + } + + func testRejectsInvalidProductIndexes() { + for productIndex in ["", "-1", "1.5", "abc"] { + assertThrows(.invalidProductIndex, "/cart?productIndex=\(productIndex)") + } + } + + func testRejectsInvalidBuyerIdentityModes() { + for buyerIdentityMode in ["", "member"] { + assertThrows(.invalidBuyerIdentityMode, "/cart?productIndex=0&buyerIdentityMode=\(buyerIdentityMode)") + } + } + + func testParsesACartCommandWithAVariantId() throws { + let link = try parse("/cart?variantId=gid://shopify/ProductVariant/1&quantity=2&buyerIdentityMode=guest") + + XCTAssertEqual(link, .cart(.init(variantId: "gid://shopify/ProductVariant/1", quantity: 2, buyerIdentityMode: .guest))) + } + + func testParsesACartCommandWithAProductIndexAndTheDefaultQuantity() throws { + let link = try parse("/cart?productIndex=3&buyerIdentityMode=hardcoded") + + XCTAssertEqual(link, .cart(.init(productIndex: 3, quantity: 1, buyerIdentityMode: .hardcoded))) + } + + func testParsesACartCommandWithATrailingSlash() throws { + XCTAssertEqual(try parse("/cart/?productIndex=3"), .cart(.init(productIndex: 3, quantity: 1))) + } + + func testParsesASignInCommand() throws { + XCTAssertEqual(try parse("/signIn"), .signIn) + } + + func testRejectsSignInParameters() { + assertThrows(.unexpectedParameters(command: "signIn"), "/signIn?email=shopper@example.com") + } + + func testErrorMessagesMatchTheOtherPlatforms() { + let messages = [ + E2EControlLinkError.unsupportedCommand: "Unsupported e2e command", + E2EControlLinkError.unexpectedParameters(command: "reset"): "reset takes no parameters", + E2EControlLinkError.missingProductSelector: "Missing variantId or productIndex", + E2EControlLinkError.ambiguousProductSelector: "Use variantId or productIndex, not both", + E2EControlLinkError.blankVariantId: "variantId must not be blank", + E2EControlLinkError.invalidQuantity: "quantity must be a positive integer", + E2EControlLinkError.invalidProductIndex: "productIndex must be a non-negative integer", + E2EControlLinkError.invalidBuyerIdentityMode: "buyerIdentityMode must be guest, hardcoded, or customerAccount", + E2EControlLinkError.unexpectedParameters(command: "signIn"): "signIn takes no parameters" + ] + + for (error, message) in messages { + XCTAssertEqual(error.errorDescription, message) + } + } + + private func parse(_ path: String) throws -> E2EControlLink? { + try E2EControlLink.parse("com.shopify.checkoutkit.swiftdemo://e2e\(path)") + } + + private func assertThrows( + _ expected: E2EControlLinkError, + _ path: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertThrowsError(try parse(path), path, file: file, line: line) { error in + XCTAssertEqual(error as? E2EControlLinkError, expected, path, file: file, line: line) + } + } +} diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControllerTests.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControllerTests.swift new file mode 100644 index 000000000..b1157b2a9 --- /dev/null +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControllerTests.swift @@ -0,0 +1,138 @@ +@testable import CheckoutKitSwiftDemo +import XCTest + +@MainActor +class E2EControllerTests: XCTestCase { + func testIgnoresLinksThatAreNotControlLinks() async { + let target = E2ECommandTargetSpy() + + let handled = await E2EController(target: target).handle(url: "https://example.com/cart") + + XCTAssertFalse(handled) + XCTAssertEqual(target.calls, []) + } + + func testReportsAParseFailure() async { + let target = E2ECommandTargetSpy() + + let handled = await handle("/teleport", target) + + XCTAssertTrue(handled) + XCTAssertEqual(target.calls, ["report(Unsupported e2e command)"]) + } + + func testResetsTheCart() async { + let target = E2ECommandTargetSpy() + + let handled = await handle("/reset", target) + + XCTAssertTrue(handled) + XCTAssertEqual(target.calls, ["resetCart"]) + } + + func testSeedsTheCartFromAVariantId() async { + let target = E2ECommandTargetSpy() + + await handle("/cart?variantId=gid://shopify/ProductVariant/1&quantity=3&buyerIdentityMode=hardcoded", target) + + XCTAssertEqual(target.calls, [ + "selectBuyerIdentityMode(hardcoded)", + "resetCart", + "addCartLine(gid://shopify/ProductVariant/1, 3)", + "showCart" + ]) + } + + func testSeedsTheCartFromAProductIndex() async { + let target = E2ECommandTargetSpy() + + await handle("/cart?productIndex=2", target) + + XCTAssertEqual(target.calls, [ + "resetCart", + "variantId(atProductIndex: 2)", + "addCartLine(variant-2, 1)", + "showCart" + ]) + } + + func testSelectsTheBuyerIdentityModeBeforeSeedingBecauseSelectingItResetsTheCart() async { + let target = E2ECommandTargetSpy() + + await handle("/cart?productIndex=0&buyerIdentityMode=guest", target) + + XCTAssertEqual(target.calls.first, "selectBuyerIdentityMode(guest)") + } + + func testReportsASeedFailureAndDoesNotShowTheCart() async { + let target = E2ECommandTargetSpy() + target.variantIdError = E2EControllerError.productIndexOutOfRange(9) + + await handle("/cart?productIndex=9", target) + + XCTAssertEqual(target.calls, [ + "resetCart", + "variantId(atProductIndex: 9)", + "report(No product at index 9)" + ]) + } + + func testPresentsSignIn() async { + let target = E2ECommandTargetSpy() + + let handled = await handle("/signIn", target) + + XCTAssertTrue(handled) + XCTAssertEqual(target.calls, ["presentSignIn"]) + } + + @discardableResult + private func handle(_ path: String, _ target: E2ECommandTargetSpy) async -> Bool { + await E2EController(target: target).handle(url: "com.shopify.checkoutkit.swiftdemo://e2e\(path)") + } +} + +@MainActor +private class E2ECommandTargetSpy: E2ECommandTarget { + var calls: [String] = [] + var variantIdError: Error? + var addCartLineError: Error? + + func selectBuyerIdentityMode(_ mode: BuyerIdentityMode) async { + calls.append("selectBuyerIdentityMode(\(mode.rawValue))") + } + + func resetCart() async { + calls.append("resetCart") + } + + func variantId(atProductIndex index: Int) async throws -> String { + calls.append("variantId(atProductIndex: \(index))") + + if let variantIdError { + throw variantIdError + } + + return "variant-\(index)" + } + + func addCartLine(variantId: String, quantity: Int) async throws { + calls.append("addCartLine(\(variantId), \(quantity))") + + if let addCartLineError { + throw addCartLineError + } + } + + func presentSignIn() async { + calls.append("presentSignIn") + } + + func showCart() async { + calls.append("showCart") + } + + func report(failure message: String) async { + calls.append("report(\(message))") + } +} diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2ETestIdsTests.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2ETestIdsTests.swift index 4ddeb0562..5bf2b9703 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2ETestIdsTests.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2ETestIdsTests.swift @@ -5,4 +5,18 @@ class E2ETestIdsTests: XCTestCase { func testAppReadyMarkerMatchesTheMaestroFlows() { XCTAssertEqual(E2ETestIds.appReady, "checkout-kit-sample-ready") } + + func testCartMarkersMatchTheMaestroFlows() { + XCTAssertEqual(E2ETestIds.Cart.checkoutReady, "cart-checkout-ready") + XCTAssertEqual(E2ETestIds.Cart.checkoutButton, "checkout-button") + XCTAssertEqual(E2ETestIds.Cart.emptyMessage, "cart-empty-message") + } + + func testTabMarkersMatchTheMaestroFlows() { + XCTAssertEqual(E2ETestIds.Tabs.cart, "cart-tab") + } + + func testAccountMarkersMatchTheMaestroFlows() { + XCTAssertEqual(E2ETestIds.Account.signedInView, "account-signed-in-view") + } } diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/project.yml b/platforms/swift/Samples/CheckoutKitSwiftDemo/project.yml index 54193e7cf..0b75fae38 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/project.yml +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/project.yml @@ -89,9 +89,12 @@ targets: StorefrontAccessToken: $(STOREFRONT_ACCESS_TOKEN) CustomerAccountApiClientId: $(CUSTOMER_ACCOUNT_API_CLIENT_ID) CustomerAccountApiShopId: $(CUSTOMER_ACCOUNT_API_SHOP_ID) + CustomUserAgent: $(CUSTOM_USER_AGENT) CFBundleURLTypes: - CFBundleURLSchemes: - shop.$(CUSTOMER_ACCOUNT_API_SHOP_ID).app + - CFBundleURLSchemes: + - com.shopify.checkoutkit.swiftdemo Address1: $(ADDRESS_1) Address2: $(ADDRESS_2) City: $(CITY) diff --git a/platforms/swift/Samples/ShopifyAcceleratedCheckoutsApp/project.yml b/platforms/swift/Samples/ShopifyAcceleratedCheckoutsApp/project.yml index b266522b3..ab203735a 100644 --- a/platforms/swift/Samples/ShopifyAcceleratedCheckoutsApp/project.yml +++ b/platforms/swift/Samples/ShopifyAcceleratedCheckoutsApp/project.yml @@ -49,7 +49,7 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.shopify.example.ShopifyAcceleratedCheckoutsApp - DEVELOPMENT_TEAM: A7XGC83MZE + DEVELOPMENT_TEAM: $(DEVELOPMENT_TEAM) SWIFT_VERSION: "6.0" TARGETED_DEVICE_FAMILY: "1,2" MARKETING_VERSION: "1.0.1" diff --git a/platforms/swift/Scripts/build_and_test_samples b/platforms/swift/Scripts/build_and_test_samples new file mode 100755 index 000000000..e2d29fcb4 --- /dev/null +++ b/platforms/swift/Scripts/build_and_test_samples @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +SAMPLES_DIR="$SCRIPT_DIR/../Samples" + +if ! "$REPO_ROOT/scripts/setup_storefront_env" --check; then + echo "Run dev up from the repo root to sync sample app storefront configuration." + exit 1 +fi + +"$SCRIPT_DIR/generate_xcode_projects" + +cd "$SAMPLES_DIR" + +run_app() { + local app="$1" + local action="$2" + + if [[ ! -f "$app/Storefront.xcconfig" ]]; then + echo "Missing Storefront.xcconfig for $app. Run dev up from the repo root." + exit 1 + fi + + "$SCRIPT_DIR/xcode_run" "$action" "$app" +} + +run_app CheckoutKitSwiftDemo "clean build test" +run_app ShopifyAcceleratedCheckoutsApp "clean build" diff --git a/platforms/swift/Scripts/e2e_maestro b/platforms/swift/Scripts/e2e_maestro new file mode 100755 index 000000000..464c1d8f5 --- /dev/null +++ b/platforms/swift/Scripts/e2e_maestro @@ -0,0 +1,122 @@ +#!/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)" +APP_NAME="CheckoutKitSwiftDemo" +APP_ID="com.shopify.checkoutkit.swiftdemo" +READY_MARKER="checkout-kit-sample-ready" +DERIVED_DATA="$ROOT_DIR/Samples/build/e2e-simulator" +INCLUDE_TAGS="" +# e2e/config.yaml quarantines flaky and wip for every consumer, so this stays empty +# unless the caller excludes something else. +EXCLUDE_TAGS="" + +usage() { + cat <&2 +Usage: dev swift e2e [--tags TAG[,TAG...]] [--exclude-tags TAG[,TAG...]] + +Builds the Swift sample app, installs it on the booted simulator, and runs the +Maestro tests in e2e/tests. Without --tags, every test 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. +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 + +require_maestro() { + if ! maestro --version >/dev/null 2>&1; then + echo "Maestro is required to run E2E tests." >&2 + echo "Install Maestro and make sure maestro --version succeeds." >&2 + return 1 + fi +} + +# Selects by runtime header rather than by device name, because a simulator may carry +# any name. simctl groups booted devices under "-- iOS --" headers, so this +# takes the first device under an iOS header and ignores watchOS and tvOS. +booted_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 }' +} + +require_maestro + +SIMULATOR_ID="$(booted_simulator || true)" +if [ -z "$SIMULATOR_ID" ]; then + echo "No booted iOS simulator found." >&2 + echo "Boot a simulator first, because Maestro drives the device the app runs on." >&2 + exit 1 +fi + +echo "Using booted simulator: $SIMULATOR_ID" + +"$SCRIPT_DIR/generate_xcode_projects" + +cd "$ROOT_DIR/Samples" + +xcodebuild build \ + -workspace Samples.xcworkspace \ + -scheme "$APP_NAME" \ + -sdk iphonesimulator \ + -destination "id=$SIMULATOR_ID" \ + -derivedDataPath "$DERIVED_DATA" \ + -skipPackagePluginValidation \ + -disableAutomaticPackageResolution \ + CODE_SIGNING_ALLOWED=YES \ + CODE_SIGNING_REQUIRED=YES \ + CODE_SIGN_IDENTITY="-" \ + CODE_SIGN_STYLE=Manual \ + PROVISIONING_PROFILE_SPECIFIER="" \ + DEVELOPMENT_TEAM="" + +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 + exit 1 +fi + +xcrun simctl install "$SIMULATOR_ID" "$APP_PATH" + +E2E_DEVICE_ID="$SIMULATOR_ID" \ + "$REPO_ROOT/e2e/scripts/run_maestro" ios "$APP_ID" "$READY_MARKER" "$INCLUDE_TAGS" "$EXCLUDE_TAGS" diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift index 26294508d..272ce8456 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift @@ -421,7 +421,7 @@ class CheckoutWebViewTests: XCTestCase { pollForInvalidation() - wait(for: [invalidated], timeout: 2) + wait(for: [invalidated], timeout: 3) } func testInvalidateDetachesCachedPreloadedWebView() { @@ -864,7 +864,7 @@ class CheckoutWebViewTests: XCTestCase { view.userContentController(WKUserContentController(), didReceive: message) - await fulfillment(of: [responseSent], timeout: 1.0) + await fulfillment(of: [responseSent], timeout: 5.0) let response = try XCTUnwrap(MockCheckoutBridge.lastResponseBody) let parsed = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) XCTAssertEqual(parsed["id"] as? String, "r") diff --git a/scripts/setup_storefront_env b/scripts/setup_storefront_env index 08ba8b772..2c04b6fac 100755 --- a/scripts/setup_storefront_env +++ b/scripts/setup_storefront_env @@ -231,6 +231,7 @@ root_has_canonical_keys() { STOREFRONT_DOMAIN \ STOREFRONT_ACCESS_TOKEN \ STOREFRONT_MERCHANT_IDENTIFIER \ + DEVELOPMENT_TEAM \ API_VERSION \ CUSTOMER_ACCOUNT_API_CLIENT_ID \ CUSTOMER_ACCOUNT_API_SHOP_ID \ @@ -245,7 +246,8 @@ root_has_canonical_keys() { LAST_NAME \ PROVINCE \ ZIP \ - PHONE; do + PHONE \ + CUSTOM_USER_AGENT; do env_has_key "$key" "$ROOT_ENV" || return 1 done } @@ -448,23 +450,34 @@ load_values() { "$(read_env_value CUSTOMER_ACCOUNT_API_VERSION "$SWIFT_ACCELERATED_XCCONFIG")" \ "$DEFAULT_CUSTOMER_ACCOUNT_API_VERSION")" + CUSTOM_USER_AGENT_VALUE="$(root_or_source_value CUSTOM_USER_AGENT \ + "$(env_fallback CUSTOM_USER_AGENT)" \ + "$(read_env_value CUSTOM_USER_AGENT "$ANDROID_ENV")" \ + "$(read_env_value CUSTOM_USER_AGENT "$REACT_NATIVE_ENV")" \ + "$(read_env_value CUSTOM_USER_AGENT "$SWIFT_DEMO_XCCONFIG")")" + + E2E_CUSTOMER_ACCOUNT_EMAIL_VALUE="$(root_or_source_value E2E_CUSTOMER_ACCOUNT_EMAIL \ + "$(env_fallback E2E_CUSTOMER_ACCOUNT_EMAIL)")" + + E2E_CUSTOMER_ACCOUNT_CODE_VALUE="$(root_or_source_value E2E_CUSTOMER_ACCOUNT_CODE \ + "$(env_fallback E2E_CUSTOMER_ACCOUNT_CODE)")" + EMAIL_VALUE="$(root_or_source_value EMAIL "$(env_fallback EMAIL)" "$(read_env_value EMAIL "$ANDROID_ENV")" "$(read_env_value PREFILL_EMAIL "$ANDROID_ENV")" "$(read_env_value EMAIL "$REACT_NATIVE_ENV")" "$(read_env_value EMAIL "$SWIFT_DEMO_XCCONFIG")" "checkout-kit@example.com")" - ADDRESS_1_VALUE="$(root_or_source_value ADDRESS_1 "$(env_fallback ADDRESS_1)" "$(read_env_value ADDRESS_1 "$REACT_NATIVE_ENV")" "$(read_env_value ADDRESS_1 "$SWIFT_DEMO_XCCONFIG")" "650 King Street")" + ADDRESS_1_VALUE="$(root_or_source_value ADDRESS_1 "$(env_fallback ADDRESS_1)" "$(read_env_value ADDRESS_1 "$REACT_NATIVE_ENV")" "$(read_env_value ADDRESS_1 "$SWIFT_DEMO_XCCONFIG")" "700 S Flower St")" ADDRESS_2_VALUE="$(root_or_source_value ADDRESS_2 "$(env_fallback ADDRESS_2)" "$(read_env_value ADDRESS_2 "$REACT_NATIVE_ENV")" "$(read_env_value ADDRESS_2 "$SWIFT_DEMO_XCCONFIG")" "Shopify HQ")" - CITY_VALUE="$(root_or_source_value CITY "$(env_fallback CITY)" "$(read_env_value CITY "$REACT_NATIVE_ENV")" "$(read_env_value CITY "$SWIFT_DEMO_XCCONFIG")" "Toronto")" + CITY_VALUE="$(root_or_source_value CITY "$(env_fallback CITY)" "$(read_env_value CITY "$REACT_NATIVE_ENV")" "$(read_env_value CITY "$SWIFT_DEMO_XCCONFIG")" "Los Angeles")" COMPANY_VALUE="$(root_or_source_value COMPANY "$(env_fallback COMPANY)" "$(read_env_value COMPANY "$REACT_NATIVE_ENV")" "$(read_env_value COMPANY "$SWIFT_DEMO_XCCONFIG")" "Shopify")" - COUNTRY_VALUE="$(root_or_source_value COUNTRY "$(env_fallback COUNTRY)" "$(read_env_value COUNTRY "$REACT_NATIVE_ENV")" "$(read_env_value COUNTRY "$SWIFT_DEMO_XCCONFIG")" "CA")" + COUNTRY_VALUE="$(root_or_source_value COUNTRY "$(env_fallback COUNTRY)" "$(read_env_value COUNTRY "$REACT_NATIVE_ENV")" "$(read_env_value COUNTRY "$SWIFT_DEMO_XCCONFIG")" "US")" FIRST_NAME_VALUE="$(root_or_source_value FIRST_NAME "$(env_fallback FIRST_NAME)" "$(read_env_value FIRST_NAME "$REACT_NATIVE_ENV")" "$(read_env_value FIRST_NAME "$SWIFT_DEMO_XCCONFIG")" "Evelyn")" LAST_NAME_VALUE="$(root_or_source_value LAST_NAME "$(env_fallback LAST_NAME)" "$(read_env_value LAST_NAME "$REACT_NATIVE_ENV")" "$(read_env_value LAST_NAME "$SWIFT_DEMO_XCCONFIG")" "Hartley")" - PROVINCE_VALUE="$(root_or_source_value PROVINCE "$(env_fallback PROVINCE)" "$(read_env_value PROVINCE "$REACT_NATIVE_ENV")" "$(read_env_value PROVINCE "$SWIFT_DEMO_XCCONFIG")" "ON")" - ZIP_VALUE="$(root_or_source_value ZIP "$(env_fallback ZIP)" "$(read_env_value ZIP "$REACT_NATIVE_ENV")" "$(read_env_value ZIP "$SWIFT_DEMO_XCCONFIG")" "M5V 1M7")" - PHONE_VALUE="$(root_or_source_value PHONE "$(env_fallback PHONE)" "$(read_env_value PHONE "$ANDROID_ENV")" "$(read_env_value PREFILL_PHONE "$ANDROID_ENV")" "$(read_env_value PHONE "$REACT_NATIVE_ENV")" "$(read_env_value PHONE "$SWIFT_DEMO_XCCONFIG")" "+14165550100")" + PROVINCE_VALUE="$(root_or_source_value PROVINCE "$(env_fallback PROVINCE)" "$(read_env_value PROVINCE "$REACT_NATIVE_ENV")" "$(read_env_value PROVINCE "$SWIFT_DEMO_XCCONFIG")" "CA")" + ZIP_VALUE="$(root_or_source_value ZIP "$(env_fallback ZIP)" "$(read_env_value ZIP "$REACT_NATIVE_ENV")" "$(read_env_value ZIP "$SWIFT_DEMO_XCCONFIG")" "90017")" + PHONE_VALUE="$(root_or_source_value PHONE "$(env_fallback PHONE)" "$(read_env_value PHONE "$ANDROID_ENV")" "$(read_env_value PREFILL_PHONE "$ANDROID_ENV")" "$(read_env_value PHONE "$REACT_NATIVE_ENV")" "$(read_env_value PHONE "$SWIFT_DEMO_XCCONFIG")" "+12135550142")" - # Only the demo xcconfig carries this. The Accelerated sample still names the team in its - # own project.yml, so a second copy here would be a value nothing reads. DEVELOPMENT_TEAM_VALUE="$(root_or_source_nonempty_value DEVELOPMENT_TEAM \ "$(env_fallback DEVELOPMENT_TEAM)" \ "$(read_env_value DEVELOPMENT_TEAM "$SWIFT_DEMO_XCCONFIG")" \ + "$(read_env_value DEVELOPMENT_TEAM "$SWIFT_ACCELERATED_XCCONFIG")" \ "$DEFAULT_DEVELOPMENT_TEAM")" } @@ -519,7 +532,7 @@ EOF write_env_assignment STOREFRONT_MERCHANT_IDENTIFIER "$STOREFRONT_MERCHANT_IDENTIFIER_VALUE" cat <"$output" 2>&1 assert_output_is_sanitized "$output" @@ -158,6 +171,27 @@ test_required_values_only() { assert_output_is_sanitized "$output" } +test_buyer_address_defaults_match_the_e2e_shop_country() { + local fixture output + fixture="$(make_fixture)" + output="$fixture/output.log" + + STOREFRONT_DOMAIN=synthetic-store.example.myshopify.com \ + STOREFRONT_ACCESS_TOKEN=synthetic-token \ + "$fixture/scripts/setup_storefront_env" --skip-optional-prompts >"$output" 2>&1 + + assert_output_is_sanitized "$output" + assert_contains "$fixture/.env" "ADDRESS_1=700 S Flower St" + assert_contains "$fixture/.env" "CITY=Los Angeles" + assert_contains "$fixture/.env" "COUNTRY=US" + assert_contains "$fixture/.env" "PROVINCE=CA" + assert_contains "$fixture/.env" "ZIP=90017" + assert_contains "$fixture/.env" "PHONE=+12135550142" + assert_contains "$fixture/platforms/android/samples/CheckoutKitAndroidDemo/.env" "COUNTRY=US" + assert_contains "$fixture/platforms/react-native/sample/.env" "COUNTRY=\"US\"" + assert_contains "$fixture/platforms/swift/Samples/CheckoutKitSwiftDemo/Storefront.xcconfig" "COUNTRY = US" +} + test_optional_sync_updates_blank_optional_values_after_required_setup() { local fixture output fixture="$(make_fixture)" @@ -249,17 +283,19 @@ EOF # DEVELOPMENT_TEAM in .env must still get a signable project. The literal default keeps # that working, and a value in .env has to win so a second team can build the sample. test_development_team_defaults_then_yields_to_env() { - local fixture output xcconfig + local fixture output demo_xcconfig accelerated_xcconfig fixture="$(make_fixture)" output="$fixture/output.log" - xcconfig="$fixture/platforms/swift/Samples/CheckoutKitSwiftDemo/Storefront.xcconfig" + demo_xcconfig="$fixture/platforms/swift/Samples/CheckoutKitSwiftDemo/Storefront.xcconfig" + accelerated_xcconfig="$fixture/platforms/swift/Samples/ShopifyAcceleratedCheckoutsApp/Storefront.xcconfig" STOREFRONT_DOMAIN=synthetic-store.example.myshopify.com \ STOREFRONT_ACCESS_TOKEN=synthetic-token \ "$fixture/scripts/setup_storefront_env" --skip-optional-prompts >"$output" 2>&1 assert_output_is_sanitized "$output" - assert_contains "$xcconfig" "DEVELOPMENT_TEAM = A7XGC83MZE" + assert_contains "$demo_xcconfig" "DEVELOPMENT_TEAM = A7XGC83MZE" + assert_contains "$accelerated_xcconfig" "DEVELOPMENT_TEAM = A7XGC83MZE" # .env has to carry the key as well, or the next normalizing rewrite drops the override. assert_contains "$fixture/.env" "DEVELOPMENT_TEAM=A7XGC83MZE" @@ -271,14 +307,36 @@ test_development_team_defaults_then_yields_to_env() { "$fixture/scripts/setup_storefront_env" --skip-optional-prompts >"$output" 2>&1 assert_output_is_sanitized "$output" - assert_contains "$xcconfig" "DEVELOPMENT_TEAM = SYNTHETIC9" + assert_contains "$demo_xcconfig" "DEVELOPMENT_TEAM = SYNTHETIC9" + assert_contains "$accelerated_xcconfig" "DEVELOPMENT_TEAM = SYNTHETIC9" assert_contains "$fixture/.env" "DEVELOPMENT_TEAM=SYNTHETIC9" +} - # The Accelerated sample still hardcodes the team in its own project.yml, so writing the - # key into its xcconfig would create a second source that nothing reads. - assert_not_contains \ - "$fixture/platforms/swift/Samples/ShopifyAcceleratedCheckoutsApp/Storefront.xcconfig" \ - "DEVELOPMENT_TEAM" +# A clone whose .env predates DEVELOPMENT_TEAM holds every other canonical key, so the +# normalizing rewrite has to notice the one absent key. Otherwise dev up leaves .env alone +# and the developer never sees the key they are meant to override. +test_development_team_is_added_to_an_existing_env() { + local fixture output accelerated_xcconfig + fixture="$(make_fixture)" + output="$fixture/output.log" + accelerated_xcconfig="$fixture/platforms/swift/Samples/ShopifyAcceleratedCheckoutsApp/Storefront.xcconfig" + + STOREFRONT_DOMAIN=synthetic-store.example.myshopify.com \ + STOREFRONT_ACCESS_TOKEN=synthetic-token \ + "$fixture/scripts/setup_storefront_env" --skip-optional-prompts >"$output" 2>&1 + + grep -v '^DEVELOPMENT_TEAM=' "$fixture/.env" >"$fixture/.env.next" + mv "$fixture/.env.next" "$fixture/.env" + + if "$fixture/scripts/setup_storefront_env" --check >"$output" 2>&1; then + fail "root .env without DEVELOPMENT_TEAM passed --check" + fi + assert_output_is_sanitized "$output" + + "$fixture/scripts/setup_storefront_env" --skip-optional-prompts >"$output" 2>&1 + assert_output_is_sanitized "$output" + assert_contains "$fixture/.env" "DEVELOPMENT_TEAM=A7XGC83MZE" + assert_contains "$accelerated_xcconfig" "DEVELOPMENT_TEAM = A7XGC83MZE" } test_migration_from_platform_config() { @@ -310,10 +368,12 @@ EOF test_sync_and_check test_required_values_only +test_buyer_address_defaults_match_the_e2e_shop_country test_optional_sync_updates_blank_optional_values_after_required_setup test_normalizes_existing_root_env test_blank_customer_account_api_version_defaults test_development_team_defaults_then_yields_to_env +test_development_team_is_added_to_an_existing_env test_migration_from_platform_config echo "setup_storefront_env synthetic tests passed."