From 2e878aecb395b750352c139b90e6d2509ab1c80a Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Wed, 5 Aug 2026 19:20:58 +0100 Subject: [PATCH 01/15] test: widen two tight async waits in CheckoutWebViewTests Both waits were outliers against their own neighbours and failed on consecutive CI runs on slow macOS runners. testPreloadKeepAliveFailureInvalidatesCache polled to a 2s deadline and waited 2s, leaving no slack. The neighbouring expiry test already pairs a 2s deadline with a 3s wait. testWindowOpenRequestReturnsInvalidParamsForMalformedBody was the only sendResponse wait at 1.0s; every other one in the file uses 5.0s. Co-Authored-By: Claude Opus 5 (1M context) Assisted-By: devx/5b4b8d5b-690a-4389-841e-039723ade1da --- .env.example | 4 +- .github/workflows/android-test.yml | 10 ----- .../swift-build-and-test-samples.yml | 2 +- .../project.yml | 2 +- .../swift/Scripts/build_and_test_samples | 31 +++++++++++++ .../CheckoutWebViewTests.swift | 4 +- scripts/setup_storefront_env | 7 +-- scripts/test_setup_storefront_env | 43 +++++++++++++++---- 8 files changed, 75 insertions(+), 28 deletions(-) create mode 100755 platforms/swift/Scripts/build_and_test_samples diff --git a/.env.example b/.env.example index c0f22f8bc..0d8f4a4ca 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 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/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/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/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..97084e4ac 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 \ @@ -460,11 +461,10 @@ load_values() { 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")" - # 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 +519,7 @@ EOF write_env_assignment STOREFRONT_MERCHANT_IDENTIFIER "$STOREFRONT_MERCHANT_IDENTIFIER_VALUE" cat <"$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 +273,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() { @@ -314,6 +338,7 @@ 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." From 9b3d9a8efda1a3c5227111289587004bf00e9a03 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Thu, 30 Jul 2026 12:36:40 +0100 Subject: [PATCH 02/15] Add the E2E control link parser to all four sample apps The E2E suite drives four apps: Swift iOS, Kotlin Android, React Native iOS and React Native Android. Only React Native could read a deep link, and only for a cart. Widen the contract to ://e2e/ with the commands reset, cart and signIn, and port the parser to Swift and Kotlin so one Maestro flow drives every app. Every platform rewrites the scheme to https before parsing. Neither Foundation URLComponents nor java.net.URI accepts the underscores in the Android application id, and the React Native URL polyfill parses a host only for http(s). A test per platform pins that behaviour with both app schemes. The parser stays pure and keeps three outcomes: nil when the link belongs to another feature, a parsed command, or an error that names the bad parameter. A parameter that is present must carry a valid value. Co-Authored-By: Claude Opus 5 (1M context) --- .../e2e/E2EControlLink.kt | 143 +++++++++++++++ .../e2e/E2EControlLinkTest.kt | 138 ++++++++++++++ .../src/e2e/__tests__/cartBootstrap.test.ts | 104 ----------- .../src/e2e/__tests__/controlLink.test.ts | 149 +++++++++++++++ .../sample/src/e2e/cartBootstrap.ts | 103 ----------- .../sample/src/e2e/controlLink.ts | 169 +++++++++++++++++ .../sample/src/e2e/useE2ECartBootstrap.ts | 25 ++- .../react-native/scripts/e2e_maestro_android | 2 +- .../react-native/scripts/e2e_maestro_ios | 2 +- .../Sources/E2E/E2EControlLink.swift | 172 ++++++++++++++++++ .../E2E/E2EControlLinkTests.swift | 127 +++++++++++++ 11 files changed, 916 insertions(+), 218 deletions(-) create mode 100644 platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/e2e/E2EControlLink.kt create mode 100644 platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkout_kit_android_demo/e2e/E2EControlLinkTest.kt delete mode 100644 platforms/react-native/sample/src/e2e/__tests__/cartBootstrap.test.ts create mode 100644 platforms/react-native/sample/src/e2e/__tests__/controlLink.test.ts delete mode 100644 platforms/react-native/sample/src/e2e/cartBootstrap.ts create mode 100644 platforms/react-native/sample/src/e2e/controlLink.ts create mode 100644 platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EControlLink.swift create mode 100644 platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControlLinkTests.swift 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..080724ff7 --- /dev/null +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/e2e/E2EControlLink.kt @@ -0,0 +1,143 @@ +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 class SignIn(val email: String? = null) : 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(email = signInEmail(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 signInEmail(parameters: Map): String? { + val email = parameters["email"] ?: return null + + require(email.isNotEmpty()) { "email must not be blank" } + + return email + } + + 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/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..1cbe79421 --- /dev/null +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkout_kit_android_demo/e2e/E2EControlLinkTest.kt @@ -0,0 +1,138 @@ +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) + + assertThat(E2EControlLink.parse("com.shopify.checkout_kit_android_demo://e2e/cart?productIndex=0")).isEqualTo(expected) + assertThat(E2EControlLink.parse("com.shopify.checkoutkit.swiftdemo://e2e/cart?productIndex=0")).isEqualTo(expected) + } + + @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 without an email`() { + assertThat(parse("/signIn")).isEqualTo(E2EControlLink.SignIn()) + } + + @Test + fun `parses a sign in command with an email`() { + assertThat(parse("/signIn?email=shopper%2Be2e@example.com")) + .isEqualTo(E2EControlLink.SignIn(email = "shopper+e2e@example.com")) + } + + @Test + fun `rejects a blank sign in email`() { + assertRejects("/signIn?email=", "email must not be blank") + assertRejects("/signIn?email=%20", "email must not be blank") + } + + 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/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..026771f2e --- /dev/null +++ b/platforms/react-native/sample/src/e2e/__tests__/controlLink.test.ts @@ -0,0 +1,149 @@ +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}; + + expect( + parseControlLink( + 'com.shopify.checkoutkit.reactnativedemo://e2e/cart?productIndex=0', + ), + ).toEqual(expected); + expect( + parseControlLink( + 'com.shopify.checkout_kit_android_demo://e2e/cart?productIndex=0', + ), + ).toEqual(expected); + }); + + 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 without an email', () => { + expect(parse('/signIn')).toEqual({command: 'signIn'}); + }); + + it('parses a sign in command with an email', () => { + expect(parse('/signIn?email=shopper%2Be2e@example.com')).toEqual({ + command: 'signIn', + email: 'shopper+e2e@example.com', + }); + }); + + it.each(['/signIn?email=', '/signIn?email=%20'])( + 'rejects the blank sign in email in %s', + path => { + expectRejection(path, 'email must not be blank'); + }, + ); +}); 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..d3b6d2e08 --- /dev/null +++ b/platforms/react-native/sample/src/e2e/controlLink.ts @@ -0,0 +1,169 @@ +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'; + email?: string; +}; + +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 { + const email = parameters.get('email'); + + if (email === undefined) { + return {command: 'signIn'}; + } + + if (email === '') { + throw new Error('email must not be blank'); + } + + return {command: 'signIn', email}; +} + +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/useE2ECartBootstrap.ts b/platforms/react-native/sample/src/e2e/useE2ECartBootstrap.ts index e75eecd8c..6fa4bddd4 100644 --- a/platforms/react-native/sample/src/e2e/useE2ECartBootstrap.ts +++ b/platforms/react-native/sample/src/e2e/useE2ECartBootstrap.ts @@ -2,7 +2,7 @@ import {useCallback} from 'react'; import {Alert} from 'react-native'; import {useCart} from '../context/Cart'; import useShopify from '../hooks/useShopify'; -import {parseCartBootstrapLink, type CartBootstrapLink} from './cartBootstrap'; +import {parseControlLink, type E2EControlLink} from './controlLink'; type UseE2ECartBootstrapOptions = { onCartReady: () => void; @@ -19,26 +19,33 @@ export function useE2ECartBootstrap({onCartReady}: UseE2ECartBootstrapOptions) { return useCallback( async (url: string) => { - let cartBootstrapLink: CartBootstrapLink | null = null; + let controlLink: E2EControlLink | null = null; try { - cartBootstrapLink = parseCartBootstrapLink(url); + controlLink = parseControlLink(url); } catch (error) { - Alert.alert('Invalid cart bootstrap link', errorMessage(error)); + Alert.alert('Invalid e2e control link', errorMessage(error)); return true; } - if (!cartBootstrapLink) { + if (!controlLink) { return false; } + if (controlLink.command !== 'cart') { + Alert.alert('Unsupported e2e command', controlLink.command); + return true; + } + + const cartCommand = controlLink; + try { - let {variantId} = cartBootstrapLink; + let {variantId} = cartCommand; if (!variantId) { const {data} = await fetchProducts(); const product = - data?.products.edges[cartBootstrapLink.productIndex ?? 0]?.node; + data?.products.edges[cartCommand.productIndex ?? 0]?.node; variantId = product?.variants.edges[0]?.node.id; } @@ -49,8 +56,8 @@ export function useE2ECartBootstrap({onCartReady}: UseE2ECartBootstrapOptions) { await seedCart( variantId, - cartBootstrapLink.quantity, - cartBootstrapLink.buyerIdentityMode, + cartCommand.quantity, + cartCommand.buyerIdentityMode, ); onCartReady(); } catch (error) { diff --git a/platforms/react-native/scripts/e2e_maestro_android b/platforms/react-native/scripts/e2e_maestro_android index 3d7325086..4b06704f7 100755 --- a/platforms/react-native/scripts/e2e_maestro_android +++ b/platforms/react-native/scripts/e2e_maestro_android @@ -9,7 +9,7 @@ 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" +CART_BOOTSTRAP_BASE_LINK="${APP_ID}://e2e/cart?productIndex=0&quantity=1" MAESTRO_FLOWS=() usage() { diff --git a/platforms/react-native/scripts/e2e_maestro_ios b/platforms/react-native/scripts/e2e_maestro_ios index c717f94fb..2f39cb1ff 100755 --- a/platforms/react-native/scripts/e2e_maestro_ios +++ b/platforms/react-native/scripts/e2e_maestro_ios @@ -9,7 +9,7 @@ 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" +CART_BOOTSTRAP_BASE_LINK="${APP_ID}://e2e/cart?productIndex=0&quantity=1" MAESTRO_FLOWS=() usage() { 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..86944b838 --- /dev/null +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EControlLink.swift @@ -0,0 +1,172 @@ +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 + case blankEmail + + 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" + case .blankEmail: + return "email must not be blank" + } + } +} + +enum E2EControlLink: Equatable { + case reset + case cart(CartCommand) + case signIn(email: String?) + + 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": + return try .signIn(email: signInEmail(from: parameters)) + 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 static func signInEmail(from parameters: Parameters) throws -> String? { + guard let email = parameters.value("email") else { + return nil + } + + guard !email.isEmpty else { + throw E2EControlLinkError.blankEmail + } + + return email + } + + 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/CheckoutKitSwiftDemoTests/E2E/E2EControlLinkTests.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControlLinkTests.swift new file mode 100644 index 000000000..5e84c1820 --- /dev/null +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControlLinkTests.swift @@ -0,0 +1,127 @@ +@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)) + + XCTAssertEqual(try E2EControlLink.parse("com.shopify.checkoutkit.swiftdemo://e2e/cart?productIndex=0"), expected) + XCTAssertEqual(try E2EControlLink.parse("com.shopify.checkout_kit_android_demo://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 testParsesASignInCommandWithoutAnEmail() throws { + XCTAssertEqual(try parse("/signIn"), .signIn(email: nil)) + } + + func testParsesASignInCommandWithAnEmail() throws { + XCTAssertEqual(try parse("/signIn?email=shopper%2Be2e@example.com"), .signIn(email: "shopper+e2e@example.com")) + } + + func testRejectsABlankSignInEmail() { + assertThrows(.blankEmail, "/signIn?email=") + assertThrows(.blankEmail, "/signIn?email=%20") + } + + 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.blankEmail: "email must not be blank" + ] + + 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) + } + } +} From 94093d5ce33a556af4aee86fa60ef8f3ebf56ace Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Thu, 30 Jul 2026 13:07:19 +0100 Subject: [PATCH 03/15] feat(e2e): add an E2EController to each sample app Every E2E command now enters one controller per sample. The controller composes existing primitives only, so E2E logic stays out of the cart, storefront and account code that merchants read as integration guidance. An injectable E2ECommandTarget makes the decision order testable without a network or a UI. Eight tests per platform assert an ordered list of calls, which pins the one rule that matters: selecting a buyer identity mode resets the cart, so it must run before seeding. Production code touches E2E in one named line per call site: SceneDelegate.scene(_:openURLContexts:), MainActivity, and Routes. Android needs two touch points, not one. Navigation state lives in Compose, so E2ENavigation plus one E2ENavigationEffect line lets the app self-navigate to the cart. The Maestro flow stops the app before opening the link, so a cold start delivers the intent to onCreate; both entry points route through E2EControlLinkHandler. The manifest gains a VIEW filter and singleTask. Swift gains a second URL scheme, without which openURLContexts never fires. Cart markers now match the React Native precedent on all four targets: the button carries checkout-button, the label carries cart-checkout-ready. signIn reports that it is not implemented yet. A later PR adds it. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/src/main/AndroidManifest.xml | 8 + .../CheckoutKitApp.kt | 4 + .../checkout_kit_android_demo/MainActivity.kt | 10 ++ .../cart/CartView.kt | 4 + .../e2e/E2EControlLinkHandler.kt | 16 ++ .../e2e/E2EController.kt | 60 ++++++++ .../e2e/E2ENavigation.kt | 26 ++++ .../e2e/E2ESampleAppTarget.kt | 59 ++++++++ .../e2e/E2ETestIds.kt | 5 + .../e2e/E2EControllerTest.kt | 135 +++++++++++++++++ .../e2e/E2ETestIdsTest.kt | 6 + .../src/e2e/__tests__/controller.test.ts | 137 ++++++++++++++++++ .../react-native/sample/src/e2e/controller.ts | 74 ++++++++++ .../sample/src/e2e/useE2ECartBootstrap.ts | 88 +++++------ .../Sources/App/SceneDelegate.swift | 2 + .../Sources/E2E/E2EController.swift | 96 ++++++++++++ .../Sources/E2E/E2ESampleAppTarget.swift | 55 +++++++ .../Sources/E2E/E2ETestIds.swift | 5 + .../Sources/Scenes/Cart/CartView.swift | 3 +- .../E2E/E2EControllerTests.swift | 133 +++++++++++++++++ .../E2E/E2ETestIdsTests.swift | 5 + .../Samples/CheckoutKitSwiftDemo/project.yml | 2 + 22 files changed, 879 insertions(+), 54 deletions(-) create mode 100644 platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/e2e/E2EControlLinkHandler.kt create mode 100644 platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/e2e/E2EController.kt create mode 100644 platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/e2e/E2ENavigation.kt create mode 100644 platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/e2e/E2ESampleAppTarget.kt create mode 100644 platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkout_kit_android_demo/e2e/E2EControllerTest.kt create mode 100644 platforms/react-native/sample/src/e2e/__tests__/controller.test.ts create mode 100644 platforms/react-native/sample/src/e2e/controller.ts create mode 100644 platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EController.swift create mode 100644 platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ESampleAppTarget.swift create mode 100644 platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControllerTests.swift 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..4753a02ce 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() 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..268f3bdb0 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) ) } } 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..9e8571ebb --- /dev/null +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/e2e/E2EController.kt @@ -0,0 +1,60 @@ +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 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 -> throw UnsupportedOperationException("signIn is not implemented yet") + } + } 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..f74595864 --- /dev/null +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/e2e/E2ESampleAppTarget.kt @@ -0,0 +1,59 @@ +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 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..2ef583316 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,9 @@ 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" + } } 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..1cc81fbd6 --- /dev/null +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkout_kit_android_demo/e2e/E2EControllerTest.kt @@ -0,0 +1,135 @@ +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 `reports that sign in is not implemented`() { + val target = E2ECommandTargetSpy() + + handle("/signIn", target) + + assertThat(target.calls).containsExactly("report(signIn is not implemented yet)") + } + + 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 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..75d466835 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,10 @@ 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") + } } 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..6e6aa73e3 --- /dev/null +++ b/platforms/react-native/sample/src/e2e/__tests__/controller.test.ts @@ -0,0 +1,137 @@ +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 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('reports that sign in is not implemented', async () => { + const target = new E2ECommandTargetSpy(); + + await handle('/signIn', target); + + expect(target.calls).toEqual(['report(signIn is not implemented yet)']); + }); +}); 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..d2ede28aa --- /dev/null +++ b/platforms/react-native/sample/src/e2e/controller.ts @@ -0,0 +1,74 @@ +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; + 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': + throw new Error('signIn is not implemented yet'); + } + } 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 6fa4bddd4..816521534 100644 --- a/platforms/react-native/sample/src/e2e/useE2ECartBootstrap.ts +++ b/platforms/react-native/sample/src/e2e/useE2ECartBootstrap.ts @@ -1,71 +1,53 @@ -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 {parseControlLink, type E2EControlLink} from './controlLink'; +import {E2EController, type E2ECommandTarget} from './controller'; type UseE2ECartBootstrapOptions = { onCartReady: () => void; }; -function errorMessage(error: unknown) { - return error instanceof Error ? error.message : 'Unknown error'; -} - export function useE2ECartBootstrap({onCartReady}: UseE2ECartBootstrapOptions) { - const {seedCart} = useCart(); + const {seedCart, clearCart} = useCart(); const {queries} = useShopify(); const [fetchProducts] = queries.products; - return useCallback( - async (url: string) => { - let controlLink: E2EControlLink | null = null; - - try { - controlLink = parseControlLink(url); - } catch (error) { - Alert.alert('Invalid e2e control link', errorMessage(error)); - return true; - } - - if (!controlLink) { - return false; - } - - if (controlLink.command !== 'cart') { - Alert.alert('Unsupported e2e command', controlLink.command); - return true; - } - - const cartCommand = controlLink; - - try { - let {variantId} = cartCommand; - - if (!variantId) { - const {data} = await fetchProducts(); - const product = - data?.products.edges[cartCommand.productIndex ?? 0]?.node; - - variantId = product?.variants.edges[0]?.node.id; - } + 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) { - throw new Error('Cart bootstrap product variant was not found'); + throw new Error(`No product at index ${productIndex}`); } - await seedCart( - variantId, - cartCommand.quantity, - cartCommand.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 report(failure) { + Alert.alert('E2E command failed', failure); + }, + }; + }, [clearCart, fetchProducts, onCartReady, seedCart]); + + return useCallback((url: string) => new E2EController(target).handle(url), [ + target, + ]); } diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/SceneDelegate.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/SceneDelegate.swift index 329b44c0d..1ffa1754f 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/SceneDelegate.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/SceneDelegate.swift @@ -242,6 +242,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/E2EController.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EController.swift new file mode 100644 index 000000000..549434217 --- /dev/null +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EController.swift @@ -0,0 +1,96 @@ +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 report(failure message: String) async +} + +enum E2EControllerError: LocalizedError, Equatable { + case unimplementedCommand(String) + case productIndexOutOfRange(Int) + + var errorDescription: String? { + switch self { + case let .unimplementedCommand(command): + return "\(command) is not implemented yet" + 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: + throw E2EControllerError.unimplementedCommand("signIn") + } + } 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..af02f363f --- /dev/null +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ESampleAppTarget.swift @@ -0,0 +1,55 @@ +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 report(failure message: String) async { + print("[E2E] \(message)") + } +} diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ETestIds.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ETestIds.swift index 0835e4d03..5f3a559ce 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ETestIds.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ETestIds.swift @@ -1,3 +1,8 @@ enum E2ETestIds { static let appReady = "checkout-kit-sample-ready" + + enum Cart { + static let checkoutReady = "cart-checkout-ready" + static let checkoutButton = "checkout-button" + } } 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..7d25a39ef 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) 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..cbd7c2c71 --- /dev/null +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControllerTests.swift @@ -0,0 +1,133 @@ +@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 testReportsThatSignInIsNotImplemented() async { + let target = E2ECommandTargetSpy() + + await handle("/signIn", target) + + XCTAssertEqual(target.calls, ["report(signIn is not implemented yet)"]) + } + + @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 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..ce363412b 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2ETestIdsTests.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2ETestIdsTests.swift @@ -5,4 +5,9 @@ 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") + } } diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/project.yml b/platforms/swift/Samples/CheckoutKitSwiftDemo/project.yml index 54193e7cf..54032831e 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/project.yml +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/project.yml @@ -92,6 +92,8 @@ targets: CFBundleURLTypes: - CFBundleURLSchemes: - shop.$(CUSTOMER_ACCOUNT_API_SHOP_ID).app + - CFBundleURLSchemes: + - com.shopify.checkoutkit.swiftdemo Address1: $(ADDRESS_1) Address2: $(ADDRESS_2) City: $(CITY) From ac81c3ca4d5d14b0a80079c931fec8f0e5c1cc1f Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Thu, 30 Jul 2026 13:14:21 +0100 Subject: [PATCH 04/15] feat(e2e): select tests by Maestro tag instead of scenario flags The two scenario flags only picked a file, and each test already carries its scenario in its own env block. Tags scale to the tests Phase 6 adds without a new flag per test. Every test now declares tags from one taxonomy: one journey (launch, cart, checkout, account), one cost tier (smoke, full), plus optional quarantine (flaky, wip) and platform capability (ios-only, android-only) tags. maestro_test_tags_test.rb enforces all of it, and a platform tag must name the capability that earns it, so nobody tags an unported test. config.yaml excludes flaky and wip, so a quarantined test stays in the tree and out of every run. `dev rn e2e {ios|android}` now runs the whole tests folder and takes --tags/--exclude-tags. Both match any listed tag, because that is how Maestro filters. The runner also supplies E2E_APP_ID and E2E_READY_MARKER so the shared launch smoke runs locally; a later commit unifies those names. ci.yml gates flow edits on the ruby job, so editing a test reaches the validator. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 2 + dev.yml | 14 +-- e2e/README.md | 39 ++++++--- e2e/config.yaml | 5 ++ e2e/test/maestro_test_tags_test.rb | 79 +++++++++++++++++ e2e/tests/react-native/checkout-guest.yaml | 3 + .../checkout-hardcoded-buyer-identity.yaml | 3 + e2e/tests/shared/launch-smoke.yaml | 4 + .../react-native/scripts/e2e_maestro_android | 86 ++++++++++--------- .../react-native/scripts/e2e_maestro_ios | 86 ++++++++++--------- 10 files changed, 225 insertions(+), 96 deletions(-) create mode 100644 e2e/test/maestro_test_tags_test.rb 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/dev.yml b/dev.yml index 2e954423a..bee1b24b7 100644 --- a/dev.yml +++ b/dev.yml @@ -502,19 +502,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/README.md b/e2e/README.md index 254db92f1..64d9da132 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -3,9 +3,9 @@ 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 **local** React Native suite, run with `dev rn e2e`, that exercises guest and + hardcoded buyer identity checkouts from seeded carts through Shopify checkout + and back to the app. Tags select which tests run. - A **CI matrix** that expands applications, OS version tags, and suites into BrowserStack Maestro run rows, starting with a shared launch smoke. @@ -30,17 +30,33 @@ React Native Android: dev rn e2e android ``` -Run one or more focused React Native scenarios by passing scenario flags: +Both commands run every test in `tests/`. Narrow a run with `--tags`: ```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 +dev rn e2e ios --tags checkout +dev rn e2e ios --tags smoke +dev rn e2e android --tags cart,checkout ``` +Both options match **any** listed tag, because that is how Maestro filters. +`--tags cart,checkout` runs the cart tests and the checkout tests. `--exclude-tags` +skips tests carrying any listed tag and defaults to `flaky,wip`. + +### Tags + +Every test declares tags from this taxonomy. `e2e/test/maestro_test_tags_test.rb` +enforces it. + +| 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 | + +A platform tag marks a capability only one platform has, such as Apple Pay. It +must never mark a test that is merely not ported yet. + The React Native commands start Metro if needed, build and launch the target sample app, then run Maestro. They require the standard storefront `.env` setup, but the E2E flows seed their own carts through the bootstrap deep link. The @@ -119,7 +135,8 @@ 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. 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/test/maestro_test_tags_test.rb b/e2e/test/maestro_test_tags_test.rb new file mode 100644 index 000000000..c5eb89aca --- /dev/null +++ b/e2e/test/maestro_test_tags_test.rb @@ -0,0 +1,79 @@ +# 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 test_there_is_at_least_one_test_to_check + refute_empty(test_files) + end +end diff --git a/e2e/tests/react-native/checkout-guest.yaml b/e2e/tests/react-native/checkout-guest.yaml index 42b72b4ce..639540bee 100644 --- a/e2e/tests/react-native/checkout-guest.yaml +++ b/e2e/tests/react-native/checkout-guest.yaml @@ -1,5 +1,8 @@ appId: ${APP_ID} name: React Native checkout - guest +tags: + - checkout + - full env: # Sample app buyer identity configuration diff --git a/e2e/tests/react-native/checkout-hardcoded-buyer-identity.yaml b/e2e/tests/react-native/checkout-hardcoded-buyer-identity.yaml index 0e82f742a..c126dc48d 100644 --- a/e2e/tests/react-native/checkout-hardcoded-buyer-identity.yaml +++ b/e2e/tests/react-native/checkout-hardcoded-buyer-identity.yaml @@ -1,5 +1,8 @@ appId: ${APP_ID} name: React Native checkout - hardcoded buyer identity +tags: + - checkout + - smoke env: # Sample app buyer identity configuration 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/react-native/scripts/e2e_maestro_android b/platforms/react-native/scripts/e2e_maestro_android index 4b06704f7..6daa85df4 100755 --- a/platforms/react-native/scripts/e2e_maestro_android +++ b/platforms/react-native/scripts/e2e_maestro_android @@ -10,42 +10,47 @@ METRO_PID="" E2E_ENV_FILE="" APP_ID="com.shopify.checkoutkit.reactnativedemo" CART_BOOTSTRAP_BASE_LINK="${APP_ID}://e2e/cart?productIndex=0&quantity=1" -MAESTRO_FLOWS=() +READY_MARKER="checkout-kit-sample-ready" +INCLUDE_TAGS="" +EXCLUDE_TAGS="flaky,wip" 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. Defaults to flaky,wip. 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 +58,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" } @@ -126,10 +124,20 @@ ENVFILE="$E2E_ENV_FILE" pnpm sample android --extra-params "--refresh-dependenci ( 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 + MAESTRO_ARGS=(--platform android test --config config.yaml) + + if [ -n "$INCLUDE_TAGS" ]; then + MAESTRO_ARGS+=(--include-tags "$INCLUDE_TAGS") + fi + + if [ -n "$EXCLUDE_TAGS" ]; then + MAESTRO_ARGS+=(--exclude-tags "$EXCLUDE_TAGS") + fi + + "$MAESTRO" "${MAESTRO_ARGS[@]}" \ + -e "APP_ID=${APP_ID}" \ + -e "E2E_APP_ID=${APP_ID}" \ + -e "E2E_READY_MARKER=${READY_MARKER}" \ + -e "CART_BOOTSTRAP_BASE_LINK=${CART_BOOTSTRAP_BASE_LINK}" \ + tests ) diff --git a/platforms/react-native/scripts/e2e_maestro_ios b/platforms/react-native/scripts/e2e_maestro_ios index 2f39cb1ff..2016c0b44 100755 --- a/platforms/react-native/scripts/e2e_maestro_ios +++ b/platforms/react-native/scripts/e2e_maestro_ios @@ -10,42 +10,47 @@ METRO_PID="" E2E_ENV_FILE="" APP_ID="com.shopify.checkoutkit.reactnativedemo" CART_BOOTSTRAP_BASE_LINK="${APP_ID}://e2e/cart?productIndex=0&quantity=1" -MAESTRO_FLOWS=() +READY_MARKER="checkout-kit-sample-ready" +INCLUDE_TAGS="" +EXCLUDE_TAGS="flaky,wip" 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. Defaults to flaky,wip. 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 +58,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" } @@ -127,10 +125,20 @@ 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 + MAESTRO_ARGS=(--platform ios test --config config.yaml) + + if [ -n "$INCLUDE_TAGS" ]; then + MAESTRO_ARGS+=(--include-tags "$INCLUDE_TAGS") + fi + + if [ -n "$EXCLUDE_TAGS" ]; then + MAESTRO_ARGS+=(--exclude-tags "$EXCLUDE_TAGS") + fi + + "$MAESTRO" "${MAESTRO_ARGS[@]}" \ + -e "APP_ID=${APP_ID}" \ + -e "E2E_APP_ID=${APP_ID}" \ + -e "E2E_READY_MARKER=${READY_MARKER}" \ + -e "CART_BOOTSTRAP_BASE_LINK=${CART_BOOTSTRAP_BASE_LINK}" \ + tests ) From bb4aa6ad4852a0565039f850bc7f853e3644d422 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Thu, 30 Jul 2026 13:35:22 +0100 Subject: [PATCH 05/15] Replace the E2E suite cross product with Maestro tags in CI Every BrowserStack run now executes the whole e2e/tests folder and tags decide what runs inside it. Adding a test file adds no matrix rows: it only needs a tag the matrix already includes. That removes the suites x applications cross product, which grew four rows per new test. matrix.yml gains tests_path and a top-level tags block holding the default include and exclude lists. An application may override either list to adopt a test before the other three carry it. Deliberate deviation from the plan: the plan said add E2E_CONTROL_LINK per application in matrix.yml. The library derives it as "://e2e" instead, because the deep link scheme equals the app id on all four targets. A second copy would drift from app_id. A test pins the derivation for every row. Validation rejects an include tag no test carries. Without it a typo produced a green run that executed nothing. declared_tags reads the flow headers, so both the top-level and per-application lists are checked. Also fixes three real defects found while wiring this up: - RN Android could not receive the control link at all. Its manifest restricted the custom scheme to host "cart" while the contract uses host "e2e". Host "cart" was referenced nowhere else. - The parser tests on all three platforms pinned com.shopify.checkout_kit_android_demo, which no app uses. The Kotlin sample's applicationId is com.shopify.checkoutkit.androiddemo. They now pin the four real schemes plus one unknown scheme as an explicit robustness case. - CI and local runs disagreed on variable names. Both now use E2E_APP_ID, E2E_READY_MARKER and E2E_CONTROL_LINK, and the bootstrap flow composes /cart?... from E2E_CONTROL_LINK itself. The APP_ID and CART_BOOTSTRAP_BASE_LINK bridge variables are gone. The run result payload carries include_tags and exclude_tags so a run that selected nothing is visible in the artifact instead of silently green. Verified: 163 Ruby tests, 29 Swift, 28 Kotlin, 203 jest, dev check all 12 green. Co-Authored-By: Claude Opus 5 (1M context) --- e2e/README.md | 27 ++-- e2e/config/matrix.yml | 12 +- e2e/flows/app/bootstrap-cart-from-link.yaml | 6 +- e2e/flows/checkout/assert-complete.yaml | 2 +- .../assert-returned-to-empty-cart.yaml | 2 +- e2e/flows/checkout/dismiss-active-field.yaml | 2 +- e2e/flows/checkout/fill-contact.yaml | 2 +- e2e/flows/checkout/fill-payment-card.yaml | 2 +- e2e/flows/checkout/fill-shipping-address.yaml | 2 +- e2e/flows/checkout/present.yaml | 2 +- e2e/flows/checkout/submit.yaml | 2 +- .../e2e_matrix_to_browserstack_run_plan.rb | 119 ++++++++++++++---- e2e/scripts/execute_browserstack_run | 11 +- ...2e_matrix_to_browserstack_run_plan_test.rb | 82 ++++++++++++ e2e/tests/react-native/checkout-guest.yaml | 2 +- .../checkout-hardcoded-buyer-identity.yaml | 2 +- .../e2e/E2EControlLinkTest.kt | 16 ++- .../app/src/main/AndroidManifest.template.xml | 3 +- .../src/e2e/__tests__/controlLink.test.ts | 24 ++-- .../react-native/scripts/e2e_maestro_android | 5 +- .../react-native/scripts/e2e_maestro_ios | 5 +- .../E2E/E2EControlLinkTests.swift | 16 ++- 22 files changed, 273 insertions(+), 73 deletions(-) diff --git a/e2e/README.md b/e2e/README.md index 64d9da132..737698039 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -6,8 +6,9 @@ Kit sample apps. Two complementary setups live here: - A **local** React Native suite, run with `dev rn e2e`, that exercises guest and hardcoded buyer identity checkouts from seeded carts through Shopify checkout and back to the app. Tags select which tests run. -- A **CI matrix** that expands applications, OS version tags, and suites into - BrowserStack Maestro run rows, starting with a shared launch smoke. +- A **CI matrix** that expands applications and OS version tags into BrowserStack + Maestro run rows. Every row runs the whole `tests/` folder and tags select what + runs inside it. ## Run locally @@ -90,24 +91,30 @@ the React Native sample configuration, not local in-repo native SDK overrides. ## 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: @@ -144,7 +151,7 @@ ruby e2e/scripts/e2e_matrix_to_browserstack_run_plan count from a bootstrapped cart with hardcoded buyer identity. - `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. +- `tests/shared/launch-smoke.yaml` is the shared launch smoke test. ## Shared app contract diff --git a/e2e/config/matrix.yml b/e2e/config/matrix.yml index 2c697ae04..c216f659b 100644 --- a/e2e/config/matrix.yml +++ b/e2e/config/matrix.yml @@ -1,5 +1,14 @@ 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 + exclude: + - flaky + - wip applications: - id: react-native-ios target: react-native @@ -52,6 +61,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..439b69925 100644 --- a/e2e/flows/app/bootstrap-cart-from-link.yaml +++ b/e2e/flows/app/bootstrap-cart-from-link.yaml @@ -1,4 +1,4 @@ -appId: ${APP_ID} +appId: ${E2E_APP_ID} --- - launchApp: clearState: true @@ -8,12 +8,12 @@ appId: ${APP_ID} AppleLanguages: "(en)" - extendedWaitUntil: visible: - id: checkout-kit-sample-ready + id: ${E2E_READY_MARKER} timeout: 10000 # Reset app data before testing the deep-link cold-start path. The app is # stopped so openLink is the launch that seeds the cart. - stopApp -- openLink: "${CART_BOOTSTRAP_BASE_LINK}&buyerIdentityMode=${BUYER_IDENTITY_MODE}" +- openLink: "${E2E_CONTROL_LINK}/cart?productIndex=0&quantity=1&buyerIdentityMode=${BUYER_IDENTITY_MODE}" - waitForAnimationToEnd - extendedWaitUntil: visible: diff --git a/e2e/flows/checkout/assert-complete.yaml b/e2e/flows/checkout/assert-complete.yaml index 85015b417..3269468a0 100644 --- a/e2e/flows/checkout/assert-complete.yaml +++ b/e2e/flows/checkout/assert-complete.yaml @@ -1,4 +1,4 @@ -appId: ${APP_ID} +appId: ${E2E_APP_ID} --- - extendedWaitUntil: visible: "${POST_SUBMIT_RESULT_PATTERN}" diff --git a/e2e/flows/checkout/assert-returned-to-empty-cart.yaml b/e2e/flows/checkout/assert-returned-to-empty-cart.yaml index 6ef334dde..ba7f925ed 100644 --- a/e2e/flows/checkout/assert-returned-to-empty-cart.yaml +++ b/e2e/flows/checkout/assert-returned-to-empty-cart.yaml @@ -1,4 +1,4 @@ -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 diff --git a/e2e/flows/checkout/dismiss-active-field.yaml b/e2e/flows/checkout/dismiss-active-field.yaml index 4b672f5ef..9407fd75d 100644 --- a/e2e/flows/checkout/dismiss-active-field.yaml +++ b/e2e/flows/checkout/dismiss-active-field.yaml @@ -1,4 +1,4 @@ -appId: ${APP_ID} +appId: ${E2E_APP_ID} --- - runFlow: when: diff --git a/e2e/flows/checkout/fill-contact.yaml b/e2e/flows/checkout/fill-contact.yaml index 2fdaf65b2..3e0d6dde2 100644 --- a/e2e/flows/checkout/fill-contact.yaml +++ b/e2e/flows/checkout/fill-contact.yaml @@ -1,4 +1,4 @@ -appId: ${APP_ID} +appId: ${E2E_APP_ID} --- - tapOn: text: "^Email( or mobile phone number)?$" diff --git a/e2e/flows/checkout/fill-payment-card.yaml b/e2e/flows/checkout/fill-payment-card.yaml index a9d47ed95..bee1b29f8 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: diff --git a/e2e/flows/checkout/fill-shipping-address.yaml b/e2e/flows/checkout/fill-shipping-address.yaml index d06090553..3efa178a8 100644 --- a/e2e/flows/checkout/fill-shipping-address.yaml +++ b/e2e/flows/checkout/fill-shipping-address.yaml @@ -1,4 +1,4 @@ -appId: ${APP_ID} +appId: ${E2E_APP_ID} --- - scrollUntilVisible: element: 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..56e90d6b1 100644 --- a/e2e/flows/checkout/submit.yaml +++ b/e2e/flows/checkout/submit.yaml @@ -1,4 +1,4 @@ -appId: ${APP_ID} +appId: ${E2E_APP_ID} --- - scrollUntilVisible: element: diff --git a/e2e/lib/e2e_matrix_to_browserstack_run_plan.rb b/e2e/lib/e2e_matrix_to_browserstack_run_plan.rb index 35c955738..b8b2986f3 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" => tests_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,21 @@ def os_version_tags @config.fetch("os_version_tags", []) || [] end - def suites - @config.fetch("suites", []) || [] + def tests_path + @config.fetch("tests_path", "tests") + 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 +249,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 +303,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)) - 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? + errors << "tests_path is not a directory: #{tests_path}" + end + + # 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? + + unless tags.is_a?(Array) + errors << "tags #{kind} must be an array" + next + end - errors << "suite #{id} execute path does not exist: #{execute}" unless File.exist?(File.join(e2e_root, execute)) + 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..59c488422 100755 --- a/e2e/scripts/execute_browserstack_run +++ b/e2e/scripts/execute_browserstack_run @@ -147,9 +147,14 @@ class BrowserStackRunExecutor customBuildName: run.fetch("id"), devices: [device], execute: [run.fetch("execute")], + tags: { + includeTags: run.fetch("include_tags"), + excludeTags: run.fetch("exclude_tags") + }, 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") } } response = @client.start_build(run.fetch("platform"), body) @@ -205,6 +210,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 +235,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/test/e2e_matrix_to_browserstack_run_plan_test.rb b/e2e/test/e2e_matrix_to_browserstack_run_plan_test.rb index c9745642e..ca79f893e 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,88 @@ 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 + + def test_a_run_executes_the_whole_tests_folder + assert_equal "tests", run_for("swift-ios").fetch("execute") + end + + def test_a_run_carries_the_default_tags + run = run_for("swift-ios") + + assert_equal ["launch"], 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/tests/react-native/checkout-guest.yaml b/e2e/tests/react-native/checkout-guest.yaml index 639540bee..1d76f63cf 100644 --- a/e2e/tests/react-native/checkout-guest.yaml +++ b/e2e/tests/react-native/checkout-guest.yaml @@ -1,4 +1,4 @@ -appId: ${APP_ID} +appId: ${E2E_APP_ID} name: React Native checkout - guest tags: - checkout diff --git a/e2e/tests/react-native/checkout-hardcoded-buyer-identity.yaml b/e2e/tests/react-native/checkout-hardcoded-buyer-identity.yaml index c126dc48d..f02b721c5 100644 --- a/e2e/tests/react-native/checkout-hardcoded-buyer-identity.yaml +++ b/e2e/tests/react-native/checkout-hardcoded-buyer-identity.yaml @@ -1,4 +1,4 @@ -appId: ${APP_ID} +appId: ${E2E_APP_ID} name: React Native checkout - hardcoded buyer identity tags: - checkout 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 index 1cbe79421..d8f4aa253 100644 --- 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 @@ -15,9 +15,21 @@ class E2EControlLinkTest { @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", + ) - assertThat(E2EControlLink.parse("com.shopify.checkout_kit_android_demo://e2e/cart?productIndex=0")).isEqualTo(expected) - assertThat(E2EControlLink.parse("com.shopify.checkoutkit.swiftdemo://e2e/cart?productIndex=0")).isEqualTo(expected) + 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 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/e2e/__tests__/controlLink.test.ts b/platforms/react-native/sample/src/e2e/__tests__/controlLink.test.ts index 026771f2e..e91e1579e 100644 --- a/platforms/react-native/sample/src/e2e/__tests__/controlLink.test.ts +++ b/platforms/react-native/sample/src/e2e/__tests__/controlLink.test.ts @@ -20,17 +20,23 @@ describe('parseControlLink', () => { 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.shopify.checkoutkit.reactnativedemo://e2e/cart?productIndex=0', - ), - ).toEqual(expected); - expect( - parseControlLink( - 'com.shopify.checkout_kit_android_demo://e2e/cart?productIndex=0', - ), - ).toEqual(expected); + parseControlLink('com.example.anything://e2e/cart?productIndex=0'), + ).toEqual({command: 'cart', productIndex: 0, quantity: 1}); }); it('parses the reset command', () => { diff --git a/platforms/react-native/scripts/e2e_maestro_android b/platforms/react-native/scripts/e2e_maestro_android index 6daa85df4..69852b9e7 100755 --- a/platforms/react-native/scripts/e2e_maestro_android +++ b/platforms/react-native/scripts/e2e_maestro_android @@ -9,7 +9,7 @@ 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}://e2e/cart?productIndex=0&quantity=1" +CONTROL_LINK="${APP_ID}://e2e" READY_MARKER="checkout-kit-sample-ready" INCLUDE_TAGS="" EXCLUDE_TAGS="flaky,wip" @@ -135,9 +135,8 @@ ENVFILE="$E2E_ENV_FILE" pnpm sample android --extra-params "--refresh-dependenci fi "$MAESTRO" "${MAESTRO_ARGS[@]}" \ - -e "APP_ID=${APP_ID}" \ -e "E2E_APP_ID=${APP_ID}" \ -e "E2E_READY_MARKER=${READY_MARKER}" \ - -e "CART_BOOTSTRAP_BASE_LINK=${CART_BOOTSTRAP_BASE_LINK}" \ + -e "E2E_CONTROL_LINK=${CONTROL_LINK}" \ tests ) diff --git a/platforms/react-native/scripts/e2e_maestro_ios b/platforms/react-native/scripts/e2e_maestro_ios index 2016c0b44..076b21b13 100755 --- a/platforms/react-native/scripts/e2e_maestro_ios +++ b/platforms/react-native/scripts/e2e_maestro_ios @@ -9,7 +9,7 @@ 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}://e2e/cart?productIndex=0&quantity=1" +CONTROL_LINK="${APP_ID}://e2e" READY_MARKER="checkout-kit-sample-ready" INCLUDE_TAGS="" EXCLUDE_TAGS="flaky,wip" @@ -136,9 +136,8 @@ ENVFILE="$E2E_ENV_FILE" pnpm sample ios fi "$MAESTRO" "${MAESTRO_ARGS[@]}" \ - -e "APP_ID=${APP_ID}" \ -e "E2E_APP_ID=${APP_ID}" \ -e "E2E_READY_MARKER=${READY_MARKER}" \ - -e "CART_BOOTSTRAP_BASE_LINK=${CART_BOOTSTRAP_BASE_LINK}" \ + -e "E2E_CONTROL_LINK=${CONTROL_LINK}" \ tests ) diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControlLinkTests.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControlLinkTests.swift index 5e84c1820..3d0c2ba31 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControlLinkTests.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControlLinkTests.swift @@ -10,9 +10,21 @@ class E2EControlLinkTests: XCTestCase { 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.shopify.checkoutkit.swiftdemo://e2e/cart?productIndex=0"), expected) - XCTAssertEqual(try E2EControlLink.parse("com.shopify.checkout_kit_android_demo://e2e/cart?productIndex=0"), expected) + XCTAssertEqual(try E2EControlLink.parse("com.example.anything://e2e/cart?productIndex=0"), expected) } func testParsesTheResetCommand() throws { From e189505b28e040d37f73f27f1deb34d8431ba7ef Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Thu, 30 Jul 2026 16:35:27 +0100 Subject: [PATCH 06/15] feat(e2e): add the cart-from-control-link test on all four targets The first shared test that runs on Swift iOS, Kotlin Android, React Native iOS and React Native Android. It drives the `cart` control link and asserts the `cart-checkout-ready` marker, which appears only after the app parsed the link, resolved a variant, created a cart, added the line, and navigated. Adds `e2e/scripts/run_maestro`, the single Maestro invocation every local runner calls, plus `dev swift e2e` and `dev android e2e` so all four targets run locally. Verified locally: React Native iOS 23s, Swift iOS 23s, Kotlin Android 15s, React Native Android 19s. Three deliberate deviations from the plan: - `E2E_CONTROL_LINK` is derived as `://e2e` rather than configured per application. The scheme equals the app id on all four targets, so a second copy would drift from `app_id`. - Maestro runs the workspace root, not `tests`. Maestro resolves the `flows:` glob in `config.yaml` relative to the path on the command line, so `tests` would look for `tests/tests/**` and match nothing. `tests_path` remains for tag discovery. - The flow no longer stops the app before the link. `SceneDelegate` discards a cold-start URL and Android would need launch-intent handling, so one flow line replaces native work on two platforms. The account journey needs a running app anyway. Co-Authored-By: Claude Opus 5 (1M context) --- dev.yml | 11 ++ e2e/README.md | 118 ++++++++++-------- e2e/config/matrix.yml | 1 + e2e/flows/app/bootstrap-cart-from-link.yaml | 43 +++++-- .../e2e_matrix_to_browserstack_run_plan.rb | 10 +- e2e/scripts/run_maestro | 48 +++++++ ...2e_matrix_to_browserstack_run_plan_test.rb | 8 +- e2e/test/maestro_test_tags_test.rb | 27 ++++ e2e/tests/react-native/checkout-guest.yaml | 2 +- .../checkout-hardcoded-buyer-identity.yaml | 2 +- e2e/tests/shared/cart-from-control-link.yaml | 11 ++ platforms/android/scripts/e2e_maestro | 87 +++++++++++++ .../react-native/scripts/e2e_maestro_android | 54 ++++---- .../react-native/scripts/e2e_maestro_ios | 59 ++++----- platforms/swift/Scripts/e2e_maestro | 116 +++++++++++++++++ 15 files changed, 474 insertions(+), 123 deletions(-) create mode 100755 e2e/scripts/run_maestro create mode 100644 e2e/tests/shared/cart-from-control-link.yaml create mode 100755 platforms/android/scripts/e2e_maestro create mode 100755 platforms/swift/Scripts/e2e_maestro diff --git a/dev.yml b/dev.yml index bee1b24b7..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. diff --git a/e2e/README.md b/e2e/README.md index 737698039..bef98991d 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -3,45 +3,43 @@ This directory contains Maestro end-to-end flows and configuration for Checkout Kit sample apps. Two complementary setups live here: -- A **local** React Native suite, run with `dev rn e2e`, that exercises guest and - hardcoded buyer identity checkouts from seeded carts through Shopify checkout - and back to the app. Tags select which tests run. +- **Local** runs, one command per target, that build the sample app, install it on + the booted device, and run the tests in `tests/`. Tags select which tests run. - A **CI matrix** that expands applications and OS version tags into BrowserStack Maestro run rows. Every row runs the whole `tests/` folder and tags select what runs inside it. +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`) - -Run the matching command from the repo root. +### One command per target -React Native iOS: +Boot a simulator or emulator first, because Maestro drives the device the app runs +on. Then run the matching command from the repo root. -```bash -dev rn e2e ios -``` +| Target | Command | +|---|---| +| Swift iOS | `dev swift e2e` | +| Kotlin Android | `dev android e2e` | +| React Native iOS | `dev rn e2e ios` | +| React Native Android | `dev rn e2e android` | -React Native Android: - -```bash -dev rn e2e android -``` - -Both commands run every test in `tests/`. Narrow a run with `--tags`: +Each command runs every test in `tests/`. Narrow a run with `--tags`: ```bash +dev swift e2e --tags cart dev rn e2e ios --tags checkout -dev rn e2e ios --tags smoke -dev rn e2e android --tags cart,checkout +dev android e2e --tags cart,checkout ``` Both options match **any** listed tag, because that is how Maestro filters. `--tags cart,checkout` runs the cart tests and the checkout tests. `--exclude-tags` -skips tests carrying any listed tag and defaults to `flaky,wip`. +skips tests carrying any listed tag. `config.yaml` quarantines `flaky` and `wip` +for every run, so those need no command line option. ### Tags @@ -58,36 +56,31 @@ enforces it. A platform tag marks a capability only one platform has, such as Apple Pay. It must never mark a test that is merely not ported yet. -The React Native commands start Metro if needed, build and launch the target -sample app, then run Maestro. They require the standard storefront `.env` setup, -but the E2E flows seed their own carts through the bootstrap deep link. The -React Native bootstrap link accepts `buyerIdentityMode`, so guest and hardcoded -buyer identity scenarios share the same cart setup path. No manual sample cart -setup is required. +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. -### Shared launch smoke +React Native E2E runs should use the released native SDK artifacts declared by +the React Native sample configuration, not local in-repo native SDK overrides. -The launch smoke launches a sample app and waits for the shared ready marker -exposed by that app, using the same environment contract used by CI. +### The control link -React Native iOS: +The samples share one command channel: a deep link on the app's own scheme. -```bash -E2E_APP_ID=com.shopify.checkoutkit.reactnativedemo \ -E2E_READY_MARKER=checkout-kit-sample-ready \ -maestro --platform ios test e2e/tests/shared/launch-smoke.yaml ``` - -React Native Android: - -```bash -E2E_APP_ID=com.shopify.checkoutkit.reactnativedemo \ -E2E_READY_MARKER=checkout-kit-sample-ready \ -maestro --platform android test e2e/tests/shared/launch-smoke.yaml +://e2e/? ``` -React Native E2E runs should use the released native SDK artifacts declared by -the React Native sample configuration, not local in-repo native SDK overrides. +The scheme equals the app id on all four targets, so `scripts/run_maestro` derives +`E2E_CONTROL_LINK` rather than taking it as an argument. Commands are `reset`, +`cart` and `signIn`. Each sample parses the link in its own E2E folder and runs the +command through one `E2EController`, so sample code that merchants read holds a +single hook. + +The link goes to an app that already runs. A stopped app would need a second entry +point on every platform, because iOS delivers a cold-start URL through the scene +connection options and Android through the launch intent. ## Matrix @@ -145,28 +138,49 @@ ruby e2e/scripts/e2e_matrix_to_browserstack_run_plan count - `config.yaml` configures Maestro for shared platform behavior and quarantines the `flaky` and `wip` tags. - `flows/` contains reusable Maestro subflows for app setup and checkout steps. +- `tests/shared/` holds the tests every target runs through the CI matrix. +- `tests//` holds platform-local tests. The matrix may ignore their tags. +- `tests/shared/launch-smoke.yaml` is the shared launch smoke test. +- `tests/shared/cart-from-control-link.yaml` seeds a cart through the control link + and waits for the cart marker. - `tests/react-native/checkout-guest.yaml` composes the React Native guest checkout smoke test from those subflows. - `tests/react-native/checkout-hardcoded-buyer-identity.yaml` verifies checkout from a bootstrapped cart with hardcoded buyer identity. +- `scripts/run_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 test. + +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/matrix.yml b/e2e/config/matrix.yml index c216f659b..a551be192 100644 --- a/e2e/config/matrix.yml +++ b/e2e/config/matrix.yml @@ -6,6 +6,7 @@ tests_path: tests tags: include: - launch + - cart exclude: - flaky - wip diff --git a/e2e/flows/app/bootstrap-cart-from-link.yaml b/e2e/flows/app/bootstrap-cart-from-link.yaml index 439b69925..617625f40 100644 --- a/e2e/flows/app/bootstrap-cart-from-link.yaml +++ b/e2e/flows/app/bootstrap-cart-from-link.yaml @@ -6,16 +6,39 @@ appId: ${E2E_APP_ID} # 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: 10000 -# Reset app data before testing the deep-link cold-start path. The app is -# stopped so openLink is the launch that seeds the cart. -- stopApp -- openLink: "${E2E_CONTROL_LINK}/cart?productIndex=0&quantity=1&buyerIdentityMode=${BUYER_IDENTITY_MODE}" -- waitForAnimationToEnd -- extendedWaitUntil: - visible: - id: cart-checkout-ready - timeout: 10000 + timeout: 60000 +# The wait above can match a view hierarchy the dying process left behind, which sends the +# link seconds before the new activity exists. Android then drops it, because a deep link +# has no delivery guarantee. One repeat covers that, and the command is safe to repeat: +# every cart command resets the cart before it seeds one. +- retry: + maxRetries: 1 + commands: + # The link goes to the running app. launchApp cleared the state above, so the cart is + # already empty, and a running app is what the account journey needs later: iOS delivers + # a cold-start URL through the scene connect options and Android through the launch + # intent, so a stopped app would need a second entry point on every platform. + # Each test supplies E2E_CART_PARAMS, so tests that do not care about buyer + # identity leave the app's configured mode alone. + - openLink: "${E2E_CONTROL_LINK}/cart?${E2E_CART_PARAMS}" + - waitForAnimationToEnd + # iOS asks the user to confirm before it hands a custom scheme to an app. Android + # does not, so the tap is conditional rather than platform-gated. + - runFlow: + when: + visible: "Open" + commands: + - tapOn: "Open" + - waitForAnimationToEnd + # The cart marker only appears after the controller resolved a variant, created a + # cart, added the line, and navigated. Reaching it proves the whole command path. + # That is two network round trips, so the timeout covers a slow device on a slow shop. + - extendedWaitUntil: + visible: + id: cart-checkout-ready + timeout: 60000 diff --git a/e2e/lib/e2e_matrix_to_browserstack_run_plan.rb b/e2e/lib/e2e_matrix_to_browserstack_run_plan.rb index b8b2986f3..738fe6609 100644 --- a/e2e/lib/e2e_matrix_to_browserstack_run_plan.rb +++ b/e2e/lib/e2e_matrix_to_browserstack_run_plan.rb @@ -128,7 +128,7 @@ def build_run(application, os_version_tag) "app_id" => app_id, "control_link" => control_link(app_id), "artifact_env" => application.fetch("artifact_env"), - "execute" => tests_path, + "execute" => workspace_path, "include_tags" => application_tags(application, "include"), "exclude_tags" => application_tags(application, "exclude"), "ready_marker" => application.fetch("ready_marker"), @@ -206,6 +206,14 @@ 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 diff --git a/e2e/scripts/run_maestro b/e2e/scripts/run_maestro new file mode 100755 index 000000000..93a138ee0 --- /dev/null +++ b/e2e/scripts/run_maestro @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# +# Runs the shared Maestro suite for one platform and one sample app. +# +# Usage: run_maestro [include_tags] [exclude_tags] +# +# Every platform runner calls this script, so the environment contract has exactly one +# definition. Maestro exits non-zero when a tag or the flows glob matches nothing. +set -euo pipefail + +PLATFORM="${1:?platform (ios|android) is required}" +APP_ID="${2:?app id is required}" +READY_MARKER="${3:?ready marker is required}" +INCLUDE_TAGS="${4:-}" +EXCLUDE_TAGS="${5:-}" + +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" + +MAESTRO_ARGS=(--platform "$PLATFORM" 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}" \ + . 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 ca79f893e..5b67a9d62 100644 --- a/e2e/test/e2e_matrix_to_browserstack_run_plan_test.rb +++ b/e2e/test/e2e_matrix_to_browserstack_run_plan_test.rb @@ -32,14 +32,16 @@ def test_expand_produces_one_run_per_application_and_os_version_tag ) end - def test_a_run_executes_the_whole_tests_folder - assert_equal "tests", run_for("swift-ios").fetch("execute") + # 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"], run.fetch("include_tags") + assert_equal ["launch", "cart"], run.fetch("include_tags") assert_equal ["flaky", "wip"], run.fetch("exclude_tags") end diff --git a/e2e/test/maestro_test_tags_test.rb b/e2e/test/maestro_test_tags_test.rb index c5eb89aca..1f9197c2f 100644 --- a/e2e/test/maestro_test_tags_test.rb +++ b/e2e/test/maestro_test_tags_test.rb @@ -73,7 +73,34 @@ def test_the_workspace_config_quarantines_the_quarantine_tags assert_equal(QUARANTINE_TAGS.sort, (config["excludeTags"] || []).sort) end + def shared_test_files + test_files.select { |path| path.start_with?("tests/shared/") } + end + + def matrix + YAML.safe_load_file(File.join(E2E_ROOT, "config", "matrix.yml"), aliases: true) + end + + # Tests under tests/shared/ run on every target through the CI matrix. Tests under + # tests// are local-only, so the matrix is free to ignore their tags. + def matrix_include_tags + defaults = matrix.fetch("tags", {}).fetch("include", []) + + matrix.fetch("applications", []).flat_map { |application| application.fetch("include_tags", defaults) }.uniq + end + + def test_the_matrix_selects_every_journey_a_shared_test_declares + declared = shared_test_files.flat_map { |path| tags(path) & JOURNEY_TAGS }.uniq + unselected = declared - matrix_include_tags + + assert_empty( + unselected, + "config/matrix.yml includes no application for #{unselected.inspect}, so those shared tests never run in CI" + ) + end + def test_there_is_at_least_one_test_to_check refute_empty(test_files) + refute_empty(shared_test_files) end end diff --git a/e2e/tests/react-native/checkout-guest.yaml b/e2e/tests/react-native/checkout-guest.yaml index 1d76f63cf..d98b3e055 100644 --- a/e2e/tests/react-native/checkout-guest.yaml +++ b/e2e/tests/react-native/checkout-guest.yaml @@ -6,7 +6,7 @@ tags: env: # Sample app buyer identity configuration - BUYER_IDENTITY_MODE: "guest" + E2E_CART_PARAMS: "productIndex=0&quantity=1&buyerIdentityMode=guest" # Checkout contact fixture EMAIL: "maestro.e2e@shopify.com" diff --git a/e2e/tests/react-native/checkout-hardcoded-buyer-identity.yaml b/e2e/tests/react-native/checkout-hardcoded-buyer-identity.yaml index f02b721c5..386d21710 100644 --- a/e2e/tests/react-native/checkout-hardcoded-buyer-identity.yaml +++ b/e2e/tests/react-native/checkout-hardcoded-buyer-identity.yaml @@ -6,7 +6,7 @@ tags: env: # Sample app buyer identity configuration - BUYER_IDENTITY_MODE: "hardcoded" + E2E_CART_PARAMS: "productIndex=0&quantity=1&buyerIdentityMode=hardcoded" # Checkout payment fixture CARD_NUMBER: "1" diff --git a/e2e/tests/shared/cart-from-control-link.yaml b/e2e/tests/shared/cart-from-control-link.yaml new file mode 100644 index 000000000..6f59c6534 --- /dev/null +++ b/e2e/tests/shared/cart-from-control-link.yaml @@ -0,0 +1,11 @@ +appId: ${E2E_APP_ID} +name: Cart from control link +tags: + - cart + - smoke + +env: + # No buyerIdentityMode: this test asserts cart seeding, not buyer identity. + E2E_CART_PARAMS: "productIndex=0&quantity=1" +--- +- runFlow: ../../flows/app/bootstrap-cart-from-link.yaml diff --git a/platforms/android/scripts/e2e_maestro b/platforms/android/scripts/e2e_maestro new file mode 100755 index 000000000..2ffa70a2c --- /dev/null +++ b/platforms/android/scripts/e2e_maestro @@ -0,0 +1,87 @@ +#!/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 +} + +require_emulator() { + if ! adb devices | grep -q "[[:space:]]device$"; then + echo "No running Android device or emulator found." >&2 + echo "Start an emulator first, because Maestro drives the device the app runs on." >&2 + return 1 + fi +} + +require_maestro +require_emulator + +"$SAMPLE_DIR/gradlew" -p "$SAMPLE_DIR" :app:installDebug --console=plain + +"$REPO_ROOT/e2e/scripts/run_maestro" android "$APP_ID" "$READY_MARKER" "$INCLUDE_TAGS" "$EXCLUDE_TAGS" diff --git a/platforms/react-native/scripts/e2e_maestro_android b/platforms/react-native/scripts/e2e_maestro_android index 69852b9e7..092cba4a3 100755 --- a/platforms/react-native/scripts/e2e_maestro_android +++ b/platforms/react-native/scripts/e2e_maestro_android @@ -9,10 +9,11 @@ METRO_LOG="${TMPDIR:-/tmp}/checkout-kit-rn-android-metro.log" METRO_PID="" E2E_ENV_FILE="" APP_ID="com.shopify.checkoutkit.reactnativedemo" -CONTROL_LINK="${APP_ID}://e2e" READY_MARKER="checkout-kit-sample-ready" INCLUDE_TAGS="" -EXCLUDE_TAGS="flaky,wip" +# 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 @@ -25,7 +26,7 @@ and platform capability (ios-only, android-only). Options: --tags TAG[,TAG...] Run only tests carrying any of these tags. - --exclude-tags TAG[,TAG...] Skip tests carrying any of these tags. Defaults to flaky,wip. + --exclude-tags TAG[,TAG...] Skip tests carrying any of these tags. EOF } @@ -102,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="$!" @@ -120,23 +136,7 @@ fi wait_for_metro prepare_e2e_env -ENVFILE="$E2E_ENV_FILE" pnpm sample android --extra-params "--refresh-dependencies" - -( - cd "$REPO_ROOT/e2e" - MAESTRO_ARGS=(--platform android test --config config.yaml) - - if [ -n "$INCLUDE_TAGS" ]; then - MAESTRO_ARGS+=(--include-tags "$INCLUDE_TAGS") - fi - - if [ -n "$EXCLUDE_TAGS" ]; then - MAESTRO_ARGS+=(--exclude-tags "$EXCLUDE_TAGS") - fi +ENVFILE="$E2E_ENV_FILE" pnpm sample android --device "$DEVICE_ID" --extra-params "--refresh-dependencies" - "$MAESTRO" "${MAESTRO_ARGS[@]}" \ - -e "E2E_APP_ID=${APP_ID}" \ - -e "E2E_READY_MARKER=${READY_MARKER}" \ - -e "E2E_CONTROL_LINK=${CONTROL_LINK}" \ - tests -) +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 076b21b13..f74d3b282 100755 --- a/platforms/react-native/scripts/e2e_maestro_ios +++ b/platforms/react-native/scripts/e2e_maestro_ios @@ -9,10 +9,11 @@ METRO_LOG="${TMPDIR:-/tmp}/checkout-kit-rn-ios-metro.log" METRO_PID="" E2E_ENV_FILE="" APP_ID="com.shopify.checkoutkit.reactnativedemo" -CONTROL_LINK="${APP_ID}://e2e" READY_MARKER="checkout-kit-sample-ready" INCLUDE_TAGS="" -EXCLUDE_TAGS="flaky,wip" +# 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 @@ -25,7 +26,7 @@ and platform capability (ios-only, android-only). Options: --tags TAG[,TAG...] Run only tests carrying any of these tags. - --exclude-tags TAG[,TAG...] Skip tests carrying any of these tags. Defaults to flaky,wip. + --exclude-tags TAG[,TAG...] Skip tests carrying any of these tags. EOF } @@ -102,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="$!" @@ -121,23 +140,7 @@ fi wait_for_metro prepare_e2e_env -ENVFILE="$E2E_ENV_FILE" pnpm sample ios - -( - cd "$REPO_ROOT/e2e" - MAESTRO_ARGS=(--platform ios test --config config.yaml) - - if [ -n "$INCLUDE_TAGS" ]; then - MAESTRO_ARGS+=(--include-tags "$INCLUDE_TAGS") - fi - - if [ -n "$EXCLUDE_TAGS" ]; then - MAESTRO_ARGS+=(--exclude-tags "$EXCLUDE_TAGS") - fi +ENVFILE="$E2E_ENV_FILE" pnpm sample ios --udid "$SIMULATOR_ID" - "$MAESTRO" "${MAESTRO_ARGS[@]}" \ - -e "E2E_APP_ID=${APP_ID}" \ - -e "E2E_READY_MARKER=${READY_MARKER}" \ - -e "E2E_CONTROL_LINK=${CONTROL_LINK}" \ - tests -) +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/Scripts/e2e_maestro b/platforms/swift/Scripts/e2e_maestro new file mode 100755 index 000000000..0ee23c493 --- /dev/null +++ b/platforms/swift/Scripts/e2e_maestro @@ -0,0 +1,116 @@ +#!/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=NO + +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" + +"$REPO_ROOT/e2e/scripts/run_maestro" ios "$APP_ID" "$READY_MARKER" "$INCLUDE_TAGS" "$EXCLUDE_TAGS" From 3751176aeb0b75dfde1bb9cdf29dd8bfb86aecfa Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Thu, 30 Jul 2026 18:46:51 +0100 Subject: [PATCH 07/15] feat(e2e): add the present-and-close checkout test on all four targets Second test of the rollout. It seeds a cart through the control link, presents checkout, closes it, and asserts the sheet is gone. Swift iOS, Kotlin Android, React Native iOS and React Native Android all run it. Assert on the close control, not on checkout copy: the React Native catalog sells a product called "Email", which the checkout field pattern matches, so copy-based assertions pass on the wrong screen. Extract flows/checkout/close.yaml so the close identifier, which differs by SDK version rather than by platform, has one home. The React Native order flow now calls it instead of carrying its own platform split. The four targets return to different screens after the sheet closes, so this test asserts dismissal only. The tests that place an order assert the cart. Add the checkout tag to the CI matrix and exclude full, because the remaining full tests place a real order and live under tests/react-native/. Pass the installed device to Maestro from both native runners. Maestro otherwise picks a device itself, and it can pick one the app is missing from. Co-Authored-By: Claude Opus 5 (1M context) --- e2e/config/matrix.yml | 5 ++++ e2e/flows/checkout/assert-dismissed.yaml | 14 ++++++++++ .../assert-returned-to-empty-cart.yaml | 16 +---------- e2e/flows/checkout/close.yaml | 27 +++++++++++++++++++ e2e/scripts/run_maestro | 12 ++++++++- ...2e_matrix_to_browserstack_run_plan_test.rb | 4 +-- .../checkout-hardcoded-buyer-identity.yaml | 3 ++- .../shared/checkout-present-and-close.yaml | 16 +++++++++++ platforms/android/scripts/e2e_maestro | 21 +++++++++------ platforms/swift/Scripts/e2e_maestro | 3 ++- 10 files changed, 93 insertions(+), 28 deletions(-) create mode 100644 e2e/flows/checkout/assert-dismissed.yaml create mode 100644 e2e/flows/checkout/close.yaml create mode 100644 e2e/tests/shared/checkout-present-and-close.yaml diff --git a/e2e/config/matrix.yml b/e2e/config/matrix.yml index a551be192..4053c22a4 100644 --- a/e2e/config/matrix.yml +++ b/e2e/config/matrix.yml @@ -7,9 +7,14 @@ tags: include: - launch - cart + - checkout exclude: - flaky - wip + # The remaining `full` tests place a real order and live under tests/react-native/, + # so they cannot run on the Swift and Kotlin rows. Drop this entry when the shared + # ordering tests replace them. + - full applications: - id: react-native-ios target: react-native 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 ba7f925ed..34ef280a4 100644 --- a/e2e/flows/checkout/assert-returned-to-empty-cart.yaml +++ b/e2e/flows/checkout/assert-returned-to-empty-cart.yaml @@ -1,20 +1,6 @@ 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 - extendedWaitUntil: visible: 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/scripts/run_maestro b/e2e/scripts/run_maestro index 93a138ee0..b3d19513c 100755 --- a/e2e/scripts/run_maestro +++ b/e2e/scripts/run_maestro @@ -6,6 +6,9 @@ # # 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}" @@ -13,6 +16,7 @@ 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)" @@ -26,7 +30,13 @@ MAESTRO="$("$E2E_ROOT/scripts/maestro_bin")" # than passed. e2e/lib/e2e_matrix_to_browserstack_run_plan.rb derives it the same way for CI. CONTROL_LINK="${APP_ID}://e2e" -MAESTRO_ARGS=(--platform "$PLATFORM" test --config config.yaml) +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") 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 5b67a9d62..b37a8c13b 100644 --- a/e2e/test/e2e_matrix_to_browserstack_run_plan_test.rb +++ b/e2e/test/e2e_matrix_to_browserstack_run_plan_test.rb @@ -41,8 +41,8 @@ def test_a_run_executes_the_workspace_root_so_the_config_glob_resolves def test_a_run_carries_the_default_tags run = run_for("swift-ios") - assert_equal ["launch", "cart"], run.fetch("include_tags") - assert_equal ["flaky", "wip"], run.fetch("exclude_tags") + assert_equal ["launch", "cart", "checkout"], run.fetch("include_tags") + assert_equal ["flaky", "wip", "full"], run.fetch("exclude_tags") end def test_an_application_overrides_the_default_tags diff --git a/e2e/tests/react-native/checkout-hardcoded-buyer-identity.yaml b/e2e/tests/react-native/checkout-hardcoded-buyer-identity.yaml index 386d21710..47215670e 100644 --- a/e2e/tests/react-native/checkout-hardcoded-buyer-identity.yaml +++ b/e2e/tests/react-native/checkout-hardcoded-buyer-identity.yaml @@ -2,7 +2,8 @@ appId: ${E2E_APP_ID} name: React Native checkout - hardcoded buyer identity tags: - checkout - - smoke + # This test submits a real order, so it belongs to the expensive tier. + - full env: # Sample app buyer identity configuration 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/platforms/android/scripts/e2e_maestro b/platforms/android/scripts/e2e_maestro index 2ffa70a2c..7a27701f9 100755 --- a/platforms/android/scripts/e2e_maestro +++ b/platforms/android/scripts/e2e_maestro @@ -71,17 +71,22 @@ require_maestro() { fi } -require_emulator() { - if ! adb devices | grep -q "[[:space:]]device$"; then - echo "No running Android device or emulator found." >&2 - echo "Start an emulator first, because Maestro drives the device the app runs on." >&2 - return 1 - fi +attached_device() { + adb devices | awk '$2 == "device" { print $1; exit }' } require_maestro -require_emulator + +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 -"$REPO_ROOT/e2e/scripts/run_maestro" android "$APP_ID" "$READY_MARKER" "$INCLUDE_TAGS" "$EXCLUDE_TAGS" +E2E_DEVICE_ID="$DEVICE_ID" \ + "$REPO_ROOT/e2e/scripts/run_maestro" android "$APP_ID" "$READY_MARKER" "$INCLUDE_TAGS" "$EXCLUDE_TAGS" diff --git a/platforms/swift/Scripts/e2e_maestro b/platforms/swift/Scripts/e2e_maestro index 0ee23c493..456de3cbb 100755 --- a/platforms/swift/Scripts/e2e_maestro +++ b/platforms/swift/Scripts/e2e_maestro @@ -113,4 +113,5 @@ fi xcrun simctl install "$SIMULATOR_ID" "$APP_PATH" -"$REPO_ROOT/e2e/scripts/run_maestro" ios "$APP_ID" "$READY_MARKER" "$INCLUDE_TAGS" "$EXCLUDE_TAGS" +E2E_DEVICE_ID="$SIMULATOR_ID" \ + "$REPO_ROOT/e2e/scripts/run_maestro" ios "$APP_ID" "$READY_MARKER" "$INCLUDE_TAGS" "$EXCLUDE_TAGS" From a78a1ea62824bf8fa13d0c521d3853754428a3e1 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Thu, 30 Jul 2026 21:27:43 +0100 Subject: [PATCH 08/15] feat(e2e): add the hardcoded buyer identity order test on all four targets Row 3 of the test rollout. The cart carries the contact details and the delivery address, so checkout asks only for payment. That makes this the shortest real order, and the first test to look at when checkout breaks. The test moves from tests/react-native/ to tests/shared/, so Swift iOS, Kotlin Android, React Native iOS and React Native Android all run it. The matrix drops the `full` exclude entry that kept the ordering tests off the native rows. Android supplied only email, phone and country in its cart, while Swift and React Native both supplied a delivery address. Checkout therefore showed an empty Delivery form on Android only. CartInput.delivery now carries a pre-selected one-time address there too, which matches Swift. The address fields come from the same .env values the other samples read. Two Maestro details drive the flow changes: - A `visibilityPercentage` below 100 normalizes to zero, so `scrollUntilVisible` stops without scrolling. `centerElement: true` replaces it. A tap that lands on the keyboard does nothing, and Maestro still reports it as completed. - Android `hideKeyboard` issues a Back press when no keyboard is up, which closes the checkout sheet. dismiss-active-field.yaml therefore only ever follows an inputText. Verified green on all four targets, plus dev check, the Ruby suite, the Swift package and sample targets, the Android library and sample tests, the React Native JS and iOS tests, web and protocol. Co-Authored-By: Claude Opus 5 (1M context) --- e2e/config/matrix.yml | 4 -- e2e/flows/checkout/assert-complete.yaml | 4 +- .../assert-returned-to-empty-cart.yaml | 8 ++- e2e/flows/checkout/dismiss-active-field.yaml | 9 +++ e2e/flows/checkout/fill-billing-address.yaml | 38 +++++++++++ e2e/flows/checkout/fill-payment-card.yaml | 8 ++- e2e/flows/checkout/submit.yaml | 26 ++++++- ...2e_matrix_to_browserstack_run_plan_test.rb | 2 +- e2e/tests/react-native/checkout-guest.yaml | 3 + .../checkout-hardcoded-buyer-identity.yaml | 24 ------- .../checkout-hardcoded-buyer-identity.yaml | 39 +++++++++++ .../CheckoutKitAndroidDemo/app/build.gradle | 18 +++++ .../CheckoutKitApp.kt | 7 +- .../cart/CartView.kt | 1 + .../cart/data/CartRepository.kt | 67 +++++++++++++------ .../cart/data/DemoBuyerIdentity.kt | 35 +++++++++- .../e2e/E2ETestIds.kt | 5 ++ .../cart/data/CartRepositoryTest.kt | 38 +++++++++++ .../cart/data/DemoBuyerIdentityTest.kt | 44 ++++++++++++ .../e2e/E2ETestIdsTest.kt | 6 ++ .../Sources/App/SceneDelegate.swift | 1 + .../Sources/E2E/E2ETestIds.swift | 5 ++ .../Sources/Scenes/Cart/CartView.swift | 1 + .../E2E/E2ETestIdsTests.swift | 5 ++ 24 files changed, 339 insertions(+), 59 deletions(-) create mode 100644 e2e/flows/checkout/fill-billing-address.yaml delete mode 100644 e2e/tests/react-native/checkout-hardcoded-buyer-identity.yaml create mode 100644 e2e/tests/shared/checkout-hardcoded-buyer-identity.yaml create mode 100644 platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkout_kit_android_demo/cart/data/CartRepositoryTest.kt create mode 100644 platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkout_kit_android_demo/cart/data/DemoBuyerIdentityTest.kt diff --git a/e2e/config/matrix.yml b/e2e/config/matrix.yml index 4053c22a4..090e1519c 100644 --- a/e2e/config/matrix.yml +++ b/e2e/config/matrix.yml @@ -11,10 +11,6 @@ tags: exclude: - flaky - wip - # The remaining `full` tests place a real order and live under tests/react-native/, - # so they cannot run on the Swift and Kotlin rows. Drop this entry when the shared - # ordering tests replace them. - - full applications: - id: react-native-ios target: react-native diff --git a/e2e/flows/checkout/assert-complete.yaml b/e2e/flows/checkout/assert-complete.yaml index 3269468a0..b91f2028b 100644 --- a/e2e/flows/checkout/assert-complete.yaml +++ b/e2e/flows/checkout/assert-complete.yaml @@ -1,5 +1,7 @@ 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-returned-to-empty-cart.yaml b/e2e/flows/checkout/assert-returned-to-empty-cart.yaml index 34ef280a4..c5005b9ea 100644 --- a/e2e/flows/checkout/assert-returned-to-empty-cart.yaml +++ b/e2e/flows/checkout/assert-returned-to-empty-cart.yaml @@ -2,10 +2,14 @@ appId: ${E2E_APP_ID} --- - 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/dismiss-active-field.yaml b/e2e/flows/checkout/dismiss-active-field.yaml index 9407fd75d..40b8c6f90 100644 --- a/e2e/flows/checkout/dismiss-active-field.yaml +++ b/e2e/flows/checkout/dismiss-active-field.yaml @@ -5,6 +5,15 @@ appId: ${E2E_APP_ID} 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..29678ca51 --- /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$" + - 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: "^Postal code$" + - inputText: "${BILLING_POSTAL_CODE}" + - runFlow: dismiss-active-field.yaml + - waitForAnimationToEnd diff --git a/e2e/flows/checkout/fill-payment-card.yaml b/e2e/flows/checkout/fill-payment-card.yaml index bee1b29f8..e27ccc940 100644 --- a/e2e/flows/checkout/fill-payment-card.yaml +++ b/e2e/flows/checkout/fill-payment-card.yaml @@ -9,8 +9,9 @@ appId: ${E2E_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,9 @@ appId: ${E2E_APP_ID} - waitForAnimationToEnd - inputText: "${CARD_SECURITY_CODE}" - runFlow: dismiss-active-field.yaml +- tapOn: + text: "^Name on card$" +- waitForAnimationToEnd +- inputText: "${CARD_HOLDER_NAME}" +- runFlow: dismiss-active-field.yaml - waitForAnimationToEnd diff --git a/e2e/flows/checkout/submit.yaml b/e2e/flows/checkout/submit.yaml index 56e90d6b1..0b1472453 100644 --- a/e2e/flows/checkout/submit.yaml +++ b/e2e/flows/checkout/submit.yaml @@ -1,10 +1,32 @@ 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/test/e2e_matrix_to_browserstack_run_plan_test.rb b/e2e/test/e2e_matrix_to_browserstack_run_plan_test.rb index b37a8c13b..d4d98a0b6 100644 --- a/e2e/test/e2e_matrix_to_browserstack_run_plan_test.rb +++ b/e2e/test/e2e_matrix_to_browserstack_run_plan_test.rb @@ -42,7 +42,7 @@ def test_a_run_carries_the_default_tags run = run_for("swift-ios") assert_equal ["launch", "cart", "checkout"], run.fetch("include_tags") - assert_equal ["flaky", "wip", "full"], run.fetch("exclude_tags") + assert_equal ["flaky", "wip"], run.fetch("exclude_tags") end def test_an_application_overrides_the_default_tags diff --git a/e2e/tests/react-native/checkout-guest.yaml b/e2e/tests/react-native/checkout-guest.yaml index d98b3e055..958dfcafc 100644 --- a/e2e/tests/react-native/checkout-guest.yaml +++ b/e2e/tests/react-native/checkout-guest.yaml @@ -3,6 +3,9 @@ name: React Native checkout - guest tags: - checkout - full + # Only React Native runs this today, and every row now runs the whole tests folder. + # The shared guest test replaces this file, and this tag goes with it. + - wip env: # Sample app buyer identity configuration 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 47215670e..000000000 --- a/e2e/tests/react-native/checkout-hardcoded-buyer-identity.yaml +++ /dev/null @@ -1,24 +0,0 @@ -appId: ${E2E_APP_ID} -name: React Native checkout - hardcoded buyer identity -tags: - - checkout - # This test submits a real order, so it belongs to the expensive tier. - - full - -env: - # Sample app buyer identity configuration - E2E_CART_PARAMS: "productIndex=0&quantity=1&buyerIdentityMode=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/checkout-hardcoded-buyer-identity.yaml b/e2e/tests/shared/checkout-hardcoded-buyer-identity.yaml new file mode 100644 index 000000000..c21b019fa --- /dev/null +++ b/e2e/tests/shared/checkout-hardcoded-buyer-identity.yaml @@ -0,0 +1,39 @@ +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 its credit card gateway in test mode, which + # accepts this number only. Checkout groups the digits, so the flow needs both forms. + CARD_NUMBER: "4242424242424242" + CARD_NUMBER_DISPLAY: "4242 4242 4242 4242" + 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 province keep the shop defaults, so this address stays in Canada. + BILLING_FIRST_NAME: "Maestro" + BILLING_LAST_NAME: "Shopify" + BILLING_ADDRESS_LINE1: "620 King Street West" + BILLING_CITY: "Toronto" + BILLING_POSTAL_CODE: "M5V 1M7" + + # 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/platforms/android/samples/CheckoutKitAndroidDemo/app/build.gradle b/platforms/android/samples/CheckoutKitAndroidDemo/app/build.gradle index 76fefc336..e72459b84 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/build.gradle +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/build.gradle @@ -54,6 +54,15 @@ 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 prefillFirstName = propertyOrDefault("FIRST_NAME", "Evelyn") +def prefillLastName = propertyOrDefault("LAST_NAME", "Hartley") +def prefillAddress1 = propertyOrDefault("ADDRESS_1", "650 King Street") +def prefillAddress2 = propertyOrDefault("ADDRESS_2", "Shopify HQ") +def prefillCompany = propertyOrDefault("COMPANY", "Shopify") +def prefillCity = propertyOrDefault("CITY", "Toronto") +def prefillProvince = propertyOrDefault("PROVINCE", "ON") +def prefillZip = propertyOrDefault("ZIP", "M5V 1M7") +def prefillCountry = propertyOrDefault("COUNTRY", "CA") if (!storefrontDomain || !accessToken) { println("**** Please add a .env file with STOREFRONT_DOMAIN and STOREFRONT_ACCESS_TOKEN set *****") @@ -83,6 +92,15 @@ android { buildConfigField "String", "customerAccountApiGraphQLBaseUrl", "\"$customerAccountApiGraphQLBaseUrl\"" 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/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 4753a02ce..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 @@ -142,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/cart/CartView.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/cart/CartView.kt index 268f3bdb0..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 @@ -275,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/E2ETestIds.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/e2e/E2ETestIds.kt index 2ef583316..a80fa361f 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 @@ -6,5 +6,10 @@ object E2ETestIds { 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" } } 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/E2ETestIdsTest.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkout_kit_android_demo/e2e/E2ETestIdsTest.kt index 75d466835..fe092dbed 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 @@ -13,5 +13,11 @@ class E2ETestIdsTest { 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") } } diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/SceneDelegate.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/SceneDelegate.swift index 1ffa1754f..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 diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ETestIds.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ETestIds.swift index 5f3a559ce..f591a5954 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ETestIds.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ETestIds.swift @@ -4,5 +4,10 @@ enum E2ETestIds { 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" } } 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 7d25a39ef..cad515071 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartView.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartView.swift @@ -175,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/CheckoutKitSwiftDemoTests/E2E/E2ETestIdsTests.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2ETestIdsTests.swift index ce363412b..f8a8184a1 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2ETestIdsTests.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2ETestIdsTests.swift @@ -9,5 +9,10 @@ class E2ETestIdsTests: XCTestCase { 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") } } From 27326cc98151ffd9e47647b9dc59091b993a64b5 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Fri, 31 Jul 2026 11:37:57 +0100 Subject: [PATCH 09/15] feat(e2e): add the guest checkout order test on all four targets Moves the guest checkout test out of tests/react-native and into tests/shared, so Swift, Kotlin, React Native iOS and React Native Android all run it. The old file carried a wip tag to keep it out of the matrix; both go away together. Three flow fixes came out of running it: - fill-shipping-address.yaml asked for visibilityPercentage 10 in four places. A value under 100 rounds down to zero, so those scrolls stopped before they moved. centerElement replaces all four. - The shop requires a phone number on a delivery address, and no flow filled it. The field only appears when a shop asks for it, so the new step is optional. - Checkout copies the delivery name into "Name on card", and inputText appends to it. fill-payment-card.yaml now erases the field first. Adds e2e/FLAKES.md, which records every unstable test and every trap that gives a false verdict, so we clear them before the suite ships. Co-Authored-By: Claude Opus 5 (1M context) Assisted-By: devx/5b4b8d5b-690a-4389-841e-039723ade1da --- e2e/FLAKES.md | 41 +++++++++++++ e2e/README.md | 10 ++-- e2e/flows/checkout/fill-payment-card.yaml | 3 + e2e/flows/checkout/fill-shipping-address.yaml | 35 +++++++++-- e2e/tests/react-native/checkout-guest.yaml | 42 ------------- e2e/tests/shared/checkout-guest.yaml | 59 +++++++++++++++++++ 6 files changed, 140 insertions(+), 50 deletions(-) create mode 100644 e2e/FLAKES.md delete mode 100644 e2e/tests/react-native/checkout-guest.yaml create mode 100644 e2e/tests/shared/checkout-guest.yaml diff --git a/e2e/FLAKES.md b/e2e/FLAKES.md new file mode 100644 index 000000000..67569a713 --- /dev/null +++ b/e2e/FLAKES.md @@ -0,0 +1,41 @@ +# 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 after 2 seconds. The driver answers `viewHierarchy` with HTTP 500 and `kAXErrorInvalidUIElement`. | 2026-07-31, 1 run of 2 | 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. | + +## 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. + +## 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 bef98991d..3bb705fe6 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -143,10 +143,12 @@ ruby e2e/scripts/e2e_matrix_to_browserstack_run_plan count - `tests/shared/launch-smoke.yaml` is the shared launch smoke test. - `tests/shared/cart-from-control-link.yaml` seeds a cart through the control link and waits for the cart marker. -- `tests/react-native/checkout-guest.yaml` composes the React Native guest - checkout smoke test from those subflows. -- `tests/react-native/checkout-hardcoded-buyer-identity.yaml` verifies checkout - from a bootstrapped cart with hardcoded buyer identity. +- `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. +- `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 diff --git a/e2e/flows/checkout/fill-payment-card.yaml b/e2e/flows/checkout/fill-payment-card.yaml index e27ccc940..6f68c89b6 100644 --- a/e2e/flows/checkout/fill-payment-card.yaml +++ b/e2e/flows/checkout/fill-payment-card.yaml @@ -35,6 +35,9 @@ appId: ${E2E_APP_ID} - 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 3efa178a8..c911d1055 100644 --- a/e2e/flows/checkout/fill-shipping-address.yaml +++ b/e2e/flows/checkout/fill-shipping-address.yaml @@ -1,9 +1,13 @@ 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: ${E2E_APP_ID} element: text: "^${COUNTRY_LABEL}$" direction: DOWN - visibilityPercentage: 10 + centerElement: true optional: true - runFlow: when: @@ -32,7 +36,7 @@ appId: ${E2E_APP_ID} element: text: "^${COUNTRY_LABEL}$" direction: UP - visibilityPercentage: 10 + centerElement: true - tapOn: text: "^${COUNTRY_LABEL}$" - waitForAnimationToEnd @@ -72,7 +76,7 @@ appId: ${E2E_APP_ID} element: text: "^${STATE_LABEL}$" direction: DOWN - visibilityPercentage: 10 + centerElement: true optional: true - runFlow: when: @@ -82,12 +86,35 @@ appId: ${E2E_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/tests/react-native/checkout-guest.yaml b/e2e/tests/react-native/checkout-guest.yaml deleted file mode 100644 index 958dfcafc..000000000 --- a/e2e/tests/react-native/checkout-guest.yaml +++ /dev/null @@ -1,42 +0,0 @@ -appId: ${E2E_APP_ID} -name: React Native checkout - guest -tags: - - checkout - - full - # Only React Native runs this today, and every row now runs the whole tests folder. - # The shared guest test replaces this file, and this tag goes with it. - - wip - -env: - # Sample app buyer identity configuration - E2E_CART_PARAMS: "productIndex=0&quantity=1&buyerIdentityMode=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/shared/checkout-guest.yaml b/e2e/tests/shared/checkout-guest.yaml new file mode 100644 index 000000000..7a6e6ca6a --- /dev/null +++ b/e2e/tests/shared/checkout-guest.yaml @@ -0,0 +1,59 @@ +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. The shop opens on its own country, so this address picks a + # different one and makes the flow use the country picker. 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 its credit card gateway in test mode, which + # accepts this number only. Checkout groups the digits, so the flow needs both forms. + CARD_NUMBER: "4242424242424242" + CARD_NUMBER_DISPLAY: "4242 4242 4242 4242" + 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_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 From 75c818715bf3497ae55f16fd75f67de4f92afefc Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Fri, 31 Jul 2026 15:58:15 +0100 Subject: [PATCH 10/15] feat(e2e): add the customer account checkout test on all four targets The test signs a test customer in through the hosted login page, seeds a cart with the customer buyer identity, and orders. Checkout reads the contact and the saved delivery address from the account, so the test asserts the prefill instead of typing an address. Three sample repairs fall out of running it: - The Swift E2E build signed nothing, so the app carried no application-identifier and every keychain write returned -34018. The cart then dropped the customer and checkout opened as a guest. The build now signs ad hoc. - generate_android_env wrote only EMAIL and PHONE, so the Android sample used its Canadian fallbacks and the billing labels never matched. It now writes the same eleven keys as the Swift and React Native generators, and test_setup_storefront_env asserts all three. - The React Native sign-in link opened Login with no AccountHome beneath it, so goBack() left the tab and the sample landed on the catalog. The link now passes initial: false. The account credentials and the login web view user agent stay out of the repository. A run without them skips the account tag instead of failing. Co-Authored-By: Claude Opus 5 (1M context) Assisted-By: devx/5b4b8d5b-690a-4389-841e-039723ade1da --- .env.example | 9 ++++ e2e/BITRISE.md | 15 ++++++ e2e/FLAKES.md | 5 +- e2e/README.md | 2 + e2e/config/matrix.yml | 2 + e2e/flows/app/bootstrap-cart-from-link.yaml | 46 ++-------------- e2e/flows/app/confirm-open-link.yaml | 10 ++++ e2e/flows/app/launch.yaml | 14 +++++ e2e/flows/app/seed-cart-from-link.yaml | 24 +++++++++ e2e/flows/app/sign-in-customer-account.yaml | 52 +++++++++++++++++++ .../checkout/assert-buyer-prefilled.yaml | 12 +++++ e2e/flows/checkout/fill-billing-address.yaml | 4 +- e2e/flows/checkout/fill-contact.yaml | 27 ++-------- e2e/flows/checkout/fill-email.yaml | 9 ++++ e2e/flows/checkout/fill-name.yaml | 22 ++++++++ e2e/scripts/execute_browserstack_run | 25 ++++++++- e2e/scripts/run_maestro | 24 +++++++++ ...2e_matrix_to_browserstack_run_plan_test.rb | 2 +- e2e/test/maestro_test_tags_test.rb | 22 ++++++++ .../shared/checkout-customer-account.yaml | 43 +++++++++++++++ e2e/tests/shared/checkout-guest.yaml | 15 +++--- .../checkout-hardcoded-buyer-identity.yaml | 17 +++--- .../CheckoutKitAndroidDemo/app/build.gradle | 4 ++ .../e2e/E2EControlLink.kt | 12 ++--- .../e2e/E2EController.kt | 4 +- .../e2e/E2ESampleAppTarget.kt | 4 ++ .../e2e/E2ETestIds.kt | 4 ++ .../settings/account/AccountView.kt | 6 ++- .../settings/authentication/LoginView.kt | 6 ++- .../settings/authentication/LoginWebView.kt | 8 ++- .../e2e/E2EControlLinkTest.kt | 15 ++---- .../e2e/E2EControllerTest.kt | 8 ++- .../e2e/E2ETestIdsTest.kt | 5 ++ platforms/react-native/sample/src/App.tsx | 7 ++- .../src/e2e/__tests__/controlLink.test.ts | 19 +++---- .../src/e2e/__tests__/controller.test.ts | 8 ++- .../sample/src/e2e/controlLink.ts | 13 ++--- .../react-native/sample/src/e2e/controller.ts | 4 +- .../sample/src/e2e/useE2ECartBootstrap.ts | 11 +++- .../sample/src/screens/LoginScreen.tsx | 2 + .../Sources/E2E/E2EControlLink.swift | 22 ++------ .../Sources/E2E/E2EController.swift | 6 +-- .../Sources/E2E/E2ESampleAppTarget.swift | 10 ++++ .../Sources/E2E/E2ESignInRequest.swift | 16 ++++++ .../Sources/E2E/E2ETestIds.swift | 4 ++ .../Sources/Lib/InfoDictionary.swift | 4 ++ .../Sources/Lib/KeychainHelper.swift | 11 +++- .../Sources/Scenes/AccountView.swift | 8 +++ .../CustomerAccountLoginView.swift | 4 ++ .../E2E/E2EControlLinkTests.swift | 15 ++---- .../E2E/E2EControllerTests.swift | 11 ++-- .../E2E/E2ETestIdsTests.swift | 4 ++ .../Samples/CheckoutKitSwiftDemo/project.yml | 1 + platforms/swift/Scripts/e2e_maestro | 7 ++- scripts/setup_storefront_env | 35 ++++++++++++- scripts/test_setup_storefront_env | 13 +++++ 56 files changed, 535 insertions(+), 177 deletions(-) create mode 100644 e2e/flows/app/confirm-open-link.yaml create mode 100644 e2e/flows/app/launch.yaml create mode 100644 e2e/flows/app/seed-cart-from-link.yaml create mode 100644 e2e/flows/app/sign-in-customer-account.yaml create mode 100644 e2e/flows/checkout/assert-buyer-prefilled.yaml create mode 100644 e2e/flows/checkout/fill-email.yaml create mode 100644 e2e/flows/checkout/fill-name.yaml create mode 100644 e2e/tests/shared/checkout-customer-account.yaml create mode 100644 platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ESignInRequest.swift diff --git a/.env.example b/.env.example index 0d8f4a4ca..e532054b9 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,15 @@ CUSTOMER_ACCOUNT_API_CLIENT_ID= CUSTOMER_ACCOUNT_API_SHOP_ID= CUSTOMER_ACCOUNT_API_VERSION=2026-04 +# 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 EMAIL=checkout-kit@example.com ADDRESS_1=650 King Street 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 index 67569a713..89f337cf1 100644 --- a/e2e/FLAKES.md +++ b/e2e/FLAKES.md @@ -10,7 +10,7 @@ the symptom, the date, and the artifact directory. Delete the entry when the fix | # | Target | Symptom | Seen | Suspected cause | |---|---|---|---|---| -| F1 | React Native iOS | `launch-smoke` fails after 2 seconds. The driver answers `viewHierarchy` with HTTP 500 and `kAXErrorInvalidUIElement`. | 2026-07-31, 1 run of 2 | Maestro reads the accessibility tree while the app still builds it. `extendedWaitUntil` retries a missing element, but it does not retry a driver error. | +| 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. | ## Runner limits, not product faults @@ -33,6 +33,9 @@ Keep them out of new flows. `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 diff --git a/e2e/README.md b/e2e/README.md index 3bb705fe6..f0e91daff 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -148,6 +148,8 @@ ruby e2e/scripts/e2e_matrix_to_browserstack_run_plan count 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. diff --git a/e2e/config/matrix.yml b/e2e/config/matrix.yml index 090e1519c..7ebbea1bf 100644 --- a/e2e/config/matrix.yml +++ b/e2e/config/matrix.yml @@ -8,6 +8,8 @@ tags: - launch - cart - checkout + # execute_browserstack_run drops this tag again when the account secrets are absent. + - account exclude: - flaky - wip diff --git a/e2e/flows/app/bootstrap-cart-from-link.yaml b/e2e/flows/app/bootstrap-cart-from-link.yaml index 617625f40..ad075343d 100644 --- a/e2e/flows/app/bootstrap-cart-from-link.yaml +++ b/e2e/flows/app/bootstrap-cart-from-link.yaml @@ -1,44 +1,6 @@ appId: ${E2E_APP_ID} --- -- launchApp: - clearState: true - 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 -# The wait above can match a view hierarchy the dying process left behind, which sends the -# link seconds before the new activity exists. Android then drops it, because a deep link -# has no delivery guarantee. One repeat covers that, and the command is safe to repeat: -# every cart command resets the cart before it seeds one. -- retry: - maxRetries: 1 - commands: - # The link goes to the running app. launchApp cleared the state above, so the cart is - # already empty, and a running app is what the account journey needs later: iOS delivers - # a cold-start URL through the scene connect options and Android through the launch - # intent, so a stopped app would need a second entry point on every platform. - # Each test supplies E2E_CART_PARAMS, so tests that do not care about buyer - # identity leave the app's configured mode alone. - - openLink: "${E2E_CONTROL_LINK}/cart?${E2E_CART_PARAMS}" - - waitForAnimationToEnd - # iOS asks the user to confirm before it hands a custom scheme to an app. Android - # does not, so the tap is conditional rather than platform-gated. - - runFlow: - when: - visible: "Open" - commands: - - tapOn: "Open" - - waitForAnimationToEnd - # The cart marker only appears after the controller resolved a variant, created a - # cart, added the line, and navigated. Reaching it proves the whole command path. - # That is two network round trips, so the timeout covers a slow device on a slow shop. - - extendedWaitUntil: - visible: - id: cart-checkout-ready - timeout: 60000 +# 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..953fd4a53 --- /dev/null +++ b/e2e/flows/app/launch.yaml @@ -0,0 +1,14 @@ +appId: ${E2E_APP_ID} +--- +- launchApp: + clearState: true + 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/fill-billing-address.yaml b/e2e/flows/checkout/fill-billing-address.yaml index 29678ca51..7ce5543f5 100644 --- a/e2e/flows/checkout/fill-billing-address.yaml +++ b/e2e/flows/checkout/fill-billing-address.yaml @@ -16,7 +16,7 @@ appId: ${E2E_APP_ID} # 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$" + text: "^First name( \\(optional\\))?$" - inputText: "${BILLING_FIRST_NAME}" - runFlow: dismiss-active-field.yaml - tapOn: @@ -32,7 +32,7 @@ appId: ${E2E_APP_ID} - inputText: "${BILLING_CITY}" - runFlow: dismiss-active-field.yaml - tapOn: - text: "^Postal code$" + 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 3e0d6dde2..46f0ef4a3 100644 --- a/e2e/flows/checkout/fill-contact.yaml +++ b/e2e/flows/checkout/fill-contact.yaml @@ -1,25 +1,6 @@ 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/scripts/execute_browserstack_run b/e2e/scripts/execute_browserstack_run index 59c488422..a5a73e1ba 100755 --- a/e2e/scripts/execute_browserstack_run +++ b/e2e/scripts/execute_browserstack_run @@ -137,6 +137,25 @@ class BrowserStackRunExecutor response end + # The repository is public, so the account credentials live in Bitrise secrets only. + # A run without them skips the account journey instead of failing inside checkout. + def customer_account_email + ENV.fetch("E2E_CUSTOMER_ACCOUNT_EMAIL", "") + end + + def customer_account_code + ENV.fetch("E2E_CUSTOMER_ACCOUNT_CODE", "") + end + + def exclude_tags(run) + tags = run.fetch("exclude_tags") + + return tags unless customer_account_email.empty? || customer_account_code.empty? + + warn("no customer account secrets, excluding the account tag") + (tags + ["account"]).uniq + end + def start_build(run, app_url, test_suite_url, device) body = { app: app_url, @@ -149,12 +168,14 @@ class BrowserStackRunExecutor execute: [run.fetch("execute")], tags: { includeTags: run.fetch("include_tags"), - excludeTags: run.fetch("exclude_tags") + excludeTags: exclude_tags(run) }, setEnvVariables: { E2E_APP_ID: run.fetch("app_id"), E2E_READY_MARKER: run.fetch("ready_marker"), - E2E_CONTROL_LINK: run.fetch("control_link") + E2E_CONTROL_LINK: run.fetch("control_link"), + E2E_CUSTOMER_ACCOUNT_EMAIL: customer_account_email, + E2E_CUSTOMER_ACCOUNT_CODE: customer_account_code } } response = @client.start_build(run.fetch("platform"), body) diff --git a/e2e/scripts/run_maestro b/e2e/scripts/run_maestro index b3d19513c..74722abff 100755 --- a/e2e/scripts/run_maestro +++ b/e2e/scripts/run_maestro @@ -30,6 +30,28 @@ MAESTRO="$("$E2E_ROOT/scripts/maestro_bin")" # 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 + EXCLUDE_TAGS="${EXCLUDE_TAGS:+${EXCLUDE_TAGS},}account" +fi + MAESTRO_ARGS=(--platform "$PLATFORM") if [ -n "$DEVICE_ID" ]; then @@ -55,4 +77,6 @@ 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/e2e_matrix_to_browserstack_run_plan_test.rb b/e2e/test/e2e_matrix_to_browserstack_run_plan_test.rb index d4d98a0b6..37e98af04 100644 --- a/e2e/test/e2e_matrix_to_browserstack_run_plan_test.rb +++ b/e2e/test/e2e_matrix_to_browserstack_run_plan_test.rb @@ -41,7 +41,7 @@ def test_a_run_executes_the_workspace_root_so_the_config_glob_resolves def test_a_run_carries_the_default_tags run = run_for("swift-ios") - assert_equal ["launch", "cart", "checkout"], run.fetch("include_tags") + assert_equal ["launch", "cart", "checkout", "account"], run.fetch("include_tags") assert_equal ["flaky", "wip"], run.fetch("exclude_tags") end diff --git a/e2e/test/maestro_test_tags_test.rb b/e2e/test/maestro_test_tags_test.rb index 1f9197c2f..e2bb10f3f 100644 --- a/e2e/test/maestro_test_tags_test.rb +++ b/e2e/test/maestro_test_tags_test.rb @@ -99,6 +99,28 @@ def test_the_matrix_selects_every_journey_a_shared_test_declares ) 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 test_there_is_at_least_one_test_to_check refute_empty(test_files) refute_empty(shared_test_files) 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 index 7a6e6ca6a..1f138bc16 100644 --- a/e2e/tests/shared/checkout-guest.yaml +++ b/e2e/tests/shared/checkout-guest.yaml @@ -15,9 +15,9 @@ env: FIRST_NAME: "Maestro" LAST_NAME: "Shopify" - # Checkout delivery fixture. The shop opens on its own country, so this address picks a - # different one and makes the flow use the country picker. The two field labels follow - # the country: the United States names them State and ZIP code. + # 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" @@ -29,10 +29,10 @@ env: # reserved for fiction, so this value reaches nobody. PHONE: "213 555 0142" - # Checkout payment fixture. The shop runs its credit card gateway in test mode, which - # accepts this number only. Checkout groups the digits, so the flow needs both forms. - CARD_NUMBER: "4242424242424242" - CARD_NUMBER_DISPLAY: "4242 4242 4242 4242" + # 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" @@ -43,6 +43,7 @@ env: 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. diff --git a/e2e/tests/shared/checkout-hardcoded-buyer-identity.yaml b/e2e/tests/shared/checkout-hardcoded-buyer-identity.yaml index c21b019fa..561cdc5b0 100644 --- a/e2e/tests/shared/checkout-hardcoded-buyer-identity.yaml +++ b/e2e/tests/shared/checkout-hardcoded-buyer-identity.yaml @@ -11,21 +11,22 @@ env: # breaks. E2E_CART_PARAMS: "productIndex=0&quantity=1&buyerIdentityMode=hardcoded" - # Checkout payment fixture. The shop runs its credit card gateway in test mode, which - # accepts this number only. Checkout groups the digits, so the flow needs both forms. - CARD_NUMBER: "4242424242424242" - CARD_NUMBER_DISPLAY: "4242 4242 4242 4242" + # 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 province keep the shop defaults, so this address stays in Canada. + # 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: "620 King Street West" - BILLING_CITY: "Toronto" - BILLING_POSTAL_CODE: "M5V 1M7" + 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).*" diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/build.gradle b/platforms/android/samples/CheckoutKitAndroidDemo/app/build.gradle index e72459b84..edc349c6d 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" } @@ -90,6 +93,7 @@ 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\"" 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 index 080724ff7..13733e679 100644 --- 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 @@ -25,7 +25,7 @@ sealed interface E2EControlLink { val buyerIdentityMode: E2EBuyerIdentityMode? = null, ) : E2EControlLink - data class SignIn(val email: String? = null) : E2EControlLink + data object SignIn : E2EControlLink companion object { const val HOST = "e2e" @@ -56,7 +56,7 @@ sealed interface E2EControlLink { return when (uri.path.orEmpty().trim('/')) { "reset" -> reset(parameters) "cart" -> cart(parameters) - "signIn" -> SignIn(email = signInEmail(parameters)) + "signIn" -> signIn(parameters) else -> throw IllegalArgumentException("Unsupported e2e command") } } @@ -111,12 +111,10 @@ sealed interface E2EControlLink { } } - private fun signInEmail(parameters: Map): String? { - val email = parameters["email"] ?: return null + private fun signIn(parameters: Map): SignIn { + require(parameters.isEmpty()) { "signIn takes no parameters" } - require(email.isNotEmpty()) { "email must not be blank" } - - return email + return SignIn } private fun parameters(rawQuery: String?): Map { 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 index 9e8571ebb..a67fc21ce 100644 --- 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 @@ -11,6 +11,8 @@ interface E2ECommandTarget { suspend fun showCart() + suspend fun presentSignIn() + suspend fun report(failure: String) } @@ -38,7 +40,7 @@ class E2EController(private val target: E2ECommandTarget) { when (link) { is E2EControlLink.Reset -> target.resetCart() is E2EControlLink.Cart -> seedCart(link) - is E2EControlLink.SignIn -> throw UnsupportedOperationException("signIn is not implemented yet") + is E2EControlLink.SignIn -> target.presentSignIn() } } catch (error: Exception) { target.report(message(error)) 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 index f74595864..0fa78868d 100644 --- 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 @@ -53,6 +53,10 @@ class E2ESampleAppTarget : E2ECommandTarget, KoinComponent { 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 a80fa361f..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 @@ -12,4 +12,8 @@ object E2ETestIds { 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/e2e/E2EControlLinkTest.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkout_kit_android_demo/e2e/E2EControlLinkTest.kt index d8f4aa253..263dcc4ec 100644 --- 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 @@ -123,20 +123,13 @@ class E2EControlLinkTest { } @Test - fun `parses a sign in command without an email`() { - assertThat(parse("/signIn")).isEqualTo(E2EControlLink.SignIn()) + fun `parses a sign in command`() { + assertThat(parse("/signIn")).isEqualTo(E2EControlLink.SignIn) } @Test - fun `parses a sign in command with an email`() { - assertThat(parse("/signIn?email=shopper%2Be2e@example.com")) - .isEqualTo(E2EControlLink.SignIn(email = "shopper+e2e@example.com")) - } - - @Test - fun `rejects a blank sign in email`() { - assertRejects("/signIn?email=", "email must not be blank") - assertRejects("/signIn?email=%20", "email must not be blank") + 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") 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 index 1cc81fbd6..7d3167c23 100644 --- 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 @@ -87,12 +87,12 @@ class E2EControllerTest { } @Test - fun `reports that sign in is not implemented`() { + fun `presents sign in`() { val target = E2ECommandTargetSpy() handle("/signIn", target) - assertThat(target.calls).containsExactly("report(signIn is not implemented yet)") + assertThat(target.calls).containsExactly("presentSignIn") } private fun handle(path: String, target: E2ECommandTargetSpy) = runBlocking { @@ -129,6 +129,10 @@ private class E2ECommandTargetSpy : E2ECommandTarget { 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 fe092dbed..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 @@ -20,4 +20,9 @@ class E2ETestIdsTest { 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/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__/controlLink.test.ts b/platforms/react-native/sample/src/e2e/__tests__/controlLink.test.ts index e91e1579e..5cc6e2579 100644 --- a/platforms/react-native/sample/src/e2e/__tests__/controlLink.test.ts +++ b/platforms/react-native/sample/src/e2e/__tests__/controlLink.test.ts @@ -135,21 +135,14 @@ describe('parseControlLink', () => { }); }); - it('parses a sign in command without an email', () => { + it('parses a sign in command', () => { expect(parse('/signIn')).toEqual({command: 'signIn'}); }); - it('parses a sign in command with an email', () => { - expect(parse('/signIn?email=shopper%2Be2e@example.com')).toEqual({ - command: 'signIn', - email: 'shopper+e2e@example.com', - }); + it('rejects sign in parameters', () => { + expectRejection( + '/signIn?email=shopper@example.com', + 'signIn takes no parameters', + ); }); - - it.each(['/signIn?email=', '/signIn?email=%20'])( - 'rejects the blank sign in email in %s', - path => { - expectRejection(path, 'email must not be blank'); - }, - ); }); diff --git a/platforms/react-native/sample/src/e2e/__tests__/controller.test.ts b/platforms/react-native/sample/src/e2e/__tests__/controller.test.ts index 6e6aa73e3..7e9c9cf2b 100644 --- a/platforms/react-native/sample/src/e2e/__tests__/controller.test.ts +++ b/platforms/react-native/sample/src/e2e/__tests__/controller.test.ts @@ -24,6 +24,10 @@ class E2ECommandTargetSpy implements E2ECommandTarget { return `variant-${productIndex}`; } + async presentSignIn() { + this.calls.push('presentSignIn'); + } + async addCartLine(variantId: string, quantity: number) { this.calls.push(`addCartLine(${variantId}, ${quantity})`); @@ -127,11 +131,11 @@ describe('E2EController', () => { ]); }); - it('reports that sign in is not implemented', async () => { + it('presents sign in', async () => { const target = new E2ECommandTargetSpy(); await handle('/signIn', target); - expect(target.calls).toEqual(['report(signIn is not implemented yet)']); + expect(target.calls).toEqual(['presentSignIn']); }); }); diff --git a/platforms/react-native/sample/src/e2e/controlLink.ts b/platforms/react-native/sample/src/e2e/controlLink.ts index d3b6d2e08..a881cc80a 100644 --- a/platforms/react-native/sample/src/e2e/controlLink.ts +++ b/platforms/react-native/sample/src/e2e/controlLink.ts @@ -18,7 +18,6 @@ export type E2ECartCommand = { export type E2ESignInCommand = { command: 'signIn'; - email?: string; }; export type E2EControlLink = @@ -116,17 +115,11 @@ function parseCart(parameters: Parameters): E2ECartCommand { } function parseSignIn(parameters: Parameters): E2ESignInCommand { - const email = parameters.get('email'); - - if (email === undefined) { - return {command: 'signIn'}; - } - - if (email === '') { - throw new Error('email must not be blank'); + if (parameters.size > 0) { + throw new Error('signIn takes no parameters'); } - return {command: 'signIn', email}; + return {command: 'signIn'}; } export function parseControlLink(url: string): E2EControlLink | null { diff --git a/platforms/react-native/sample/src/e2e/controller.ts b/platforms/react-native/sample/src/e2e/controller.ts index d2ede28aa..97d53c33d 100644 --- a/platforms/react-native/sample/src/e2e/controller.ts +++ b/platforms/react-native/sample/src/e2e/controller.ts @@ -11,6 +11,7 @@ export interface E2ECommandTarget { variantId(productIndex: number): Promise; addCartLine(variantId: string, quantity: number): Promise; showCart(): Promise; + presentSignIn(): Promise; report(failure: string): Promise; } @@ -50,7 +51,8 @@ export class E2EController { await this.seedCart(link); break; case 'signIn': - throw new Error('signIn is not implemented yet'); + await this.target.presentSignIn(); + break; } } catch (error) { await this.target.report(message(error)); diff --git a/platforms/react-native/sample/src/e2e/useE2ECartBootstrap.ts b/platforms/react-native/sample/src/e2e/useE2ECartBootstrap.ts index 816521534..f43103101 100644 --- a/platforms/react-native/sample/src/e2e/useE2ECartBootstrap.ts +++ b/platforms/react-native/sample/src/e2e/useE2ECartBootstrap.ts @@ -7,9 +7,13 @@ import {E2EController, type E2ECommandTarget} from './controller'; type UseE2ECartBootstrapOptions = { onCartReady: () => void; + onSignInRequested: () => void; }; -export function useE2ECartBootstrap({onCartReady}: UseE2ECartBootstrapOptions) { +export function useE2ECartBootstrap({ + onCartReady, + onSignInRequested, +}: UseE2ECartBootstrapOptions) { const {seedCart, clearCart} = useCart(); const {queries} = useShopify(); const [fetchProducts] = queries.products; @@ -41,11 +45,14 @@ export function useE2ECartBootstrap({onCartReady}: UseE2ECartBootstrapOptions) { async showCart() { onCartReady(); }, + async presentSignIn() { + onSignInRequested(); + }, async report(failure) { Alert.alert('E2E command failed', failure); }, }; - }, [clearCart, fetchProducts, onCartReady, seedCart]); + }, [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/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EControlLink.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EControlLink.swift index 86944b838..e7d8dfbff 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EControlLink.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EControlLink.swift @@ -9,7 +9,6 @@ enum E2EControlLinkError: LocalizedError, Equatable, Hashable { case invalidQuantity case invalidProductIndex case invalidBuyerIdentityMode - case blankEmail var errorDescription: String? { switch self { @@ -29,8 +28,6 @@ enum E2EControlLinkError: LocalizedError, Equatable, Hashable { return "productIndex must be a non-negative integer" case .invalidBuyerIdentityMode: return "buyerIdentityMode must be guest, hardcoded, or customerAccount" - case .blankEmail: - return "email must not be blank" } } } @@ -38,7 +35,7 @@ enum E2EControlLinkError: LocalizedError, Equatable, Hashable { enum E2EControlLink: Equatable { case reset case cart(CartCommand) - case signIn(email: String?) + case signIn struct CartCommand: Equatable { var variantId: String? @@ -76,7 +73,10 @@ enum E2EControlLink: Equatable { case "cart": return try .cart(cartCommand(from: parameters)) case "signIn": - return try .signIn(email: signInEmail(from: parameters)) + guard parameters.isEmpty else { + throw E2EControlLinkError.unexpectedParameters(command: "signIn") + } + return .signIn default: throw E2EControlLinkError.unsupportedCommand } @@ -136,18 +136,6 @@ enum E2EControlLink: Equatable { return buyerIdentityMode } - private static func signInEmail(from parameters: Parameters) throws -> String? { - guard let email = parameters.value("email") else { - return nil - } - - guard !email.isEmpty else { - throw E2EControlLinkError.blankEmail - } - - return email - } - private struct Parameters { private let values: [String: String] diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EController.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EController.swift index 549434217..1b7920865 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EController.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2EController.swift @@ -7,17 +7,15 @@ protocol E2ECommandTarget { 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 unimplementedCommand(String) case productIndexOutOfRange(Int) var errorDescription: String? { switch self { - case let .unimplementedCommand(command): - return "\(command) is not implemented yet" case let .productIndexOutOfRange(index): return "No product at index \(index)" } @@ -62,7 +60,7 @@ final class E2EController { case let .cart(command): try await seedCart(command) case .signIn: - throw E2EControllerError.unimplementedCommand("signIn") + await target.presentSignIn() } } catch { await target.report(failure: E2EController.message(for: error)) diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ESampleAppTarget.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ESampleAppTarget.swift index af02f363f..3d0394c4b 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ESampleAppTarget.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ESampleAppTarget.swift @@ -49,6 +49,16 @@ struct E2ESampleAppTarget: E2ECommandTarget { 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 f591a5954..7343ea251 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ETestIds.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/E2E/E2ETestIds.swift @@ -10,4 +10,8 @@ enum E2ETestIds { 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/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 index 3d0c2ba31..f19f2a4fd 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControlLinkTests.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControlLinkTests.swift @@ -91,17 +91,12 @@ class E2EControlLinkTests: XCTestCase { XCTAssertEqual(try parse("/cart/?productIndex=3"), .cart(.init(productIndex: 3, quantity: 1))) } - func testParsesASignInCommandWithoutAnEmail() throws { - XCTAssertEqual(try parse("/signIn"), .signIn(email: nil)) + func testParsesASignInCommand() throws { + XCTAssertEqual(try parse("/signIn"), .signIn) } - func testParsesASignInCommandWithAnEmail() throws { - XCTAssertEqual(try parse("/signIn?email=shopper%2Be2e@example.com"), .signIn(email: "shopper+e2e@example.com")) - } - - func testRejectsABlankSignInEmail() { - assertThrows(.blankEmail, "/signIn?email=") - assertThrows(.blankEmail, "/signIn?email=%20") + func testRejectsSignInParameters() { + assertThrows(.unexpectedParameters(command: "signIn"), "/signIn?email=shopper@example.com") } func testErrorMessagesMatchTheOtherPlatforms() { @@ -114,7 +109,7 @@ class E2EControlLinkTests: XCTestCase { 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.blankEmail: "email must not be blank" + E2EControlLinkError.unexpectedParameters(command: "signIn"): "signIn takes no parameters" ] for (error, message) in messages { diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControllerTests.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControllerTests.swift index cbd7c2c71..b1157b2a9 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControllerTests.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2EControllerTests.swift @@ -77,12 +77,13 @@ class E2EControllerTests: XCTestCase { ]) } - func testReportsThatSignInIsNotImplemented() async { + func testPresentsSignIn() async { let target = E2ECommandTargetSpy() - await handle("/signIn", target) + let handled = await handle("/signIn", target) - XCTAssertEqual(target.calls, ["report(signIn is not implemented yet)"]) + XCTAssertTrue(handled) + XCTAssertEqual(target.calls, ["presentSignIn"]) } @discardableResult @@ -123,6 +124,10 @@ private class E2ECommandTargetSpy: E2ECommandTarget { } } + func presentSignIn() async { + calls.append("presentSignIn") + } + func showCart() async { calls.append("showCart") } diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2ETestIdsTests.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2ETestIdsTests.swift index f8a8184a1..5bf2b9703 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2ETestIdsTests.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/E2E/E2ETestIdsTests.swift @@ -15,4 +15,8 @@ class E2ETestIdsTests: XCTestCase { 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 54032831e..0b75fae38 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/project.yml +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/project.yml @@ -89,6 +89,7 @@ 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 diff --git a/platforms/swift/Scripts/e2e_maestro b/platforms/swift/Scripts/e2e_maestro index 456de3cbb..464c1d8f5 100755 --- a/platforms/swift/Scripts/e2e_maestro +++ b/platforms/swift/Scripts/e2e_maestro @@ -103,7 +103,12 @@ xcodebuild build \ -derivedDataPath "$DERIVED_DATA" \ -skipPackagePluginValidation \ -disableAutomaticPackageResolution \ - CODE_SIGNING_ALLOWED=NO + 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 diff --git a/scripts/setup_storefront_env b/scripts/setup_storefront_env index 97084e4ac..c4c953b61 100755 --- a/scripts/setup_storefront_env +++ b/scripts/setup_storefront_env @@ -246,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 } @@ -449,6 +450,18 @@ 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_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")" @@ -536,6 +549,14 @@ EOF write_env_assignment CUSTOMER_ACCOUNT_API_VERSION "$CUSTOMER_ACCOUNT_API_VERSION_VALUE" cat <"$output" 2>&1 assert_output_is_sanitized "$output" From 49ebe0caef94afce7662092ca0317d682851b23e Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Fri, 31 Jul 2026 17:09:21 +0100 Subject: [PATCH 11/15] fix(e2e): never repeat the account tag across both tag lists BrowserStack answers HTTP 422 BROWSERSTACK_DUPLICATE_TAGS when one tag appears in both includeTags and excludeTags. The account skip added `account` to the exclude list and left it in the include list, so every CI row failed before it started a build. `resolve_tags` now removes the tag from the include list at the same moment it adds it to the exclude list. `run_maestro` applies the same rule locally, and it exits early when the requested tags need account credentials, because an empty include list makes Maestro run the whole suite. Co-Authored-By: Claude Opus 5 (1M context) Assisted-By: devx/5b4b8d5b-690a-4389-841e-039723ade1da --- e2e/scripts/execute_browserstack_run | 30 ++++++++++++++------ e2e/scripts/run_maestro | 11 ++++++++ e2e/test/browserstack_run_executor_test.rb | 33 ++++++++++++++++++++++ 3 files changed, 65 insertions(+), 9 deletions(-) diff --git a/e2e/scripts/execute_browserstack_run b/e2e/scripts/execute_browserstack_run index a5a73e1ba..b5e928790 100755 --- a/e2e/scripts/execute_browserstack_run +++ b/e2e/scripts/execute_browserstack_run @@ -147,13 +147,28 @@ class BrowserStackRunExecutor ENV.fetch("E2E_CUSTOMER_ACCOUNT_CODE", "") end - def exclude_tags(run) - tags = run.fetch("exclude_tags") + # 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 tags unless customer_account_email.empty? || customer_account_code.empty? + return {includeTags: include_tags, excludeTags: exclude_tags} if account_enabled - warn("no customer account secrets, excluding the account tag") - (tags + ["account"]).uniq + { + includeTags: include_tags - ["account"], + excludeTags: (exclude_tags + ["account"]).uniq + } + end + + def account_enabled? + !customer_account_email.empty? && !customer_account_code.empty? + end + + def resolve_tags(run) + warn("no customer account secrets, excluding the account tag") unless account_enabled? + + self.class.resolve_tags(run, account_enabled: account_enabled?) end def start_build(run, app_url, test_suite_url, device) @@ -166,10 +181,7 @@ class BrowserStackRunExecutor customBuildName: run.fetch("id"), devices: [device], execute: [run.fetch("execute")], - tags: { - includeTags: run.fetch("include_tags"), - excludeTags: exclude_tags(run) - }, + tags: resolve_tags(run), setEnvVariables: { E2E_APP_ID: run.fetch("app_id"), E2E_READY_MARKER: run.fetch("ready_marker"), diff --git a/e2e/scripts/run_maestro b/e2e/scripts/run_maestro index 74722abff..f188b5924 100755 --- a/e2e/scripts/run_maestro +++ b/e2e/scripts/run_maestro @@ -49,7 +49,18 @@ CUSTOMER_ACCOUNT_CODE="${E2E_CUSTOMER_ACCOUNT_CODE:-$(read_env_value E2E_CUSTOME 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") diff --git a/e2e/test/browserstack_run_executor_test.rb b/e2e/test/browserstack_run_executor_test.rb index ac0366d95..be17f2015 100644 --- a/e2e/test/browserstack_run_executor_test.rb +++ b/e2e/test/browserstack_run_executor_test.rb @@ -7,6 +7,11 @@ class BrowserStackRunExecutorTest < Minitest::Test E2E_ROOT = File.expand_path("..", __dir__) + RUN = { + "include_tags" => %w[launch cart checkout account], + "exclude_tags" => %w[flaky wip] + }.freeze + def with_version_file(contents) Dir.mktmpdir do |dir| path = File.join(dir, ".maestro-version") @@ -55,4 +60,32 @@ 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 end From 22c7e0681761033dda7919404a6e9191f8e7c084 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Fri, 31 Jul 2026 17:22:52 +0100 Subject: [PATCH 12/15] fix(e2e): name the tags and the setup error in the GitHub report Two columns carried no information. `execute` is the tests folder on every row now that tags select the flows, so the Suite column read `.` four times. The column now names the include tags, and reads `all` when a row carries none. The failure heading and the missing-run label used the same value, so both change too. A row that fails before it starts a build reports no test cases. `result.json` already holds `error` and `error_class`, but the report dropped both, so a reader saw an empty row and had to open Bitrise. The report now names the class and the message above the table. Co-Authored-By: Claude Opus 5 (1M context) Assisted-By: devx/5b4b8d5b-690a-4389-841e-039723ade1da --- e2e/lib/e2e_github_reporter.rb | 27 +++++++-- e2e/test/e2e_github_reporter_test.rb | 89 ++++++++++++++++++++++++++-- 2 files changed, 104 insertions(+), 12 deletions(-) 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/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 From d37552820b258ed582e4af00c64cca426a8a5d96 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Fri, 31 Jul 2026 17:48:49 +0100 Subject: [PATCH 13/15] docs(e2e): record the BrowserStack-only failures Six tests pass locally on all four targets and fail on BrowserStack. PR #561 and PR #562 show the same two patterns, so no sample change causes them. Both patterns need the Maestro logs, which need BrowserStack access. Separate them from the flake register, because a flake gives a different result from the same code and these give the same result every run. Co-Authored-By: Claude Opus 5 (1M context) Assisted-By: devx/5b4b8d5b-690a-4389-841e-039723ade1da --- e2e/FLAKES.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/e2e/FLAKES.md b/e2e/FLAKES.md index 89f337cf1..3129307bb 100644 --- a/e2e/FLAKES.md +++ b/e2e/FLAKES.md @@ -12,6 +12,17 @@ the symptom, the date, and the artifact directory. Delete the entry when the fix |---|---|---|---|---| | 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 ends 1 second after launch. `launch-smoke` passes in 7 seconds, so the app starts and the ready marker appears. The whole six-test suite finishes in 44 seconds against 10m 29s locally. Both iOS targets show the same timings, and PR #561 shows them too, so no sample change causes it. | Unproven. `openLink` is the first command after launch in every failing test, and the only command `launch-smoke` does not run. | +| B2 | Kotlin Android and React Native Android | Every test that completes an order 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. | Unproven. Two candidates: the shop, because `CARD_NUMBER: "1"` needs the Bogus Gateway and the customer account must exist on that shop; and the buyer address, because `scripts/setup_storefront_env` falls back to `CA`/`ON`/`Toronto` when Bitrise supplies no address values, and the tests assert `ZIP code` and `State`. | ## Runner limits, not product faults From 9fc1dca0172b414223249aa05ffe07e3988e3629 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Mon, 3 Aug 2026 15:57:31 +0100 Subject: [PATCH 14/15] The buyer address default matches the E2E shop country Bitrise holds no address secrets, so a CI clone had no root .env, no React Native .env and no xcconfig. Every address key fell through to the Canadian default, and the generated cart carried a Toronto address into a US shop. That shop offers no delivery method for Canada, so checkout-hardcoded-buyer-identity died deep inside checkout on both Android targets. The address is the same public fixture the guest test already types, so it needs no secret. The Android sample's Gradle build carried a second copy of the same Canadian fallbacks; it now matches. Co-Authored-By: Claude Opus 5 (1M context) Assisted-By: devx/5b4b8d5b-690a-4389-841e-039723ade1da --- .env.example | 16 ++++++++------ e2e/FLAKES.md | 3 ++- .../CheckoutKitAndroidDemo/app/build.gradle | 12 +++++----- scripts/setup_storefront_env | 12 +++++----- scripts/test_setup_storefront_env | 22 +++++++++++++++++++ 5 files changed, 45 insertions(+), 20 deletions(-) diff --git a/.env.example b/.env.example index e532054b9..35144bb96 100644 --- a/.env.example +++ b/.env.example @@ -34,15 +34,17 @@ E2E_CUSTOMER_ACCOUNT_CODE= # CI supplies this from Bitrise secrets. Leave it blank for normal sample use. CUSTOM_USER_AGENT= -# Buyer identity defaults used by sample apps +# Buyer identity defaults used by sample apps. +# Keep the address in the E2E shop's own country. A foreign address offers no delivery +# method, and the E2E order tests then fail inside checkout rather than at the address. 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/e2e/FLAKES.md b/e2e/FLAKES.md index 3129307bb..b5ab3adbb 100644 --- a/e2e/FLAKES.md +++ b/e2e/FLAKES.md @@ -22,7 +22,8 @@ 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 ends 1 second after launch. `launch-smoke` passes in 7 seconds, so the app starts and the ready marker appears. The whole six-test suite finishes in 44 seconds against 10m 29s locally. Both iOS targets show the same timings, and PR #561 shows them too, so no sample change causes it. | Unproven. `openLink` is the first command after launch in every failing test, and the only command `launch-smoke` does not run. | -| B2 | Kotlin Android and React Native Android | Every test that completes an order 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. | Unproven. Two candidates: the shop, because `CARD_NUMBER: "1"` needs the Bogus Gateway and the customer account must exist on that shop; and the buyer address, because `scripts/setup_storefront_env` falls back to `CA`/`ON`/`Toronto` when Bitrise supplies no address values, and the tests assert `ZIP code` and `State`. | +| 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. | Confirmed for this test. Bitrise holds no address secrets, so `scripts/setup_storefront_env` fell back to `Toronto, ON, M5V 1M7, CA`. That cart carries a Canadian address into a US shop, which offers no delivery method. The defaults are now the US fixture. | +| B3 | Kotlin Android and React Native Android | `checkout-guest` and `checkout-customer-account` fail. | Unproven, and [B2](#browserstack-only-failures) does not explain them. `checkout-guest` types its own US address, and the account test uses the account's saved address. Read the Maestro log before you guess again. | ## Runner limits, not product faults diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/build.gradle b/platforms/android/samples/CheckoutKitAndroidDemo/app/build.gradle index edc349c6d..0728bf374 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/build.gradle +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/build.gradle @@ -56,16 +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", "650 King Street") +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", "Toronto") -def prefillProvince = propertyOrDefault("PROVINCE", "ON") -def prefillZip = propertyOrDefault("ZIP", "M5V 1M7") -def prefillCountry = propertyOrDefault("COUNTRY", "CA") +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 *****") diff --git a/scripts/setup_storefront_env b/scripts/setup_storefront_env index c4c953b61..2c04b6fac 100755 --- a/scripts/setup_storefront_env +++ b/scripts/setup_storefront_env @@ -463,16 +463,16 @@ load_values() { "$(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")" DEVELOPMENT_TEAM_VALUE="$(root_or_source_nonempty_value DEVELOPMENT_TEAM \ "$(env_fallback DEVELOPMENT_TEAM)" \ diff --git a/scripts/test_setup_storefront_env b/scripts/test_setup_storefront_env index aeaf8da51..ae93fefe8 100755 --- a/scripts/test_setup_storefront_env +++ b/scripts/test_setup_storefront_env @@ -171,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)" @@ -347,6 +368,7 @@ 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 From d6de95b87bf966a958d977ea60df965844039dd2 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Mon, 3 Aug 2026 16:40:47 +0100 Subject: [PATCH 15/15] fix(e2e): record the measured clearState evidence Eight BrowserStack iOS builds carrying clearState failed on five different units. Three builds without it passed, two of them on units that had just failed. The fault is deterministic, so a retry cannot recover it and the removal stands on measurement rather than on one run. Also confirms flake B4. The Android sample gives a guest cart a Canadian buyer identity, while Swift and React Native take the shop market, so the Kotlin checkout renders a Canadian form against a United States fixture. Co-Authored-By: Claude Opus 5 (1M context) Assisted-By: devx/5b4b8d5b-690a-4389-841e-039723ade1da --- .env.example | 5 +- e2e/FLAKES.md | 7 +-- e2e/flows/app/launch.yaml | 8 +++- e2e/scripts/execute_browserstack_run | 40 ++++++++++------ e2e/test/browserstack_run_executor_test.rb | 54 ++++++++++++++++++++++ e2e/test/maestro_test_tags_test.rb | 34 ++++++++++++++ 6 files changed, 128 insertions(+), 20 deletions(-) diff --git a/.env.example b/.env.example index 35144bb96..664e625d8 100644 --- a/.env.example +++ b/.env.example @@ -35,8 +35,9 @@ E2E_CUSTOMER_ACCOUNT_CODE= CUSTOM_USER_AGENT= # Buyer identity defaults used by sample apps. -# Keep the address in the E2E shop's own country. A foreign address offers no delivery -# method, and the E2E order tests then fail inside checkout rather than at the address. +# 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=700 S Flower St ADDRESS_2=Shopify HQ diff --git a/e2e/FLAKES.md b/e2e/FLAKES.md index b5ab3adbb..cabaeca07 100644 --- a/e2e/FLAKES.md +++ b/e2e/FLAKES.md @@ -21,9 +21,10 @@ 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 ends 1 second after launch. `launch-smoke` passes in 7 seconds, so the app starts and the ready marker appears. The whole six-test suite finishes in 44 seconds against 10m 29s locally. Both iOS targets show the same timings, and PR #561 shows them too, so no sample change causes it. | Unproven. `openLink` is the first command after launch in every failing test, and the only command `launch-smoke` does not run. | -| 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. | Confirmed for this test. Bitrise holds no address secrets, so `scripts/setup_storefront_env` fell back to `Toronto, ON, M5V 1M7, CA`. That cart carries a Canadian address into a US shop, which offers no delivery method. The defaults are now the US fixture. | -| B3 | Kotlin Android and React Native Android | `checkout-guest` and `checkout-customer-account` fail. | Unproven, and [B2](#browserstack-only-failures) does not explain them. `checkout-guest` types its own US address, and the account test uses the account's saved address. Read the Maestro log before you guess again. | +| 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 diff --git a/e2e/flows/app/launch.yaml b/e2e/flows/app/launch.yaml index 953fd4a53..edd6d2b9e 100644 --- a/e2e/flows/app/launch.yaml +++ b/e2e/flows/app/launch.yaml @@ -1,7 +1,13 @@ 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: - clearState: true arguments: # iOS-only launch arguments; Android ignores them. AppleLocale: en_US diff --git a/e2e/scripts/execute_browserstack_run b/e2e/scripts/execute_browserstack_run index b5e928790..8a8bf0fc5 100755 --- a/e2e/scripts/execute_browserstack_run +++ b/e2e/scripts/execute_browserstack_run @@ -137,16 +137,6 @@ class BrowserStackRunExecutor response end - # The repository is public, so the account credentials live in Bitrise secrets only. - # A run without them skips the account journey instead of failing inside checkout. - def customer_account_email - ENV.fetch("E2E_CUSTOMER_ACCOUNT_EMAIL", "") - end - - def customer_account_code - ENV.fetch("E2E_CUSTOMER_ACCOUNT_CODE", "") - 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:) @@ -161,12 +151,34 @@ class BrowserStackRunExecutor } 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? - !customer_account_email.empty? && !customer_account_code.empty? + self.class.account_enabled?(ENV) end def resolve_tags(run) - warn("no customer account secrets, excluding the account tag") unless account_enabled? + 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 @@ -186,8 +198,8 @@ class BrowserStackRunExecutor E2E_APP_ID: run.fetch("app_id"), E2E_READY_MARKER: run.fetch("ready_marker"), E2E_CONTROL_LINK: run.fetch("control_link"), - E2E_CUSTOMER_ACCOUNT_EMAIL: customer_account_email, - E2E_CUSTOMER_ACCOUNT_CODE: customer_account_code + 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) diff --git a/e2e/test/browserstack_run_executor_test.rb b/e2e/test/browserstack_run_executor_test.rb index be17f2015..f3b2e2500 100644 --- a/e2e/test/browserstack_run_executor_test.rb +++ b/e2e/test/browserstack_run_executor_test.rb @@ -12,6 +12,8 @@ class BrowserStackRunExecutorTest < Minitest::Test "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") @@ -88,4 +90,56 @@ def test_empty_include_list_still_excludes_the_account_tag 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/maestro_test_tags_test.rb b/e2e/test/maestro_test_tags_test.rb index e2bb10f3f..e7c44130b 100644 --- a/e2e/test/maestro_test_tags_test.rb +++ b/e2e/test/maestro_test_tags_test.rb @@ -121,6 +121,40 @@ def test_no_authored_file_assigns_a_customer_account_credential 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)