From 7022342e1fc3546f192ac37f316cbe4d8d74ee3c Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Thu, 30 Jul 2026 13:07:19 +0100 Subject: [PATCH] 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 + .../checkoutkit/androiddemo/CheckoutKitApp.kt | 4 + .../checkoutkit/androiddemo/MainActivity.kt | 10 ++ .../checkoutkit/androiddemo/cart/CartView.kt | 4 + .../androiddemo/e2e/E2EControlLinkHandler.kt | 16 ++ .../androiddemo/e2e/E2EController.kt | 60 ++++++++ .../androiddemo/e2e/E2ENavigation.kt | 26 ++++ .../androiddemo/e2e/E2ESampleAppTarget.kt | 59 ++++++++ .../checkoutkit/androiddemo/e2e/E2ETestIds.kt | 5 + .../androiddemo/e2e/E2EControllerTest.kt | 135 +++++++++++++++++ .../androiddemo/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/checkoutkit/androiddemo/e2e/E2EControlLinkHandler.kt create mode 100644 platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2EController.kt create mode 100644 platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2ENavigation.kt create mode 100644 platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2ESampleAppTarget.kt create mode 100644 platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/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 25c4c3137..ccb083a8a 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/AndroidManifest.xml +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/AndroidManifest.xml @@ -55,6 +55,7 @@ android:name=".MainActivity" android:configChanges="orientation|keyboardHidden" android:exported="true" + android:launchMode="singleTask" android:screenOrientation="portrait" tools:ignore="LockedOrientationActivity,DiscouragedApi"> @@ -62,6 +63,13 @@ + + + + + + + diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/CheckoutKitApp.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/CheckoutKitApp.kt index f17a4b733..615d10950 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/CheckoutKitApp.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/CheckoutKitApp.kt @@ -53,6 +53,7 @@ import com.shopify.checkoutkit.androiddemo.common.navigation.BottomAppBarWithNav import com.shopify.checkoutkit.androiddemo.common.navigation.CheckoutKitNavHost import com.shopify.checkoutkit.androiddemo.common.navigation.Screen import com.shopify.checkoutkit.androiddemo.common.ui.theme.CheckoutKitSampleTheme +import com.shopify.checkoutkit.androiddemo.e2e.E2ENavigationEffect import com.shopify.checkoutkit.androiddemo.e2e.E2ETestIds import com.shopify.checkoutkit.androiddemo.logs.LogsViewModel import com.shopify.checkoutkit.androiddemo.settings.SettingsUiState @@ -96,6 +97,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 snackbarHostState = remember { SnackbarHostState() } diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/MainActivity.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/MainActivity.kt index c46885e0b..b43a5b2db 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/MainActivity.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/MainActivity.kt @@ -1,6 +1,7 @@ package com.shopify.checkoutkit.androiddemo 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.checkoutkit.androiddemo.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/checkoutkit/androiddemo/cart/CartView.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/cart/CartView.kt index 162c61bd8..f95f13033 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/cart/CartView.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/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.checkoutkit.androiddemo.common.components.MoneyText import com.shopify.checkoutkit.androiddemo.common.components.ProgressIndicator import com.shopify.checkoutkit.androiddemo.common.ui.theme.horizontalPadding import com.shopify.checkoutkit.androiddemo.common.ui.theme.verticalPadding +import com.shopify.checkoutkit.androiddemo.e2e.E2ETestIds import com.shopify.checkoutkit.androiddemo.settings.data.CheckoutPresentationMode @Composable @@ -242,6 +244,7 @@ private fun CheckoutButton( Button( shape = RectangleShape, onClick = onClick, + modifier = Modifier.testTag(E2ETestIds.Cart.CHECKOUT_BUTTON), ) { Column { Text( @@ -252,6 +255,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/checkoutkit/androiddemo/e2e/E2EControlLinkHandler.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2EControlLinkHandler.kt new file mode 100644 index 000000000..e15d55eb5 --- /dev/null +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2EControlLinkHandler.kt @@ -0,0 +1,16 @@ +package com.shopify.checkoutkit.androiddemo.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/checkoutkit/androiddemo/e2e/E2EController.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2EController.kt new file mode 100644 index 000000000..c6f81f7aa --- /dev/null +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2EController.kt @@ -0,0 +1,60 @@ +package com.shopify.checkoutkit.androiddemo.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/checkoutkit/androiddemo/e2e/E2ENavigation.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2ENavigation.kt new file mode 100644 index 000000000..ea7e8d5e1 --- /dev/null +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2ENavigation.kt @@ -0,0 +1,26 @@ +package com.shopify.checkoutkit.androiddemo.e2e + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.navigation.NavController +import com.shopify.checkoutkit.androiddemo.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/checkoutkit/androiddemo/e2e/E2ESampleAppTarget.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2ESampleAppTarget.kt new file mode 100644 index 000000000..a6dca8f0e --- /dev/null +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2ESampleAppTarget.kt @@ -0,0 +1,59 @@ +package com.shopify.checkoutkit.androiddemo.e2e + +import com.shopify.checkoutkit.androiddemo.cart.CartViewModel +import com.shopify.checkoutkit.androiddemo.common.ID +import com.shopify.checkoutkit.androiddemo.common.navigation.Screen +import com.shopify.checkoutkit.androiddemo.products.product.data.ProductRepository +import com.shopify.checkoutkit.androiddemo.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/checkoutkit/androiddemo/e2e/E2ETestIds.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2ETestIds.kt index 9adcefc03..2b1b8686b 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2ETestIds.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkoutkit/androiddemo/e2e/E2ETestIds.kt @@ -2,4 +2,9 @@ package com.shopify.checkoutkit.androiddemo.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/checkoutkit/androiddemo/e2e/E2EControllerTest.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/e2e/E2EControllerTest.kt new file mode 100644 index 000000000..bf823eb12 --- /dev/null +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/e2e/E2EControllerTest.kt @@ -0,0 +1,135 @@ +package com.shopify.checkoutkit.androiddemo.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.checkoutkit.androiddemo://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/checkoutkit/androiddemo/e2e/E2ETestIdsTest.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/e2e/E2ETestIdsTest.kt index a4961f7d9..c1b7d1267 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/e2e/E2ETestIdsTest.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/test/java/com/shopify/checkoutkit/androiddemo/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 e517f5b98..5d08c64e7 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)