From c576c3096aaf2868c5833f707290bc772c945faf Mon Sep 17 00:00:00 2001 From: Ryan Brooks Date: Wed, 23 Oct 2024 20:56:57 -0700 Subject: [PATCH 1/4] Prepare for Snapshots release 1.3.0-beta02 --- gradle/libs.versions.toml | 2 +- snapshots/CHANGELOG.md | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 22828f0a..4d2ae24a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ mockito = "4.0.0" emerge-gradle-plugin = "4.0.2" emerge-performance = "2.1.2" emerge-reaper = "1.0.0-rc03" -emerge-snapshots = "1.3.0-rc01" +emerge-snapshots = "1.3.0-beta02" emerge-distribution = "0.0.1" activity-ktx = "1.9.2" navigation-compose = "2.8.2" diff --git a/snapshots/CHANGELOG.md b/snapshots/CHANGELOG.md index e3a1d462..04dc63c2 100644 --- a/snapshots/CHANGELOG.md +++ b/snapshots/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to snapshots & snapshots-processor will be documented in thi The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## 1.3.0-rc01 - 2024-10-22 +## 1.3.0-beta02 - 2024-10-23 > [!IMPORTANT] > Note: Existing previews with custom `device` specs will have expected diffs. @@ -14,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > commit with 1.3. > All future diffs should be stable once rebased! +- Bug fixes for PreviewParameters with nested types (i.e. `List<*>`) and + primitives. [#290](https://github.com/EmergeTools/emerge-android/pull/290) + +## 1.3.0-rc01 - 2024-10-22 + - Support `@PreviewParameter` annotated params. [#271](https://github.com/EmergeTools/emerge-android/pull/271) - Remove whitespace from parameterized test names to support From 2fd84fe15b3a1af51f11e75ba313af51ec514351 Mon Sep 17 00:00:00 2001 From: Ryan Brooks Date: Thu, 24 Oct 2024 20:51:24 -0700 Subject: [PATCH 2/4] Update compose invoker to better replicate PreviewActivity behavior --- .../snapshots/compose/ComposableInvoker.kt | 299 +++++++++++------- .../snapshots/compose/ComposeSnapshotter.kt | 85 ++--- .../previewparams/PreviewParamUtils.kt | 77 +++++ 3 files changed, 277 insertions(+), 184 deletions(-) create mode 100644 snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/previewparams/PreviewParamUtils.kt diff --git a/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/ComposableInvoker.kt b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/ComposableInvoker.kt index b1cba370..1b94d143 100644 --- a/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/ComposableInvoker.kt +++ b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/ComposableInvoker.kt @@ -1,149 +1,208 @@ package com.emergetools.snapshots.compose import android.util.Log -import androidx.compose.runtime.reflect.ComposableMethod -import androidx.compose.runtime.reflect.getDeclaredComposableMethod -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import java.lang.reflect.ParameterizedType - -/** - * Handles reflection-based invocation of Composable methods, - * including support for preview parameters. - */ +import androidx.compose.runtime.Composer +import java.lang.reflect.Method +import java.lang.reflect.Modifier +import kotlin.math.ceil + +// Inspired by https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui-tooling/src/jvmMain/kotlin/androidx/compose/ui/tooling/ComposableInvoker.jvm.kt object ComposableInvoker { private const val TAG = "ComposableInvoker" - /** - * Finds and returns the appropriate ComposableMethod for the given parameters. - */ - fun findComposableMethod( - klass: Class<*>, + fun invokeComposable( + className: String, methodName: String, - previewProviderClass: Class>? = null - ): ComposableMethod { - val composableMethod = previewProviderClass?.let { previewProvider -> - Log.d(TAG, "Looking for parameterized composable method: $methodName in class: ${klass.name}") - - // Find the type argument for PreviewParameterProvider - val providerType = findPreviewParameterType(previewProvider) - ?: throw IllegalArgumentException("Unable to determine type argument for PreviewParameterProvider") - - // Try to find the method with either the primitive or wrapper type - tryGetComposableMethod(klass, methodName, providerType) - ?: throw NoSuchMethodException("Could not find composable method: $methodName") - } ?: run { - Log.d(TAG, "Looking for composable method: $methodName in class: ${klass.name}") - klass.getDeclaredComposableMethod(methodName) - } - return composableMethod.also { - val backingMethod = composableMethod.asMethod() - if (!backingMethod.isAccessible) { - Log.i(TAG, "Marking composable method $methodName as accessible") - backingMethod.isAccessible = true + composer: Composer, + vararg args: Any? + ) { + try { + val composableClass = Class.forName(className) + val method = composableClass.findComposableMethod(methodName, *args) + ?: throw NoSuchMethodException("Composable $className.$methodName not found") + method.isAccessible = true + + if (Modifier.isStatic(method.modifiers)) { + // This is a top level or static method + method.invokeComposableMethod(null, composer, *args) + } else { + // The method is part of a class. We try to instantiate the class with an empty + // constructor. + val instance = composableClass.getConstructor().newInstance() + method.invokeComposableMethod(instance, composer, *args) } + } catch (e: Exception) { + Log.w(TAG, "Failed to invoke Composable Method '$className.$methodName'") + throw e } } /** - * Gets parameters from a PreviewParameterProvider, respecting the limit if specified. + * Compares the parameter types taken from the composable method and checks if they are all + * compatible with the types taken from the PreviewParameterProvider. + * + * @param composableMethodTypes types of the Composable Method + * @param previewParameterTypes types defined in the PreviewParameterProvider + * @return true if every `composableMethodTypes[n]` are equal or assignable to + * `previewParameterTypes[n]`. */ - fun getPreviewParameters( - parameterProviderClass: Class>?, - limit: Int? = null - ): List { - return parameterProviderClass?.let { - val params = getPreviewProviderParameters(it) - limit?.let { maxLimit -> params.take(maxLimit) } ?: params - } ?: listOf(null) - } + private fun areParameterTypesCompatible( + composableMethodTypes: Array>, + previewParameterTypes: Array> + ): Boolean = + composableMethodTypes.size == previewParameterTypes.size && + composableMethodTypes.mapIndexed { index, clazz -> + val composableParameterType = previewParameterTypes[index] + // We can't use [isAssignableFrom] if we have java primitives. + // Java primitives aren't equal to Java classes: + // comparing int with kotlin.Int or java.lang.Integer will return false. + // However, if we convert them both to a KClass they can be compared: + // int and java.lang.Integer will be both converted to Int + // see more: + // https://docs.oracle.com/javase/6/docs/api/java/lang/Class.html#isAssignableFrom(java.lang.Class) + clazz.kotlin == composableParameterType.kotlin || + clazz.isAssignableFrom(composableParameterType) + }.all { it } - private fun findPreviewParameterType(previewProviderClass: Class<*>): Class<*>? { - // Look through all interfaces implemented by the class - for (genericInterface in previewProviderClass.genericInterfaces) { - if (genericInterface is ParameterizedType && - genericInterface.rawType == PreviewParameterProvider::class.java - ) { - // Handle different types of type arguments - return when (val typeArg = genericInterface.actualTypeArguments.firstOrNull()) { - // Direct class reference - is Class<*> -> typeArg - // Nested generic type (e.g., List) - is ParameterizedType -> typeArg.rawType as? Class<*> - // Other cases (wildcards, type variables, etc.) - else -> null - } - } - } - - // Check superclass if the interface isn't found in the current class - val superclass = previewProviderClass.genericSuperclass - if (superclass is ParameterizedType) { - return findPreviewParameterType(superclass.rawType as Class<*>) - } + /** + * Takes the declared methods and accounts for compatible types so the signature does not need + * to exactly match. This allows finding method calls that use subclasses as parameters instead + * of the exact types. + * + * @return the compatible [Method] with the name [methodName] + * @throws NoSuchMethodException if the method is not found + */ + private fun Array.findCompatibleComposeMethod( + methodName: String, + vararg args: Class<*> + ): Method = + firstOrNull { + (methodName == it.name || it.name.startsWith("$methodName-")) && + // Methods with inlined classes as parameter will have the name mangled + // so we need to check for methodName-xxxx as well + areParameterTypesCompatible(it.parameterTypes, arrayOf(*args)) + } ?: throw NoSuchMethodException("$methodName not found") - return null + private inline fun T.dup(count: Int): Array { + return (0 until count).map { this }.toTypedArray() } - @Suppress("SwallowedException") - private fun tryGetComposableMethod( - klass: Class<*>, + /** + * Find the given method by name. If the method has parameters, this function will try to find + * the version that accepts default parameters. + * + * @return null if the composable method is not found. Returns the [Method] otherwise. + */ + private fun Class<*>.findComposableMethod( methodName: String, - parameterType: Class<*> - ): ComposableMethod? { - // Map of primitive types to their Java object wrapper classes - val primitiveToWrapper: Map?, Class<*>?> = mapOf( - Int::class.javaPrimitiveType to Integer::class.java, - Long::class.javaPrimitiveType to Long::class.java, - Double::class.javaPrimitiveType to Double::class.java, - Float::class.javaPrimitiveType to Float::class.java, - Boolean::class.javaPrimitiveType to Boolean::class.java, - Byte::class.javaPrimitiveType to Byte::class.java, - Short::class.javaPrimitiveType to Short::class.java, - Char::class.javaPrimitiveType to Character::class.java - ) - - // Map of wrapper classes to their primitive types - val wrapperToPrimitive = primitiveToWrapper.entries.associate { (k, v) -> v to k } - + vararg previewParamArgs: Any? + ): Method? { + val argsArray: Array> = + previewParamArgs.mapNotNull { it?.javaClass }.toTypedArray() return try { - // First try with the original type - klass.getDeclaredComposableMethod(methodName, parameterType) - } catch (e: NoSuchMethodException) { - Log.d( - TAG, - "Method $methodName not found with parameter type: $parameterType, trying primitive/wrapper type" + // without defaults + val changedParamsCount = changedParamCount(argsArray.size, 0) + val changedParams = Int::class.java.dup(changedParamsCount) + declaredMethods.findCompatibleComposeMethod( + methodName, + *argsArray, + Composer::class.java, // composer param + *changedParams // changed param ) - // If that fails, try with the corresponding primitive/wrapper type - val alternateType: Class<*>? = when { - parameterType.isPrimitive -> primitiveToWrapper[parameterType] - wrapperToPrimitive.containsKey(parameterType) -> wrapperToPrimitive[parameterType] - else -> null + } catch (e: ReflectiveOperationException) { + try { + declaredMethods.find { + // Methods with inlined classes as parameter will have the name mangled + // so we need to check for methodName-xxxx as well + it.name == methodName || it.name.startsWith("$methodName-") + } + } catch (e: ReflectiveOperationException) { + null } + } + } + + /** + * Calls the method on the given [instance]. If the method accepts default values, this function + * will call it with the correct options set. + */ + private fun Method.invokeComposableMethod( + instance: Any?, + composer: Composer, + vararg args: Any? + ): Any? { + val composerIndex = parameterTypes.indexOfLast { it == Composer::class.java } + val realParams = composerIndex + val thisParams = if (instance != null) 1 else 0 + val changedParams = changedParamCount(realParams, thisParams) + val totalParamsWithoutDefaults = + realParams + + 1 + // composer + changedParams + val totalParams = parameterTypes.size + val isDefault = totalParams != totalParamsWithoutDefaults + val defaultParams = if (isDefault) defaultParamCount(realParams) else 0 + + check( + realParams + + 1 + // composer + changedParams + + defaultParams == totalParams + ) { + "params don't add up to total params" + } + + val changedStartIndex = composerIndex + 1 + val defaultStartIndex = changedStartIndex + changedParams - alternateType?.let { - try { - klass.getDeclaredComposableMethod(methodName, it) - } catch (e: NoSuchMethodException) { - Log.d( - TAG, - "Method $methodName not found with primitive/wrapped type: $parameterType, returning null" - ) - null + val arguments = + Array(totalParams) { idx -> + when (idx) { + // pass in "empty" value for all real parameters since we will be using + // defaults. + in 0 until realParams -> + args.getOrElse(idx) { parameterTypes[idx].getDefaultValue() } + // the composer is the first synthetic parameter + composerIndex -> composer + // since this is the root we don't need to be anything unique. 0 should suffice. + // changed parameters should be 0 to indicate "uncertain" + in changedStartIndex until defaultStartIndex -> 0 + // Default values mask, all parameters set to use defaults + in defaultStartIndex until totalParams -> 0b111111111111111111111 + else -> error("Unexpected index") } } + return invoke(instance, *arguments) + } + + /** + * Returns the default value for the [Class] type. This will be 0 for numeric types, false for + * boolean, '0' for char and null for object references. + */ + private fun Class<*>.getDefaultValue(): Any? = + when (name) { + "int" -> 0 + "short" -> 0.toShort() + "byte" -> 0.toByte() + "long" -> 0.toLong() + "double" -> 0.toDouble() + "float" -> 0.toFloat() + "boolean" -> false + "char" -> 0.toChar() + else -> null } + + private const val SLOTS_PER_INT = 10 + private const val BITS_PER_INT = 31 + + private fun changedParamCount(realValueParams: Int, thisParams: Int): Int { + if (realValueParams == 0) return 1 + val totalParams = realValueParams + thisParams + return ceil(totalParams.toDouble() / SLOTS_PER_INT.toDouble()).toInt() } - private fun getPreviewProviderParameters( - parameterProviderClass: Class> - ): List { - val constructor = parameterProviderClass.constructors - .singleOrNull { it.parameterTypes.isEmpty() } - ?.apply { isAccessible = true } - ?: throw IllegalArgumentException( - "PreviewParameterProvider constructor can not have parameters" - ) - val params = constructor.newInstance() as PreviewParameterProvider<*> - return params.values.toList() + private fun defaultParamCount(realValueParams: Int): Int { + return ceil(realValueParams.toDouble() / BITS_PER_INT.toDouble()).toInt() } } + diff --git a/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/ComposeSnapshotter.kt b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/ComposeSnapshotter.kt index e4cfd076..6287051e 100644 --- a/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/ComposeSnapshotter.kt +++ b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/ComposeSnapshotter.kt @@ -8,64 +8,25 @@ import android.view.View import android.view.ViewGroup import android.view.ViewGroup.LayoutParams import androidx.compose.runtime.currentComposer -import androidx.compose.runtime.reflect.ComposableMethod import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.tooling.PreviewActivity -import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.IntSize import com.emergetools.snapshots.EmergeSnapshots import com.emergetools.snapshots.SnapshotErrorType +import com.emergetools.snapshots.compose.previewparams.PreviewParamUtils import com.emergetools.snapshots.shared.ComposePreviewSnapshotConfig -import java.lang.reflect.Modifier -@Suppress("TooGenericExceptionCaught", "ThrowsCount") +@Suppress("TooGenericExceptionCaught") fun snapshotComposable( snapshotRule: EmergeSnapshots, activity: PreviewActivity, previewConfig: ComposePreviewSnapshotConfig, ) { try { - val klass = Class.forName(previewConfig.fullyQualifiedClassName) - Log.d( - EmergeComposeSnapshotReflectiveParameterizedInvoker.TAG, - "Found class for ${previewConfig.fullyQualifiedClassName}: ${klass.name}" - ) - val methodName = previewConfig.originalFqn.substringAfterLast(".") - - val previewProviderClass: Class>? = - previewConfig.previewParameter?.providerClassFqn?.let { - val clazz = Class.forName(it) - require(PreviewParameterProvider::class.java.isAssignableFrom(clazz)) { - "Preview parameter provider class must implement PreviewParameterProvider" - } - clazz as Class> - } - - // Use ComposableInvoker to find and prepare the method - val composableMethod = ComposableInvoker.findComposableMethod( - klass = klass, - methodName = methodName, - previewProviderClass = previewProviderClass - ) - - // Get preview parameters - val previewParams = ComposableInvoker.getPreviewParameters( - parameterProviderClass = previewProviderClass, - limit = previewConfig.previewParameter?.limit - ) - - Log.d( - EmergeComposeSnapshotReflectiveParameterizedInvoker.TAG, - "Found ${previewParams.size} preview parameters for ${previewConfig.originalFqn}" - ) - snapshot( activity = activity, snapshotRule = snapshotRule, previewConfig = previewConfig, - composableMethod = composableMethod, - composableClass = klass, - previewParams = previewParams, ) } catch (e: Exception) { Log.e( @@ -85,26 +46,26 @@ fun snapshotComposable( private fun snapshot( activity: Activity, snapshotRule: EmergeSnapshots, - composableMethod: ComposableMethod, - composableClass: Class<*>, previewConfig: ComposePreviewSnapshotConfig, - previewParams: List = listOf(null), ) { - previewParams.forEachIndexed { index, prevParam -> - val composeView = ComposeView(activity) - composeView.layoutParams = - LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + // If no preview params, fallback to a single array of one null item to ensure we + // snapshot the composable. + val previewParameters = + PreviewParamUtils.getPreviewProviderParameters(previewConfig) ?: arrayOf(null) + + val deviceSpec = configToDeviceSpec(previewConfig) + for (index in previewParameters.indices) { + val prevParam = previewParameters[index] Log.d( EmergeComposeSnapshotReflectiveParameterizedInvoker.TAG, "Invoking composable method with preview parameter: $prevParam" ) - val args = buildList { - prevParam?.let(this::add) - add(0) - }.toTypedArray() - - val deviceSpec = configToDeviceSpec(previewConfig) + val composeView = ComposeView(activity) + composeView.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + val args = emptyArray().apply { + prevParam?.let(this::plus) + } val saveablePreviewConfig = previewConfig.copy( previewParameter = previewConfig.previewParameter?.copy(index = index) @@ -117,16 +78,12 @@ private fun snapshot( composeView.setContent { SnapshotVariantProvider(previewConfig, deviceSpec?.scalingFactor) { - @Suppress("SpreadOperator") - if (Modifier.isStatic(composableMethod.asMethod().modifiers)) { - // This is a top level or static method - composableMethod.invoke(currentComposer, null, *args) - } else { - // The method is part of a class. We try to instantiate the class with an empty - // constructor. - val instance = composableClass.getConstructor().newInstance() - composableMethod.invoke(currentComposer, instance, *args) - } + ComposableInvoker.invokeComposable( + className = previewConfig.fullyQualifiedClassName, + methodName = previewConfig.originalFqn.substringAfterLast("."), + composer = currentComposer, + args = args, + ) } } diff --git a/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/previewparams/PreviewParamUtils.kt b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/previewparams/PreviewParamUtils.kt new file mode 100644 index 00000000..77653402 --- /dev/null +++ b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/previewparams/PreviewParamUtils.kt @@ -0,0 +1,77 @@ +package com.emergetools.snapshots.compose.previewparams + +import android.util.Log +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.emergetools.snapshots.shared.ComposePreviewSnapshotConfig + +// Inspired by https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui-tooling/src/androidMain/kotlin/androidx/compose/ui/tooling/PreviewUtils.android.kt +object PreviewParamUtils { + private const val TAG = "PreviewParamUtils" + + internal fun getPreviewProviderParameters( + previewConfig: ComposePreviewSnapshotConfig + ): Array? { + if (previewConfig.previewParameter == null) { + Log.d(TAG, "No PreviewParameterProvider found") + return null + } + + if (previewConfig.previewParameter?.providerClassFqn.isNullOrEmpty()) { + Log.e(TAG, "PreviewParameterProvider class name is empty") + return null + } + + val paramProviderClass = try { + Class.forName(previewConfig.previewParameter!!.providerClassFqn) as? Class> + } catch (e: ClassNotFoundException) { + Log.e( + TAG, + "Unable to find PreviewProvider '${previewConfig.previewParameter!!.providerClassFqn}'", + e + ) + return null + } + + if (paramProviderClass == null) { + Log.e( + TAG, + "PreviewProvider '${previewConfig.previewParameter!!.providerClassFqn}' is not a PreviewParameterProvider" + ) + return null + } + + val constructor = paramProviderClass.constructors + .singleOrNull { it.parameterTypes.isEmpty() } + ?.apply { isAccessible = true } + ?: throw IllegalArgumentException( + "PreviewParameterProvider constructor can not" + " have parameters" + ) + val params = constructor.newInstance() as PreviewParameterProvider<*> + + return Array(params.count) { params.values.iterator().next() } + .map { unwrapIfInline(it) } + .toTypedArray() + } + + /** + * Checks if the object is of inlined value type. If yes, unwraps and returns the packed value If + * not, returns the object as it is + */ + private fun unwrapIfInline(classToCheck: Any?): Any? { + // At the moment is not possible to use classToCheck::class.isValue, even if it works when + // running tests, is not working once trying to run the Preview instead. + // it would be possible in the future. + // see also https://kotlinlang.org/docs/inline-classes.html + if (classToCheck != null && classToCheck::class.java.annotations.any { it is JvmInline }) { + // The first primitive declared field in the class is the value wrapped + val fieldName: String = + classToCheck::class.java.declaredFields.first { it.type.isPrimitive }.name + return classToCheck::class + .java + .getDeclaredField(fieldName) + .also { it.isAccessible = true } + .get(classToCheck) + } + return classToCheck + } +} From 80ec93dbe0708d458fee57e1e9701793bfbc9319 Mon Sep 17 00:00:00 2001 From: Ryan Brooks Date: Thu, 24 Oct 2024 16:31:01 -0700 Subject: [PATCH 3/4] Make compose dependencies compileOnly to ensure we rely on target app's compose version --- snapshots/snapshots/build.gradle.kts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/snapshots/snapshots/build.gradle.kts b/snapshots/snapshots/build.gradle.kts index d1ee69e4..3130cc99 100644 --- a/snapshots/snapshots/build.gradle.kts +++ b/snapshots/snapshots/build.gradle.kts @@ -52,11 +52,11 @@ dependencies { implementation(libs.junit) implementation(libs.kotlinx.serialization) - api(platform(libs.compose.bom)) - api(libs.compose.runtime) - api(libs.compose.ui) - api(libs.compose.ui.tooling) - api(libs.compose.foundation.android) + compileOnly(platform(libs.compose.bom)) + compileOnly(libs.compose.runtime) + compileOnly(libs.compose.ui) + compileOnly(libs.compose.ui.tooling) + compileOnly(libs.compose.foundation.android) api(projects.snapshots.snapshotsShared) api(libs.androidx.test.core) From 9024ff3d6d0bfbd11e91836c2e7ec3b49353e163 Mon Sep 17 00:00:00 2001 From: Ryan Brooks Date: Thu, 24 Oct 2024 17:09:10 -0700 Subject: [PATCH 4/4] Add test deps for unit tests --- snapshots/snapshots/build.gradle.kts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/snapshots/snapshots/build.gradle.kts b/snapshots/snapshots/build.gradle.kts index 3130cc99..04136d2c 100644 --- a/snapshots/snapshots/build.gradle.kts +++ b/snapshots/snapshots/build.gradle.kts @@ -67,6 +67,11 @@ dependencies { api(libs.compose.ui.test.junit) testImplementation(libs.junit) + testImplementation(platform(libs.compose.bom)) + testImplementation(libs.compose.runtime) + testImplementation(libs.compose.ui) + testImplementation(libs.compose.ui.tooling) + testImplementation(libs.compose.foundation.android) } tasks.register("generateMetaInfVersion") {