First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.tests.testutils"
buildFeatures {
buildConfig = true
}
}
dependencies {
implementation(libs.test.junit)
implementation(libs.test.truth)
implementation(libs.coroutines.test)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
implementation(projects.services.toolbox.api)
implementation(libs.test.turbine)
implementation(libs.molecule.runtime)
implementation(libs.androidx.compose.ui.test.junit)
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.tests.testutils
import io.element.android.libraries.androidutils.metadata.isInDebug
import org.junit.Assert.assertThrows
/**
* Assert that the lambda throws only on debug mode.
*/
fun assertThrowsInDebug(lambda: () -> Any?) {
if (isInDebug) {
assertThrows(IllegalStateException::class.java) {
lambda()
}
} else {
lambda()
}
}

View File

@@ -0,0 +1,126 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.tests.testutils
class EnsureCalledOnce : () -> Unit {
private var counter = 0
override fun invoke() {
counter++
}
fun assertSuccess() {
if (counter != 1) {
throw AssertionError("Expected to be called once, but was called $counter times")
}
}
}
class EnsureCalledTimes(val times: Int) : () -> Unit {
private var counter = 0
override fun invoke() {
counter++
}
fun assertSuccess() {
if (counter != times) {
throw AssertionError("Expected to be called $times, but was called $counter times")
}
}
}
fun ensureCalledOnce(block: (callback: () -> Unit) -> Unit) {
val callback = EnsureCalledOnce()
block(callback)
callback.assertSuccess()
}
fun ensureCalledTimes(times: Int, block: (callback: () -> Unit) -> Unit) {
val callback = EnsureCalledTimes(times)
block(callback)
callback.assertSuccess()
}
class EnsureCalledOnceWithParam<T, R>(
private val expectedParam: T,
private val result: R,
) : (T) -> R {
private var counter = 0
override fun invoke(p1: T): R {
if (p1 != expectedParam) {
throw AssertionError("Expected to be called with $expectedParam, but was called with $p1")
}
counter++
return result
}
fun assertSuccess() {
if (counter != 1) {
throw AssertionError("Expected to be called once, but was called $counter times")
}
}
}
class EnsureCalledOnceWithTwoParams<T, U>(
private val expectedParam1: T,
private val expectedParam2: U,
) : (T, U) -> Unit {
private var counter = 0
override fun invoke(p1: T, p2: U) {
if (p1 != expectedParam1 || p2 != expectedParam2) {
throw AssertionError("Expected to be called with $expectedParam1 and $expectedParam2, but was called with $p1 and $p2")
}
counter++
}
fun assertSuccess() {
if (counter != 1) {
throw AssertionError("Expected to be called once, but was called $counter times")
}
}
}
class EnsureCalledOnceWithTwoParamsAndResult<T, U, R>(
private val expectedParam1: T,
private val expectedParam2: U,
private val result: R,
) : (T, U) -> R {
private var counter = 0
override fun invoke(p1: T, p2: U): R {
if (p1 != expectedParam1 || p2 != expectedParam2) {
throw AssertionError("Expected to be called with $expectedParam1 and $expectedParam2, but was called with $p1 and $p2")
}
counter++
return result
}
fun assertSuccess() {
if (counter != 1) {
throw AssertionError("Expected to be called once, but was called $counter times")
}
}
}
/**
* Shortcut for [<T, R> ensureCalledOnceWithParam] with Unit result.
*/
fun <T> ensureCalledOnceWithParam(param: T, block: (callback: EnsureCalledOnceWithParam<T, Unit>) -> Unit) {
ensureCalledOnceWithParam(param, block, Unit)
}
fun <T, R> ensureCalledOnceWithParam(param: T, block: (callback: EnsureCalledOnceWithParam<T, R>) -> R, result: R) {
val callback = EnsureCalledOnceWithParam(param, result)
block(callback)
callback.assertSuccess()
}
fun <P1, P2> ensureCalledOnceWithTwoParams(param1: P1, param2: P2, block: (callback: EnsureCalledOnceWithTwoParams<P1, P2>) -> Unit) {
val callback = EnsureCalledOnceWithTwoParams(param1, param2)
block(callback)
callback.assertSuccess()
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.tests.testutils
import io.element.android.tests.testutils.lambda.lambdaError
class EnsureNeverCalled : () -> Unit {
override fun invoke() {
lambdaError()
}
}
class EnsureNeverCalledWithParam<T> : (T) -> Unit {
override fun invoke(p1: T) {
lambdaError("Should not be called and is called with $p1")
}
}
class EnsureNeverCalledWithParamAndResult<T, R> : (T) -> R {
override fun invoke(p1: T): R {
lambdaError("Should not be called and is called with $p1")
}
}
class EnsureNeverCalledWithTwoParams<T, U> : (T, U) -> Unit {
override fun invoke(p1: T, p2: U) {
lambdaError("Should not be called and is called with $p1 and $p2")
}
}
class EnsureNeverCalledWithTwoParamsAndResult<T, U, R> : (T, U) -> R {
override fun invoke(p1: T, p2: U): R {
lambdaError("Should not be called and is called with $p1 and $p2")
}
}
class EnsureNeverCalledWithThreeParams<T, U, V> : (T, U, V) -> Unit {
override fun invoke(p1: T, p2: U, p3: V) {
lambdaError("Should not be called and is called with $p1, $p2 and $p3")
}
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.tests.testutils
import com.google.common.truth.Truth.assertThat
class EventsRecorder<T>(
private val expectEvents: Boolean = true
) : (T) -> Unit {
private val events = mutableListOf<T>()
override fun invoke(event: T) {
if (expectEvents) {
events.add(event)
} else {
throw AssertionError("Unexpected event: $event")
}
}
fun assertEmpty() {
assertThat(events).isEmpty()
}
fun assertSingle(event: T) {
assertList(listOf(event))
}
fun assertList(expectedEvents: List<T>) {
assertThat(events).isEqualTo(expectedEvents)
}
fun assertSize(size: Int) {
assertThat(events.size).isEqualTo(size)
}
fun assertTrue(index: Int, predicate: (T) -> Boolean) {
assertThat(predicate(events[index])).isTrue()
}
fun clear() {
events.clear()
}
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.tests.testutils
import androidx.test.platform.app.InstrumentationRegistry
import io.element.android.services.toolbox.api.strings.StringProvider
class InstrumentationStringProvider : StringProvider {
private val resource = InstrumentationRegistry.getInstrumentation().context.resources
override fun getString(resId: Int): String {
return resource.getString(resId)
}
override fun getString(resId: Int, vararg formatArgs: Any?): String {
return resource.getString(resId, *formatArgs)
}
override fun getQuantityString(resId: Int, quantity: Int, vararg formatArgs: Any?): String {
return resource.getQuantityString(resId, quantity, *formatArgs)
}
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.tests.testutils
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeout
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
/**
* Workaround for https://github.com/cashapp/molecule/issues/249.
* This functions should be removed/deprecated right after we find a proper fix.
*/
suspend inline fun <T> simulateLongTask(lambda: () -> T): T {
delay(1)
return lambda()
}
/**
* Can be used for testing events in Presenter, where the event does not emit new state.
* If the (virtual) timeout is passed, we release the latch manually.
*/
suspend fun awaitWithLatch(timeout: Duration = 300.milliseconds, block: (CompletableDeferred<Unit>) -> Unit) {
val latch = CompletableDeferred<Unit>()
try {
withTimeout(timeout) {
latch.also(block).await()
}
} catch (exception: TimeoutCancellationException) {
latch.complete(Unit)
}
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.tests.testutils
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.flow.MutableStateFlow
class MutablePresenter<State>(initialState: State) : Presenter<State> {
private val stateFlow = MutableStateFlow(initialState)
fun updateState(state: State) {
stateFlow.value = state
}
@Composable
override fun present(): State {
return stateFlow.collectAsState().value
}
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.tests.testutils
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test
import io.element.android.libraries.architecture.Presenter
import org.junit.Assert.fail
import kotlin.time.Duration
suspend fun <State> Presenter<State>.test(
timeout: Duration? = null,
name: String? = null,
validate: suspend TurbineTestContext<State>.() -> Unit,
) {
try {
moleculeFlow(RecompositionMode.Immediate) {
present()
}.test(timeout, name, validate)
} catch (t: Throwable) {
if (t::class.simpleName == "KotlinReflectionInternalError") {
// Give a more explicit error to the developer
fail("""
It looks like you have an unconsumed event in your test.
If you get this error, it means that your test is missing to consume one or several events.
You can fix by consuming and check the event with `awaitItem()`, or you can also invoke
`cancelAndIgnoreRemainingEvents()`.
""".trimIndent())
}
throw t
}
}

View File

@@ -0,0 +1,70 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.tests.testutils
import app.cash.turbine.Event
import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.withTurbineTimeout
import io.element.android.libraries.core.bool.orFalse
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/**
* Consume all items until timeout is reached waiting for an event or we receive terminal event.
* The timeout is applied for each event.
* @return the list of consumed items.
*/
suspend fun <T : Any> ReceiveTurbine<T>.consumeItemsUntilTimeout(timeout: Duration = 100.milliseconds): List<T> {
return consumeItemsUntilPredicate(timeout, ignoreTimeoutError = true) { false }
}
/**
* Consume all items which are emitted sequentially.
* Use the smallest timeout possible internally to avoid wasting time.
* Same as calling skipItems(x) and then awaitItem() but without assumption on the number of items.
* @return the last item emitted.
*/
suspend fun <T : Any> ReceiveTurbine<T>.awaitLastSequentialItem(): T {
return consumeItemsUntilTimeout(1.milliseconds).last()
}
/**
* Consume items until predicate is true, or timeout is reached waiting for an event, or we receive terminal event.
* The timeout is applied for each event.
* @return the list of consumed items.
*/
suspend fun <T : Any> ReceiveTurbine<T>.consumeItemsUntilPredicate(
timeout: Duration = 3.seconds,
ignoreTimeoutError: Boolean = false,
predicate: (T) -> Boolean,
): List<T> {
val items = ArrayList<T>()
var exitLoop = false
try {
while (!exitLoop) {
when (val event = withTurbineTimeout(timeout) { awaitEvent() }) {
is Event.Item<T> -> {
items.add(event.value)
exitLoop = predicate(event.value)
}
Event.Complete -> error("Unexpected complete")
is Event.Error -> throw event.throwable
}
}
} catch (assertionError: AssertionError) {
// TurbineAssertionError is internal :/, so rely on the message
if (assertionError.message?.startsWith("No value produced in").orFalse() && ignoreTimeoutError) {
// Timeout, ignore
} else {
throw assertionError
}
}
return items
}

View File

@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.tests.testutils
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import io.element.android.libraries.designsystem.utils.LocalUiTestMode
import org.junit.Assert.assertFalse
import org.junit.rules.TestRule
import kotlin.coroutines.CoroutineContext
object RobolectricDispatcherCleaner {
// HACK: Workaround for https://github.com/robolectric/robolectric/issues/7055#issuecomment-1551119229
fun clearAndroidUiDispatcher(pkg: String = "androidx.compose.ui.platform") {
val clazz = javaClass.classLoader!!.loadClass("$pkg.AndroidUiDispatcher")
val combinedContextClass = javaClass.classLoader!!.loadClass("kotlin.coroutines.CombinedContext")
val companionClazz = clazz.getDeclaredField("Companion").get(clazz)
val combinedContext = companionClazz.javaClass.getDeclaredMethod("getMain")
.invoke(companionClazz) as CoroutineContext
val androidUiDispatcher = combinedContextClass.getDeclaredField("element")
.apply { isAccessible = true }
.get(combinedContext)
.let { clazz.cast(it) }
var scheduledFrameDispatch = clazz.getDeclaredField("scheduledFrameDispatch")
.apply { isAccessible = true }
.getBoolean(androidUiDispatcher)
var scheduledTrampolineDispatch = clazz.getDeclaredField("scheduledTrampolineDispatch")
.apply { isAccessible = true }
.getBoolean(androidUiDispatcher)
val dispatchCallback = clazz.getDeclaredField("dispatchCallback")
.apply { isAccessible = true }
.get(androidUiDispatcher) as Runnable
if (scheduledFrameDispatch || scheduledTrampolineDispatch) {
dispatchCallback.run()
scheduledFrameDispatch = clazz.getDeclaredField("scheduledFrameDispatch")
.apply { isAccessible = true }
.getBoolean(androidUiDispatcher)
scheduledTrampolineDispatch = clazz.getDeclaredField("scheduledTrampolineDispatch")
.apply { isAccessible = true }
.getBoolean(androidUiDispatcher)
}
assertFalse(scheduledFrameDispatch)
assertFalse(scheduledTrampolineDispatch)
}
}
fun <R : TestRule, A : ComponentActivity> AndroidComposeTestRule<R, A>.setSafeContent(
clearAndroidUiDispatcher: Boolean = false,
content: @Composable () -> Unit,
) {
if (clearAndroidUiDispatcher) {
RobolectricDispatcherCleaner.clearAndroidUiDispatcher()
}
setContent {
CompositionLocalProvider(LocalUiTestMode provides true) {
content()
}
}
}

View File

@@ -0,0 +1,63 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.tests.testutils
import androidx.activity.ComponentActivity
import androidx.annotation.StringRes
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import io.element.android.libraries.ui.strings.CommonStrings
import org.junit.rules.TestRule
fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.clickOn(@StringRes res: Int) {
val text = activity.getString(res)
onNode(hasText(text) and hasClickAction())
.performClick()
}
fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.clickOnFirst(@StringRes res: Int) {
val text = activity.getString(res)
onAllNodes(hasText(text) and hasClickAction()).onFirst().performClick()
}
fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.clickOnLast(@StringRes res: Int) {
val text = activity.getString(res)
onAllNodes(hasText(text) and hasClickAction()).onFirst().performClick()
}
/**
* Press the back button in the app bar.
*/
fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.pressBack() {
val text = activity.getString(CommonStrings.action_back)
onNode(hasContentDescription(text)).performClick()
}
/**
* Press the back key.
*/
fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.pressBackKey() {
activity.onBackPressedDispatcher.onBackPressed()
}
fun SemanticsNodeInteractionsProvider.pressTag(tag: String) {
onNode(hasTestTag(tag)).performClick()
}
fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.assertNoNodeWithText(@StringRes res: Int) {
val text = activity.getString(res)
onNodeWithText(text).assertDoesNotExist()
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.tests.testutils
import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.test.core.app.ApplicationProvider
@Composable
fun withConfigurationAndContext(content: @Composable () -> Any?): Any? {
var result: Any? = null
CompositionLocalProvider(
LocalConfiguration provides Configuration(),
LocalContext provides ApplicationProvider.getApplicationContext(),
) {
result = content()
}
return result
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.tests.testutils
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
/**
* Create a [CoroutineDispatchers] instance for testing.
*
* @param useUnconfinedTestDispatcher If true, use [UnconfinedTestDispatcher] for all dispatchers.
* If false, use [StandardTestDispatcher] for all dispatchers.
*/
fun TestScope.testCoroutineDispatchers(
useUnconfinedTestDispatcher: Boolean = false,
): CoroutineDispatchers = when (useUnconfinedTestDispatcher) {
true -> CoroutineDispatchers(
io = UnconfinedTestDispatcher(testScheduler),
computation = UnconfinedTestDispatcher(testScheduler),
main = UnconfinedTestDispatcher(testScheduler),
)
false -> CoroutineDispatchers(
io = StandardTestDispatcher(testScheduler),
computation = StandardTestDispatcher(testScheduler),
main = StandardTestDispatcher(testScheduler),
)
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.tests.testutils
import timber.log.Timber
fun plantTestTimber() {
Timber.plant(object : Timber.Tree() {
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
println("$tag: $message")
}
})
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.tests.testutils
import kotlinx.coroutines.delay
suspend fun waitForPredicate(
delayBetweenAttemptsMillis: Long = 1,
maxNumberOfAttempts: Int = 20,
predicate: () -> Boolean,
) {
for (i in 0..maxNumberOfAttempts) {
if (predicate()) return
if (i < maxNumberOfAttempts) delay(delayBetweenAttemptsMillis)
}
throw AssertionError("Predicate was not true after $maxNumberOfAttempts attempts")
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.tests.testutils
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import kotlinx.coroutines.test.runTest
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
import kotlin.time.Duration.Companion.seconds
/**
* moleculeFlow can take time to initialise during the first test of any given
* test class.
*
* Applying this test rule ensures that the slow initialisation is not done
* inside runTest which has a short default timeout.
*/
class WarmUpRule : TestRule {
companion object {
init {
warmUpMolecule()
}
}
override fun apply(base: Statement, description: Description): Statement = base
}
private fun warmUpMolecule() {
runTest(timeout = 60.seconds) {
moleculeFlow(RecompositionMode.Immediate) {
// Do nothing
}.test {
awaitItem() // Await a Unit composition
}
}
}

View File

@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.tests.testutils
import android.annotation.SuppressLint
import androidx.compose.runtime.Composable
import androidx.compose.runtime.InternalComposeApi
import androidx.compose.runtime.Stable
import androidx.compose.runtime.currentComposer
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.compose.LocalLifecycleOwner
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test
import io.element.android.libraries.architecture.Presenter
/**
* Composable that provides a fake [LifecycleOwner] to the composition.
*/
@OptIn(InternalComposeApi::class)
@Stable
@Composable
fun <T> withFakeLifecycleOwner(
lifecycleOwner: FakeLifecycleOwner = FakeLifecycleOwner(),
block: @Composable () -> T
): T {
currentComposer.startProvider(LocalLifecycleOwner provides lifecycleOwner)
val state = block()
currentComposer.endProvider()
return state
}
/**
* Test a [Presenter] with a fake [LifecycleOwner].
*/
suspend fun <T> Presenter<T>.testWithLifecycleOwner(
lifecycleOwner: FakeLifecycleOwner = FakeLifecycleOwner(),
block: suspend TurbineTestContext<T>.() -> Unit
) {
moleculeFlow(RecompositionMode.Immediate) {
withFakeLifecycleOwner(lifecycleOwner) {
present()
}
}.test(validate = block)
}
@SuppressLint("VisibleForTests")
class FakeLifecycleOwner(initialState: Lifecycle.State? = null) : LifecycleOwner {
override val lifecycle: Lifecycle = LifecycleRegistry.createUnsafe(this)
init {
initialState?.let { givenState(it) }
}
fun givenState(state: Lifecycle.State) {
(lifecycle as LifecycleRegistry).currentState = state
}
}

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.tests.testutils.fake
import android.net.Uri
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.tests.testutils.lambda.lambdaError
class FakeTemporaryUriDeleter(
val deleteLambda: (uri: Uri?) -> Unit = { lambdaError() }
) : TemporaryUriDeleter {
override fun delete(uri: Uri?) {
deleteLambda(uri)
}
}

View File

@@ -0,0 +1,72 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.tests.testutils.lambda
fun assert(lambdaRecorder: LambdaRecorder): LambdaRecorderAssertions {
return lambdaRecorder.assertions()
}
class LambdaRecorderAssertions internal constructor(
private val parametersSequence: List<List<Any?>>,
) {
fun isCalledOnce(): CalledOnceParametersAssertions {
return CalledOnceParametersAssertions(
assertions = isCalledExactly(1)
)
}
fun isNeverCalled() {
isCalledExactly(0)
}
fun isCalledExactly(times: Int): ParametersAssertions {
if (parametersSequence.size != times) {
throw AssertionError("Expected to be called $times, but was called ${parametersSequence.size} times")
}
return ParametersAssertions(parametersSequence)
}
}
class CalledOnceParametersAssertions internal constructor(private val assertions: ParametersAssertions) {
fun with(vararg matchers: ParameterMatcher) {
assertions.withSequence(matchers.toList())
}
fun withNoParameter() {
assertions.withNoParameter()
}
}
class ParametersAssertions internal constructor(
private val parametersSequence: List<List<Any?>>
) {
fun withSequence(vararg matchersSequence: List<ParameterMatcher>) {
if (parametersSequence.size != matchersSequence.size) {
throw AssertionError("Lambda was called ${parametersSequence.size} times, but only ${matchersSequence.size} assertions were provided")
}
parametersSequence.zip(matchersSequence).forEachIndexed { invocationIndex, (parameters, matchers) ->
if (parameters.size != matchers.size) {
throw AssertionError("Expected ${matchers.size} parameters, but got ${parameters.size} parameters during invocation #$invocationIndex")
}
parameters.zip(matchers).forEachIndexed { paramIndex, (param, matcher) ->
if (!matcher.match(param)) {
throw AssertionError(
"Parameter #$paramIndex does not match the expected value (actual=$param,expected=$matcher) during invocation #$invocationIndex"
)
}
}
}
}
fun withNoParameter() {
if (parametersSequence.any { it.isNotEmpty() }) {
throw AssertionError("Expected no parameters, but got some")
}
}
}

View File

@@ -0,0 +1,15 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.tests.testutils.lambda
fun lambdaError(
message: String = "This lambda should never be called."
): Nothing {
throw AssertionError(message)
}

View File

@@ -0,0 +1,200 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.tests.testutils.lambda
/**
* A recorder that can be used to record the parameters of lambda invocation.
*/
abstract class LambdaRecorder internal constructor(
private val assertNoInvocation: Boolean,
) {
private val parametersSequence: MutableList<List<Any?>> = mutableListOf()
internal fun onInvoke(vararg params: Any?) {
if (assertNoInvocation) {
lambdaError()
}
parametersSequence.add(params.toList())
}
fun assertions(): LambdaRecorderAssertions {
return LambdaRecorderAssertions(parametersSequence = parametersSequence)
}
}
inline fun <reified R> lambdaRecorder(
ensureNeverCalled: Boolean = false,
noinline block: () -> R
): LambdaNoParamRecorder<R> {
return LambdaNoParamRecorder(ensureNeverCalled, block)
}
inline fun <reified T, reified R> lambdaRecorder(
ensureNeverCalled: Boolean = false,
noinline block: (T) -> R
): LambdaOneParamRecorder<T, R> {
return LambdaOneParamRecorder(ensureNeverCalled, block)
}
inline fun <reified T1, reified T2, reified R> lambdaRecorder(
ensureNeverCalled: Boolean = false,
noinline block: (T1, T2) -> R
): LambdaTwoParamsRecorder<T1, T2, R> {
return LambdaTwoParamsRecorder(ensureNeverCalled, block)
}
inline fun <reified T1, reified T2, reified T3, reified R> lambdaRecorder(
ensureNeverCalled: Boolean = false,
noinline block: (T1, T2, T3) -> R
): LambdaThreeParamsRecorder<T1, T2, T3, R> {
return LambdaThreeParamsRecorder(ensureNeverCalled, block)
}
inline fun <reified T1, reified T2, reified T3, reified T4, reified R> lambdaRecorder(
ensureNeverCalled: Boolean = false,
noinline block: (T1, T2, T3, T4) -> R
): LambdaFourParamsRecorder<T1, T2, T3, T4, R> {
return LambdaFourParamsRecorder(ensureNeverCalled, block)
}
inline fun <reified T1, reified T2, reified T3, reified T4, reified T5, reified R> lambdaRecorder(
ensureNeverCalled: Boolean = false,
noinline block: (T1, T2, T3, T4, T5) -> R
): LambdaFiveParamsRecorder<T1, T2, T3, T4, T5, R> {
return LambdaFiveParamsRecorder(ensureNeverCalled, block)
}
inline fun <reified T1, reified T2, reified T3, reified T4, reified T5, reified T6, reified R> lambdaRecorder(
ensureNeverCalled: Boolean = false,
noinline block: (T1, T2, T3, T4, T5, T6) -> R
): LambdaSixParamsRecorder<T1, T2, T3, T4, T5, T6, R> {
return LambdaSixParamsRecorder(ensureNeverCalled, block)
}
inline fun <reified T1, reified T2, reified T3, reified T4, reified T5, reified T6, reified T7, reified R> lambdaRecorder(
ensureNeverCalled: Boolean = false,
noinline block: (T1, T2, T3, T4, T5, T6, T7) -> R
): LambdaSevenParamsRecorder<T1, T2, T3, T4, T5, T6, T7, R> {
return LambdaSevenParamsRecorder(ensureNeverCalled, block)
}
inline fun <reified T1, reified T2, reified T3, reified T4, reified T5, reified T6, reified T7, reified T8, reified R> lambdaRecorder(
ensureNeverCalled: Boolean = false,
noinline block: (T1, T2, T3, T4, T5, T6, T7, T8) -> R
): LambdaEightParamsRecorder<T1, T2, T3, T4, T5, T6, T7, T8, R> {
return LambdaEightParamsRecorder(ensureNeverCalled, block)
}
inline fun <reified R> lambdaAnyRecorder(
ensureNeverCalled: Boolean = false,
noinline block: (List<Any?>) -> R
): LambdaListAnyParamsRecorder<R> {
return LambdaListAnyParamsRecorder(ensureNeverCalled, block)
}
class LambdaNoParamRecorder<out R>(ensureNeverCalled: Boolean, val block: () -> R) : LambdaRecorder(ensureNeverCalled), () -> R {
override fun invoke(): R {
onInvoke()
return block()
}
}
class LambdaOneParamRecorder<in T, out R>(ensureNeverCalled: Boolean, val block: (T) -> R) : LambdaRecorder(ensureNeverCalled), (T) -> R {
override fun invoke(p: T): R {
onInvoke(p)
return block(p)
}
}
class LambdaTwoParamsRecorder<in T1, in T2, out R>(ensureNeverCalled: Boolean, val block: (T1, T2) -> R) : LambdaRecorder(ensureNeverCalled), (T1, T2) -> R {
override fun invoke(p1: T1, p2: T2): R {
onInvoke(p1, p2)
return block(p1, p2)
}
}
class LambdaThreeParamsRecorder<in T1, in T2, in T3, out R>(ensureNeverCalled: Boolean, val block: (T1, T2, T3) -> R) : LambdaRecorder(
ensureNeverCalled
), (T1, T2, T3) -> R {
override fun invoke(p1: T1, p2: T2, p3: T3): R {
onInvoke(p1, p2, p3)
return block(p1, p2, p3)
}
}
class LambdaFourParamsRecorder<in T1, in T2, in T3, in T4, out R>(ensureNeverCalled: Boolean, val block: (T1, T2, T3, T4) -> R) : LambdaRecorder(
ensureNeverCalled
), (T1, T2, T3, T4) -> R {
override fun invoke(p1: T1, p2: T2, p3: T3, p4: T4): R {
onInvoke(p1, p2, p3, p4)
return block(p1, p2, p3, p4)
}
}
class LambdaFiveParamsRecorder<in T1, in T2, in T3, in T4, in T5, out R>(
ensureNeverCalled: Boolean,
val block: (T1, T2, T3, T4, T5) -> R,
) : LambdaRecorder(
ensureNeverCalled
), (T1, T2, T3, T4, T5) -> R {
override fun invoke(p1: T1, p2: T2, p3: T3, p4: T4, p5: T5): R {
onInvoke(p1, p2, p3, p4, p5)
return block(p1, p2, p3, p4, p5)
}
}
class LambdaSixParamsRecorder<in T1, in T2, in T3, in T4, in T5, in T6, out R>(
ensureNeverCalled: Boolean,
val block: (T1, T2, T3, T4, T5, T6) -> R,
) : LambdaRecorder(ensureNeverCalled), (T1, T2, T3, T4, T5, T6) -> R {
override fun invoke(p1: T1, p2: T2, p3: T3, p4: T4, p5: T5, p6: T6): R {
onInvoke(p1, p2, p3, p4, p5, p6)
return block(p1, p2, p3, p4, p5, p6)
}
}
class LambdaSevenParamsRecorder<in T1, in T2, in T3, in T4, in T5, in T6, in T7, out R>(
ensureNeverCalled: Boolean,
val block: (T1, T2, T3, T4, T5, T6, T7) -> R,
) : LambdaRecorder(ensureNeverCalled), (T1, T2, T3, T4, T5, T6, T7) -> R {
override fun invoke(p1: T1, p2: T2, p3: T3, p4: T4, p5: T5, p6: T6, p7: T7): R {
onInvoke(p1, p2, p3, p4, p5, p6, p7)
return block(p1, p2, p3, p4, p5, p6, p7)
}
}
class LambdaEightParamsRecorder<in T1, in T2, in T3, in T4, in T5, in T6, in T7, in T8, out R>(
ensureNeverCalled: Boolean,
val block: (T1, T2, T3, T4, T5, T6, T7, T8) -> R,
) : LambdaRecorder(ensureNeverCalled), (T1, T2, T3, T4, T5, T6, T7, T8) -> R {
override fun invoke(p1: T1, p2: T2, p3: T3, p4: T4, p5: T5, p6: T6, p7: T7, p8: T8): R {
onInvoke(p1, p2, p3, p4, p5, p6, p7, p8)
return block(p1, p2, p3, p4, p5, p6, p7, p8)
}
}
class LambdaNineParamsRecorder<in T1, in T2, in T3, in T4, in T5, in T6, in T7, in T8, in T9, out R>(
ensureNeverCalled: Boolean,
val block: (T1, T2, T3, T4, T5, T6, T7, T8, T9) -> R,
) : LambdaRecorder(ensureNeverCalled), (T1, T2, T3, T4, T5, T6, T7, T8, T9) -> R {
override fun invoke(p1: T1, p2: T2, p3: T3, p4: T4, p5: T5, p6: T6, p7: T7, p8: T8, p9: T9): R {
onInvoke(p1, p2, p3, p4, p5, p6, p7, p8, p9)
return block(p1, p2, p3, p4, p5, p6, p7, p8, p9)
}
}
class LambdaListAnyParamsRecorder<out R>(
ensureNeverCalled: Boolean,
val block: (List<Any?>) -> R,
) : LambdaRecorder(ensureNeverCalled), (List<Any?>) -> R {
override fun invoke(p: List<Any?>): R {
onInvoke(*p.toTypedArray())
return block(p)
}
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.tests.testutils.lambda
/**
* A matcher that can be used to match parameters in lambda calls.
* This is useful to assert that a lambda has been called with specific parameters.
*/
interface ParameterMatcher {
fun match(param: Any?): Boolean
}
/**
* A matcher that matches a specific value.
* Can be used to assert that a lambda has been called with a specific value.
*/
fun <T> value(expectedValue: T) = object : ParameterMatcher {
override fun match(param: Any?) = param == expectedValue
override fun toString(): String = "value($expectedValue)"
}
/**
* A matcher that matches a value based on a condition.
* Can be used to assert that a lambda has been called with a value that satisfies a specific condition.
*/
fun <T> matching(check: (T) -> Boolean) = object : ParameterMatcher {
override fun match(param: Any?): Boolean {
@Suppress("UNCHECKED_CAST")
return (param as? T)?.let { check(it) } ?: false
}
override fun toString(): String = "matching(condition)"
}
/**
* A matcher that matches any value.
* Can be used when we don't care about the value of a parameter.
*/
fun any() = object : ParameterMatcher {
override fun match(param: Any?) = true
override fun toString(): String = "any()"
}
/**
* A matcher that matches any non null value
* Can be used when we don't care about the value of a parameter, just about its nullability.
*/
fun nonNull() = object : ParameterMatcher {
override fun match(param: Any?) = param != null
override fun toString(): String = "nonNull()"
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.tests.testutils.node
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.EmptyNodeView
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.AssistedNodeFactory
import io.element.android.libraries.architecture.NodeFactoriesBindings
import io.element.android.libraries.di.DependencyInjectionGraphOwner
import kotlin.reflect.KClass
/**
* A parent Node that can create a single type of child Node using the provided factory.
* This is useful to test a Feature entry point, by providing a fake parent that can create a
* child Node.
*/
class TestParentNode<Child : Node>(
private val childNodeClass: KClass<out Node>,
private val childNodeFactory: (buildContext: BuildContext, plugins: List<Plugin>) -> Child,
) : DependencyInjectionGraphOwner,
Node(
buildContext = BuildContext.Companion.root(savedStateMap = null),
plugins = emptyList(),
view = EmptyNodeView,
) {
override val graph: NodeFactoriesBindings = NodeFactoriesBindings {
mapOf(
childNodeClass to AssistedNodeFactory { buildContext, plugins ->
childNodeFactory(buildContext, plugins)
}
)
}
companion object {
// Inline factory function with reified type parameter
inline fun <reified Child : Node> create(
noinline childNodeFactory: (buildContext: BuildContext, plugins: List<Plugin>) -> Child,
): TestParentNode<Child> {
return TestParentNode(Child::class, childNodeFactory)
}
}
}