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)