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)