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

1
tests/detekt-rules/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,20 @@
plugins {
alias(libs.plugins.kotlin.jvm)
}
java {
sourceCompatibility = Versions.javaVersion
targetCompatibility = Versions.javaVersion
}
kotlin {
jvmToolchain {
languageVersion = Versions.javaLanguageVersion
}
}
dependencies {
compileOnly(libs.test.detekt.api)
testImplementation(libs.test.detekt.test)
testImplementation(libs.test.truth)
}

View File

@@ -0,0 +1,45 @@
/*
* 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.detektrules
import io.github.detekt.psi.fileName
import io.gitlab.arturbosch.detekt.api.CodeSmell
import io.gitlab.arturbosch.detekt.api.Config
import io.gitlab.arturbosch.detekt.api.Debt
import io.gitlab.arturbosch.detekt.api.Entity
import io.gitlab.arturbosch.detekt.api.Issue
import io.gitlab.arturbosch.detekt.api.Rule
import io.gitlab.arturbosch.detekt.api.Severity
import org.jetbrains.kotlin.psi.KtPropertyDelegate
class ByPreferencesDataStoreRule(config: Config) : Rule(config) {
override val issue: Issue = Issue(
id = "ByPreferencesDataStoreNotAllowed",
severity = Severity.Style,
description = "Avoid using `by preferencesDataStore(...)`, use `PreferenceDataStoreFactory.create(name)`instead.",
debt = Debt.FIVE_MINS,
)
override fun visitPropertyDelegate(delegate: KtPropertyDelegate) {
super.visitPropertyDelegate(delegate)
if (delegate.containingKtFile.fileName == "DefaultPreferencesDataStoreFactory.kt") {
// Skip the rule for the DefaultPreferencesDataStoreFactory implementation
return
}
if (delegate.text.startsWith("by preferencesDataStore")) {
report(CodeSmell(
issue = issue,
entity = Entity.from(delegate),
message = "Use `PreferenceDataStoreFactory.create(name)` instead of `by preferencesDataStore(...)`."
))
}
}
}

View File

@@ -0,0 +1,25 @@
/*
* 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.detektrules
import io.gitlab.arturbosch.detekt.api.Config
import io.gitlab.arturbosch.detekt.api.RuleSet
import io.gitlab.arturbosch.detekt.api.RuleSetProvider
class ElementRuleSetProvider : RuleSetProvider {
override val ruleSetId: String = "ElementXRules"
override fun instance(config: Config): RuleSet = RuleSet(
id = ruleSetId,
rules = listOf(
RunCatchingRule(config),
ByPreferencesDataStoreRule(config),
)
)
}

View File

@@ -0,0 +1,44 @@
/*
* 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.detektrules
import io.gitlab.arturbosch.detekt.api.CodeSmell
import io.gitlab.arturbosch.detekt.api.Config
import io.gitlab.arturbosch.detekt.api.Debt
import io.gitlab.arturbosch.detekt.api.Entity
import io.gitlab.arturbosch.detekt.api.Issue
import io.gitlab.arturbosch.detekt.api.Rule
import io.gitlab.arturbosch.detekt.api.Severity
import org.jetbrains.kotlin.psi.KtCallExpression
import org.jetbrains.kotlin.psi.psiUtil.getCallNameExpression
class RunCatchingRule(config: Config) : Rule(config) {
override val issue: Issue = Issue(
id = "RunCatchingNotAllowed",
severity = Severity.Style,
description = "Avoid using `runCatching`, use `runCatchingExceptions` or `tryOrNull` instead. " +
"Avoid `mapCatching`, use `mapCatchingExceptions` instead.",
debt = Debt.FIVE_MINS,
)
override fun visitCallExpression(expression: KtCallExpression) {
super.visitCallExpression(expression)
val callNameExpression = expression.getCallNameExpression() ?: return
val hasRunCatchingCall = callNameExpression.text == "runCatching"
val hasMapCatchingCall = callNameExpression.text == "mapCatching"
if (hasRunCatchingCall || hasMapCatchingCall) {
report(CodeSmell(
issue = issue,
entity = Entity.from(expression),
message = "Use `runCatchingExceptions` or `tryOrNull` instead of `runCatching`. Avoid `mapCatching`, use `mapCatchingExceptions` instead."
))
}
}
}

View File

@@ -0,0 +1 @@
io.element.android.detektrules.ElementRuleSetProvider

View File

@@ -0,0 +1,34 @@
/*
* 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.detektrules
import com.google.common.truth.Truth.assertThat
import io.gitlab.arturbosch.detekt.api.Config
import io.gitlab.arturbosch.detekt.test.compileAndLint
import org.junit.Test
class RunCatchingRuleTest {
private val subject = RunCatchingRule(Config.empty)
@Test
fun `test RunCatchingRule`() {
val findings = subject.compileAndLint(code)
assertThat(findings).hasSize(3)
}
private val code = """
object Foo {
fun bar() {
runCatching {}
kotlin.runCatching {}
Result.success(true).mapCatching { false }
}
}
""".trimIndent()
}

View File

@@ -0,0 +1,34 @@
/*
* 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.konsist"
}
dependencies {
val composeBom = platform(libs.androidx.compose.bom)
testImplementation(composeBom)
testImplementation(libs.androidx.compose.ui.tooling.preview)
testImplementation(libs.test.junit)
testImplementation(libs.test.konsist)
testImplementation(libs.test.truth)
testImplementation(projects.libraries.architecture)
testImplementation(projects.libraries.designsystem)
}
// Make sure Konsist tests run for 'check' tasks. This is needed because otherwise we'd have to either:
// - Add every single module as a dependency of this one.
// - Move the Konsist tests to the `app` module, but the `app` module does not need to know about Konsist.
tasks.withType<Test>().configureEach {
val isNotCheckTask = gradle.startParameter.taskNames.any { it.contains("check", ignoreCase = true).not() }
outputs.upToDateWhen { isNotCheckTask }
}

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.konsist.failures
import androidx.compose.runtime.Composable
// Make test `Sealed interface used in Composable MUST be Immutable or Stable` fails
sealed interface SealedInterface
@Composable
fun FailingComposableWithNonImmutableSealedInterface(
sealedInterface: SealedInterface
) {
}

View File

@@ -0,0 +1,23 @@
/*
* 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.konsist.failures
// Make test `Fake classes must be named using Fake and the interface it fakes` fails
interface MyInterface
// This class should be named FakeMyInterface
class FakeWrongClassName : MyInterface
class MyClass {
interface MyFactory
}
// This class should be named FakeMyClassMyFactory
class FakeWrongClassSubInterfaceName : MyClass.MyFactory

View File

@@ -0,0 +1,111 @@
/*
* 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.konsist
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.lemonappdev.konsist.api.Konsist
import com.lemonappdev.konsist.api.ext.list.constructors
import com.lemonappdev.konsist.api.ext.list.modifierprovider.withSealedModifier
import com.lemonappdev.konsist.api.ext.list.parameters
import com.lemonappdev.konsist.api.ext.list.withAnnotationOf
import com.lemonappdev.konsist.api.ext.list.withNameEndingWith
import com.lemonappdev.konsist.api.ext.list.withoutAnnotationOf
import com.lemonappdev.konsist.api.ext.list.withoutConstructors
import com.lemonappdev.konsist.api.ext.list.withoutName
import com.lemonappdev.konsist.api.ext.list.withoutParents
import com.lemonappdev.konsist.api.verify.assertEmpty
import com.lemonappdev.konsist.api.verify.assertTrue
import org.junit.Assert.assertTrue
import org.junit.Test
class KonsistArchitectureTest {
@Test
fun `Data class state MUST not have default value`() {
Konsist
.scopeFromProject()
.classes()
.withNameEndingWith("State")
.withoutName(
"CameraPositionState",
"CustomSheetState",
)
.constructors
.parameters
.assertTrue { parameterDeclaration ->
parameterDeclaration.defaultValue == null &&
// Using parameterDeclaration.defaultValue == null is not enough apparently,
// Also check that the text does not contain an equal sign
parameterDeclaration.text.contains("=").not()
}
}
@Test
fun `Events MUST be sealed interface`() {
Konsist.scopeFromProject()
.classes()
.withSealedModifier()
.withNameEndingWith("Events")
.assertEmpty(additionalMessage = "Events class MUST be sealed interface")
}
@Test
fun `Sealed class without constructor and without parent MUST be sealed interface`() {
Konsist.scopeFromProject()
.classes()
.withSealedModifier()
.withoutConstructors()
.withoutParents()
.assertEmpty(additionalMessage = "Sealed class without constructor MUST be sealed interface")
}
@Test
fun `Sealed interface used in Composable MUST be Immutable or Stable`() {
var failingTestFound = false
// List all sealed interface without Immutable nor Stable annotation in the project
val forbiddenInterfacesForComposableParameter = Konsist.scopeFromProject()
.interfaces()
.withSealedModifier()
.withoutAnnotationOf(Immutable::class, Stable::class)
.map { it.fullyQualifiedName }
Konsist.scopeFromProject()
.functions()
.withAnnotationOf(Composable::class)
.assertTrue(additionalMessage = "Consider adding the @Immutable or @Stable annotation to the sealed interface") {
val result = it.parameters.all { param ->
val type = param.type.text
return@all if (type.startsWith("@") || type.contains("->") || type.startsWith("suspend")) {
true
} else {
val typePackage = param.type.sourceDeclaration?.let { declaration ->
declaration.asTypeParameterDeclaration()?.packagee
?: declaration.asExternalDeclaration()?.packagee
?: declaration.asClassOrInterfaceDeclaration()?.packagee
?: declaration.asKotlinTypeDeclaration()?.packagee
?: declaration.asObjectDeclaration()?.packagee
}?.name
if (typePackage == null) {
false
} else {
val fullyQualifiedName = "$typePackage.$type"
fullyQualifiedName !in forbiddenInterfacesForComposableParameter
}
}
}
if (!result && !failingTestFound && it.name == "FailingComposableWithNonImmutableSealedInterface") {
failingTestFound = true
true
} else {
result
}
}
assertTrue("FailingComposableWithNonImmutableSealedInterface should make this test fail.", failingTestFound)
}
}

View File

@@ -0,0 +1,25 @@
/*
* 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.konsist
import com.lemonappdev.konsist.api.Konsist
import com.lemonappdev.konsist.api.verify.assertFalse
import org.junit.Test
class KonsistCallbackTest {
@Test
fun `we should not invoke Callback Input directly, we should use forEach`() {
Konsist
.scopeFromProduction()
.files
.assertFalse {
it.text.contains("callback?.")
}
}
}

View File

@@ -0,0 +1,180 @@
/*
* 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.konsist
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.bumble.appyx.core.node.Node
import com.google.common.truth.Truth.assertThat
import com.lemonappdev.konsist.api.Konsist
import com.lemonappdev.konsist.api.ext.list.withAllParentsOf
import com.lemonappdev.konsist.api.ext.list.withAnnotationNamed
import com.lemonappdev.konsist.api.ext.list.withNameContaining
import com.lemonappdev.konsist.api.ext.list.withNameEndingWith
import com.lemonappdev.konsist.api.ext.list.withPackage
import com.lemonappdev.konsist.api.ext.list.withoutName
import com.lemonappdev.konsist.api.ext.list.withoutNameStartingWith
import com.lemonappdev.konsist.api.verify.assertEmpty
import com.lemonappdev.konsist.api.verify.assertTrue
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.Presenter
import org.junit.Test
class KonsistClassNameTest {
@Test
fun `Classes extending 'Presenter' should have 'Presenter' suffix`() {
Konsist.scopeFromProject()
.classes()
.withAllParentsOf(Presenter::class)
.assertTrue {
it.name.endsWith("Presenter")
}
}
@Test
fun `Classes extending 'Node' should have 'Node' suffix`() {
Konsist.scopeFromProject()
.classes()
.withAllParentsOf(Node::class)
.assertTrue {
it.name.endsWith("Node")
}
}
@Test
fun `Classes extending 'BaseFlowNode' should have 'FlowNode' suffix`() {
Konsist.scopeFromProject()
.classes()
.withAllParentsOf(BaseFlowNode::class)
.assertTrue {
it.name.endsWith("FlowNode")
}
}
@Test
fun `Classes extending 'PreviewParameterProvider' name MUST end with 'Provider' and MUST contain provided class name`() {
Konsist.scopeFromProduction()
.classes()
.withAllParentsOf(PreviewParameterProvider::class)
.withoutName(
"AspectRatioProvider",
"EditableAvatarViewUriProvider",
"LoginModeViewErrorProvider",
"OverlapRatioProvider",
"TextFileContentProvider",
)
.also {
// Check that classes are actually found
assertThat(it.size).isGreaterThan(100)
}
.assertTrue { klass ->
// Cannot find a better way to get the type of the generic
val providedType = klass.text
.substringAfter("PreviewParameterProvider<")
.substringBefore(">")
// Get the substring before the first '<' to remove the generic type
.substringBefore("<")
.removeSuffix("?")
.replace(".", "")
val name = klass.name
name.endsWith("Provider") &&
name.endsWith("PreviewProvider").not() &&
name.contains(providedType)
}
}
@Test
fun `Fake classes must be named using Fake and the interface it fakes`() {
var failingCases = 0
val failingCasesList = listOf(
"FakeWrongClassName",
"FakeWrongClassSubInterfaceName",
)
Konsist.scopeFromProject()
.classes()
.withNameContaining("Fake")
.withoutName(
"FakeFileSystem",
"FakeImageLoader",
"FakeListenableFuture",
)
.assertTrue {
val interfaceName = it.name
.replace("FakeFfi", "")
.replace("Fake", "")
val result = it.name.startsWith("Fake") &&
it.parents().any { parent ->
val parentName = parent.name.replace(".", "")
parentName == interfaceName
}
if (!result && it.name in failingCasesList) {
failingCases++
true
} else {
result
}
}
assertThat(failingCases).isEqualTo(failingCasesList.size)
}
@Test
fun `All Classes that override a class from the Ffi layer must have 'FakeFfi' prefix`() {
Konsist.scopeFromTest()
.classes()
.withPackage("io.element.android.libraries.matrix.impl.fixtures.fakes")
.assertTrue { klass ->
val parentName = klass.parents().firstOrNull()?.name.orEmpty()
klass.name == "FakeFfi$parentName"
}
}
@Test
fun `Class implementing interface should have name not end with 'Impl' but start with 'Default'`() {
Konsist.scopeFromProject()
.classes()
.withNameEndingWith("Impl")
.withoutName("MediaUploadHandlerImpl")
.assertEmpty(additionalMessage = "Class implementing interface should have name not end with 'Impl' but start with 'Default'")
}
@Test
fun `Class with 'ContributeBinding' annotation should have allowed prefix`() {
Konsist.scopeFromProject()
.classes()
.withAnnotationNamed("ContributesBinding")
.withoutName(
"Factory",
"TimelineController",
"TimelineMediaGalleryDataSource",
"MetroWorkerFactory",
)
.withoutNameStartingWith(
"Accompanist",
"AES",
"Android",
"Asset",
"Database",
"DBov",
"Default",
"DataStore",
"Enterprise",
"Fdroid",
"FileExtensionExtractor",
"Internal",
"LiveMediaTimeline",
"KeyStore",
"Matrix",
"Noop",
"Oss",
"Preferences",
"Rust",
"SharedPreferences",
)
.assertEmpty()
}
}

View File

@@ -0,0 +1,66 @@
/*
* 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.konsist
import androidx.compose.runtime.Composable
import com.lemonappdev.konsist.api.KoModifier
import com.lemonappdev.konsist.api.Konsist
import com.lemonappdev.konsist.api.ext.list.modifierprovider.withoutModifier
import com.lemonappdev.konsist.api.ext.list.withAllAnnotationsOf
import com.lemonappdev.konsist.api.ext.list.withTopLevel
import com.lemonappdev.konsist.api.ext.list.withoutName
import com.lemonappdev.konsist.api.ext.list.withoutNameEndingWith
import com.lemonappdev.konsist.api.ext.list.withoutReceiverType
import com.lemonappdev.konsist.api.verify.assertTrue
import org.junit.Test
class KonsistComposableTest {
@Test
fun `Top level function with '@Composable' annotation starting with a upper case should be placed in a file with the same name`() {
Konsist
.scopeFromProject()
.functions()
.withTopLevel()
.withoutModifier(KoModifier.PRIVATE)
.withoutNameEndingWith("Preview")
.withAllAnnotationsOf(Composable::class)
.withoutReceiverType()
.withoutName(
// Add some exceptions...
"InvisibleButton",
"OutlinedButton",
"SimpleAlertDialogContent",
"TextButton",
"AvatarColorsPreviewLight",
"AvatarColorsPreviewDark",
"IconsCompoundPreviewLight",
"IconsCompoundPreviewRtl",
"IconsCompoundPreviewDark",
"CompoundSemanticColorsLight",
"CompoundSemanticColorsLightHc",
"CompoundSemanticColorsDark",
"CompoundSemanticColorsDarkHc",
)
.assertTrue(
additionalMessage =
"""
Please check the filename. It should match the top level Composable function. If the filename is correct:
- consider making the Composable private or moving it to its own file
- at last resort, you can add an exception in the Konsist test
""".trimIndent()
) {
if (it.name.first().isLowerCase()) {
true
} else {
val fileName = it.containingFile.name.removeSuffix(".kt")
fileName == it.name
}
}
}
}

View File

@@ -0,0 +1,37 @@
/*
* 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.konsist
import com.google.common.truth.Truth.assertThat
import com.lemonappdev.konsist.api.Konsist
import org.junit.Test
class KonsistConfigTest {
@Test
fun `assert that Konsist detect all the project classes`() {
assertThat(
Konsist
.scopeFromProject()
.classes()
.size
)
.isGreaterThan(1_000)
}
@Test
fun `assert that Konsist detect all the test classes`() {
assertThat(
Konsist
.scopeFromTest()
.classes()
.size
)
.isGreaterThan(100)
}
}

View File

@@ -0,0 +1,27 @@
/*
* 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.konsist
import com.lemonappdev.konsist.api.Konsist
import com.lemonappdev.konsist.api.ext.list.withImportNamed
import com.lemonappdev.konsist.api.verify.assertFalse
import org.junit.Test
class KonsistContentTest {
@Test
fun `assert that BuildConfig dot VersionCode is not used`() {
Konsist
.scopeFromProduction()
.files
.withImportNamed("io.element.android.x.BuildConfig")
.assertFalse(additionalMessage = "Please do not use BuildConfig.VERSION_CODE, but use the versionCode from BuildMeta") {
it.text.contains("BuildConfig.VERSION_CODE")
}
}
}

View File

@@ -0,0 +1,49 @@
/*
* 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.konsist
import com.lemonappdev.konsist.api.Konsist
import com.lemonappdev.konsist.api.ext.list.withAnnotationOf
import com.lemonappdev.konsist.api.ext.list.withParameter
import com.lemonappdev.konsist.api.verify.assertFalse
import com.lemonappdev.konsist.api.verify.assertTrue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import org.junit.Test
class KonsistDiTest {
@Test
fun `class annotated with @Inject should not have constructors with @Assisted parameter`() {
Konsist
.scopeFromProject()
.classes()
.withAnnotationOf(Inject::class)
.assertTrue(
additionalMessage = "Class with @Assisted parameter in constructor should be annotated with @AssistedInject and not @Inject"
) { classDeclaration ->
classDeclaration.constructors
.withParameter { parameterDeclaration ->
parameterDeclaration.hasAnnotationOf(Assisted::class)
}
.isEmpty()
}
}
@Test
fun `class annotated with @ContributesBinding does not need to be annotated with @Inject anymore`() {
Konsist
.scopeFromProject()
.classes()
.withAnnotationOf(ContributesBinding::class)
.assertFalse { classDeclaration ->
classDeclaration.hasAnnotationOf(Inject::class)
}
}
}

View File

@@ -0,0 +1,28 @@
/*
* 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.konsist
import com.lemonappdev.konsist.api.Konsist
import com.lemonappdev.konsist.api.ext.list.properties
import com.lemonappdev.konsist.api.verify.assertFalse
import org.junit.Test
class KonsistFieldTest {
@Test
fun `no field should have 'm' prefix`() {
Konsist
.scopeFromProject()
.classes()
.properties()
.assertFalse {
val secondCharacterIsUppercase = it.name.getOrNull(1)?.isUpperCase() ?: false
it.name.startsWith('m') && secondCharacterIsUppercase
}
}
}

View File

@@ -0,0 +1,38 @@
/*
* 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.konsist
import androidx.compose.runtime.Composable
import com.lemonappdev.konsist.api.Konsist
import com.lemonappdev.konsist.api.ext.list.withAnnotationOf
import com.lemonappdev.konsist.api.verify.assertFalse
import org.junit.Test
class KonsistFlowTest {
@Test
fun `flow must be remembered when it is collected as state`() {
// Match
// ```).collectAsState```
// and
// ```)
// .collectAsState```
val regex = "(.*)\\)(\n\\s*)*\\.collectAsState".toRegex()
Konsist
.scopeFromProject()
.functions()
.withAnnotationOf(Composable::class)
.assertFalse(
additionalMessage = "Please check that the flow is remembered when it is collected as state." +
" Only val flows can be not remembered.",
) { function ->
regex.matches(function.text)
}
}
}

View File

@@ -0,0 +1,78 @@
/*
* 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.konsist
import androidx.compose.runtime.Immutable
import com.lemonappdev.konsist.api.Konsist
import com.lemonappdev.konsist.api.ext.list.withAnnotationOf
import com.lemonappdev.konsist.api.ext.list.withNameEndingWith
import com.lemonappdev.konsist.api.ext.list.withoutName
import com.lemonappdev.konsist.api.verify.assertEmpty
import com.lemonappdev.konsist.api.verify.assertFalse
import org.junit.Test
class KonsistImmutableTest {
/**
* toPersistentList() returns a PersistentList which allow mutations, while toImmutableList() returns
* an ImmutableList which does not allow mutations. Generally, we do not use the mutation features,
* so we should prefer toImmutableList.
*/
@Test
fun `toPersistentList() should not be used instead of toImmutableList()`() {
Konsist
.scopeFromProject()
.functions()
.withoutName("toPersistentList() should not be used instead of toImmutableList()")
.assertFalse(additionalMessage = "Please use toImmutableList() instead of toPersistentList()") {
it.text.contains(".toPersistentList()")
}
}
/**
* toPersistentSet() returns a PersistentSet which allow mutations, while toImmutableSet() returns
* an ImmutableSet which does not allow mutations. Generally, we do not use the mutation features,
* so we should prefer toImmutableSet.
*/
@Test
fun `toPersistentSet() should not be used instead of toImmutableSet()`() {
Konsist
.scopeFromProject()
.functions()
.withoutName("toPersistentSet() should not be used instead of toImmutableSet()")
.assertFalse(additionalMessage = "Please use toImmutableSet() instead of toPersistentSet()") {
it.text.contains(".toPersistentSet()")
}
}
/**
* toPersistentMap() returns a PersistentMap which allow mutations, while toImmutableMap() returns
* an ImmutableMap which does not allow mutations. Generally, we do not use the mutation features,
* so we should prefer toImmutableMap.
*/
@Test
fun `toPersistentMap() should not be used instead of toImmutableMap()`() {
Konsist
.scopeFromProject()
.functions()
.withoutName("toPersistentMap() should not be used instead of toImmutableMap()")
.assertFalse(additionalMessage = "Please use toImmutableMap() instead of toPersistentMap()") {
it.text.contains(".toPersistentMap()")
}
}
@Test
fun `Immutable annotation is not used on sealed interface for Presenter Events`() {
Konsist
.scopeFromProduction()
.interfaces()
.withNameEndingWith("Events")
.withAnnotationOf(Immutable::class)
.assertEmpty()
}
}

View File

@@ -0,0 +1,54 @@
/*
* 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.konsist
import com.lemonappdev.konsist.api.Konsist
import com.lemonappdev.konsist.api.verify.assertFalse
import org.junit.Test
class KonsistImportTest {
@Test
fun `Functions with '@VisibleForTesting' annotation should use 'androidx' version`() {
Konsist
.scopeFromProject()
.imports
.assertFalse(
additionalMessage = "Please use 'androidx.annotation.VisibleForTesting' instead of " +
"'org.jetbrains.annotations.VisibleForTesting' (project convention).",
) {
it.name == "org.jetbrains.annotations.VisibleForTesting"
}
}
@Test
fun `OutlinedTextField should not be used`() {
Konsist
.scopeFromProject()
.imports
.assertFalse(
additionalMessage = "Please use 'io.element.android.libraries.designsystem.theme.components.TextField' instead of " +
"'androidx.compose.material3.OutlinedTextField.",
) {
it.name == "androidx.compose.material3.OutlinedTextField"
}
}
@Test
fun `material3 TopAppBar should not be used`() {
Konsist
.scopeFromProject()
.imports
.assertFalse(
additionalMessage = "Please use 'io.element.android.libraries.designsystem.theme.components.TopAppBar' instead of " +
"'androidx.compose.material3.TopAppBar.",
) {
it.name == "androidx.compose.material3.TopAppBar"
}
}
}

View File

@@ -0,0 +1,100 @@
/*
* 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.konsist
import com.google.common.truth.Truth.assertThat
import com.lemonappdev.konsist.api.Konsist
import com.lemonappdev.konsist.api.verify.assertTrue
import org.junit.Test
class KonsistLicenseTest {
private val publicLicense = """
/\*
(?:.*\n)* \* Copyright \(c\) 20\d\d((, |-)20\d\d)? Element Creations Ltd\.
(?:.*\n)* \*
\* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial\.
\* Please see LICENSE files in the repository root for full details\.
\*/
""".trimIndent().toRegex()
private val enterpriseLicense = """
/\*
\* © 20\d\d((, |-)20\d\d)? Element Creations Ltd\.
(?:.*\n)* \*
\* Element Creations Ltd, Element Software SARL, Element Software Inc\.,
\* and Element Software GmbH \(the "Element Group"\) only make this file available
\* under a proprietary license model\.
\*
\* Without a proprietary license with us, you cannot use this file\. The terms of
\* the proprietary license agreement between you and any member of the Element Group
\* shall always apply to your use of this file\. Unauthorised use, copying, distribution,
\* or modification of this file, via any medium, is strictly prohibited\.
\*
\* For details about the licensing terms, you must either visit our website or contact
\* a member of our sales team\.
\*/
""".trimIndent().toRegex()
@Test
fun `assert that FOSS files have the correct license header`() {
Konsist
.scopeFromProject()
.files
.filter {
it.moduleName.startsWith("enterprise").not() &&
it.nameWithExtension != "locales.kt" &&
it.name.startsWith("Template ").not()
}
.also {
assertThat(it).isNotEmpty()
}
.assertTrue {
publicLicense.containsMatchIn(it.text)
}
}
@Test
fun `assert that Enterprise files have the correct license header`() {
Konsist
.scopeFromProject()
.files
.filter {
it.moduleName.startsWith("enterprise")
}
.assertTrue {
enterpriseLicense.containsMatchIn(it.text)
}
}
@Test
fun `assert that files do not have double license header`() {
Konsist
.scopeFromProject()
.files
.filter {
it.nameWithExtension != "locales.kt" &&
it.nameWithExtension != "KonsistLicenseTest.kt" &&
it.name.startsWith("Template ").not()
}
.assertTrue {
it.text.count("Element Creations Ltd.") == 1
}
}
}
private fun String.count(subString: String): Int {
var count = 0
var index = 0
while (true) {
index = indexOf(subString, index)
if (index == -1) return count
count++
index += subString.length
}
}

View File

@@ -0,0 +1,24 @@
/*
* 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.konsist
import com.lemonappdev.konsist.api.Konsist
import com.lemonappdev.konsist.api.verify.assertTrue
import org.junit.Test
class KonsistMethodNameTest {
@Test
fun `Ensure that method name does not start or end with spaces`() {
Konsist.scopeFromProject()
.functions()
.assertTrue {
it.name.trim() == it.name
}
}
}

View File

@@ -0,0 +1,26 @@
/*
* 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.konsist
import com.lemonappdev.konsist.api.Konsist
import com.lemonappdev.konsist.api.ext.list.withParameter
import com.lemonappdev.konsist.api.verify.assertEmpty
import org.junit.Test
class KonsistParameterNameTest {
@Test
fun `Function parameter should not end with 'Press' but with 'Click'`() {
Konsist.scopeFromProject()
.functions()
.withParameter { parameter ->
parameter.name.endsWith("Press")
}
.assertEmpty(additionalMessage = "Please rename the parameter, for instance from 'onBackPress' to 'onBackClick'.")
}
}

View File

@@ -0,0 +1,32 @@
/*
* 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.konsist
import com.lemonappdev.konsist.api.Konsist
import com.lemonappdev.konsist.api.ext.list.constructors
import com.lemonappdev.konsist.api.ext.list.withAllParentsOf
import com.lemonappdev.konsist.api.verify.assertTrue
import io.element.android.libraries.architecture.Presenter
import org.junit.Test
class KonsistPresenterTest {
@Test
fun `'Presenter' should not depend on other presenters`() {
Konsist.scopeFromProject()
.classes()
.withAllParentsOf(Presenter::class)
.constructors
.assertTrue { constructor ->
val result = constructor.parameters.none { parameter ->
parameter.type.name.endsWith("Presenter")
}
result
}
}
}

View File

@@ -0,0 +1,214 @@
/*
* 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.konsist
import androidx.compose.ui.tooling.preview.PreviewLightDark
import com.google.common.truth.Truth.assertThat
import com.lemonappdev.konsist.api.Konsist
import com.lemonappdev.konsist.api.ext.list.withAllAnnotationsOf
import com.lemonappdev.konsist.api.ext.list.withName
import com.lemonappdev.konsist.api.ext.list.withNameEndingWith
import com.lemonappdev.konsist.api.ext.list.withoutName
import com.lemonappdev.konsist.api.verify.assertEmpty
import com.lemonappdev.konsist.api.verify.assertTrue
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import org.junit.Test
class KonsistPreviewTest {
@Test
fun `Functions with '@PreviewsDayNight' annotation should have 'Preview' suffix`() {
Konsist
.scopeFromProject()
.functions()
.withAllAnnotationsOf(PreviewsDayNight::class)
.assertTrue {
it.hasNameEndingWith("Preview") &&
it.hasNameEndingWith("LightPreview").not() &&
it.hasNameEndingWith("DarkPreview").not()
}
}
@Test
fun `Check functions with 'A11yPreview'`() {
Konsist
.scopeFromProject()
.functions()
.withNameEndingWith("A11yPreview")
.assertTrue(
additionalMessage = "Functions with 'A11yPreview' suffix should have '@Preview' annotation and not '@PreviewsDayNight'," +
" should contain 'ElementPreview' composable," +
" should contain the tested view" +
" and should be internal."
) {
val testedView = it.name.removeSuffix("A11yPreview")
it.text.contains("$testedView(") &&
it.hasAllAnnotationsOf(PreviewsDayNight::class).not() &&
it.text.contains("ElementPreview") &&
it.hasInternalModifier
}
}
@Test
fun `Functions with '@PreviewsDayNight' annotation should contain 'ElementPreview' composable`() {
Konsist
.scopeFromProject()
.functions()
.withAllAnnotationsOf(PreviewsDayNight::class)
.assertTrue {
it.text.contains("ElementPreview")
}
}
@Test
fun `Functions with '@PreviewsDayNight' are internal`() {
Konsist
.scopeFromProject()
.functions()
.withAllAnnotationsOf(PreviewsDayNight::class)
.assertTrue {
it.hasInternalModifier
}
}
private val previewNameExceptions = listOf(
"AsyncIndicatorFailurePreview",
"AsyncIndicatorLoadingPreview",
"BackgroundVerticalGradientDisabledPreview",
"BackgroundVerticalGradientPreview",
"ColorAliasesPreview",
"FocusedEventPreview",
"GradientFloatingActionButtonCircleShapePreview",
"HeaderFooterPageScrollablePreview",
"HomeTopBarMultiAccountPreview",
"HomeTopBarWithIndicatorPreview",
"IconsOtherPreview",
"MarkdownTextComposerEditPreview",
"MatrixBadgeAtomInfoPreview",
"MatrixBadgeAtomNegativePreview",
"MatrixBadgeAtomNeutralPreview",
"MatrixBadgeAtomPositivePreview",
"MentionSpanThemeInTimelinePreview",
"MessageComposerViewVoicePreview",
"MessagesReactionButtonAddPreview",
"MessagesReactionButtonExtraPreview",
"MessagesViewWithIdentityChangePreview",
"PendingMemberRowWithLongNamePreview",
"PinUnlockViewInAppPreview",
"PollAnswerViewDisclosedNotSelectedPreview",
"PollAnswerViewDisclosedSelectedPreview",
"PollAnswerViewEndedSelectedPreview",
"PollAnswerViewEndedWinnerNotSelectedPreview",
"PollAnswerViewEndedWinnerSelectedPreview",
"PollAnswerViewUndisclosedNotSelectedPreview",
"PollAnswerViewUndisclosedSelectedPreview",
"PollContentViewCreatorEditablePreview",
"PollContentViewCreatorEndedPreview",
"PollContentViewCreatorPreview",
"PollContentViewDisclosedPreview",
"PollContentViewEndedPreview",
"PollContentViewUndisclosedPreview",
"ProgressDialogWithContentPreview",
"ProgressDialogWithTextAndContentPreview",
"ReadReceiptBottomSheetPreview",
"SasEmojisPreview",
"SecureBackupSetupViewChangePreview",
"SelectedUserCannotRemovePreview",
"SpaceMembersViewNoHeroesPreview",
"TextComposerAddCaptionPreview",
"TextComposerCaptionPreview",
"TextComposerEditCaptionPreview",
"TextComposerEditNotEncryptedPreview",
"TextComposerEditPreview",
"TextComposerFormattingNotEncryptedPreview",
"TextComposerFormattingPreview",
"TextComposerLinkDialogCreateLinkPreview",
"TextComposerLinkDialogCreateLinkWithoutTextPreview",
"TextComposerLinkDialogEditLinkPreview",
"TextComposerReplyPreview",
"TextComposerSimpleNotEncryptedPreview",
"TextComposerSimplePreview",
"TextComposerVoiceNotEncryptedPreview",
"TextComposerVoicePreview",
"TextFieldDialogWithErrorPreview",
"TimelineImageWithCaptionRowPreview",
"TimelineItemEventRowForDirectRoomPreview",
"TimelineItemEventRowShieldPreview",
"TimelineItemEventRowTimestampPreview",
"TimelineItemEventRowUtdPreview",
"TimelineItemEventRowWithManyReactionsPreview",
"TimelineItemEventRowWithRRPreview",
"TimelineItemEventRowWithReplyPreview",
"TimelineItemEventRowWithThreadSummaryPreview",
"TimelineItemGroupedEventsRowContentCollapsePreview",
"TimelineItemGroupedEventsRowContentExpandedPreview",
"TimelineItemImageViewHideMediaContentPreview",
"TimelineItemVideoViewHideMediaContentPreview",
"TimelineItemVoiceViewUnifiedPreview",
"TimelineVideoWithCaptionRowPreview",
"TimelineViewMessageShieldPreview",
"UserAvatarColorsPreview",
"UserProfileHeaderSectionWithVerificationViolationPreview",
"VoiceItemViewPlayPreview",
)
@Test
fun `previewNameExceptions is sorted alphabetically`() {
assertThat(previewNameExceptions.sorted()).isEqualTo(previewNameExceptions)
}
@Test
fun `previewNameExceptions only contains existing functions`() {
val names = previewNameExceptions.toMutableSet()
Konsist
.scopeFromProject()
.functions()
.withAllAnnotationsOf(PreviewsDayNight::class)
.withName(previewNameExceptions)
.let {
it.forEach { function ->
names.remove(function.name)
}
}
assertThat(names).isEmpty()
}
@Test
fun `Functions with '@PreviewsDayNight' have correct name`() {
Konsist
.scopeFromProject()
.functions()
.withAllAnnotationsOf(PreviewsDayNight::class)
.withoutName(previewNameExceptions)
.assertTrue(
additionalMessage = "Functions for Preview should be named like this: <ViewUnderPreview>Preview. " +
"Exception can be added to the test, for multiple Previews of the same view",
) {
val testedView = if (it.name.endsWith("RtlPreview")) {
it.name.removeSuffix("RtlPreview")
} else {
it.name.removeSuffix("Preview")
}
it.name.endsWith("Preview") &&
(it.text.contains("$testedView(") ||
it.text.contains("$testedView {") ||
it.text.contains("ContentToPreview("))
}
}
@Test
fun `Ensure that '@PreviewLightDark' is not used`() {
Konsist
.scopeFromProject()
.functions()
.withAllAnnotationsOf(PreviewLightDark::class)
.assertEmpty(
additionalMessage = "Use '@PreviewsDayNight' instead of '@PreviewLightDark', or else screenshot(s) will not be generated.",
)
}
}

View File

@@ -0,0 +1,122 @@
/*
* 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.konsist
import com.google.common.truth.Truth.assertThat
import com.lemonappdev.konsist.api.Konsist
import com.lemonappdev.konsist.api.ext.list.modifierprovider.withoutOverrideModifier
import com.lemonappdev.konsist.api.ext.list.withAnnotationOf
import com.lemonappdev.konsist.api.ext.list.withFunction
import com.lemonappdev.konsist.api.ext.list.withReturnType
import com.lemonappdev.konsist.api.ext.list.withoutAnnotationOf
import com.lemonappdev.konsist.api.ext.list.withoutName
import com.lemonappdev.konsist.api.verify.assertFalse
import com.lemonappdev.konsist.api.verify.assertTrue
import org.junit.Ignore
import org.junit.Test
class KonsistTestTest {
@Test
fun `Ensure that unit tests are detected`() {
val numberOfTests = Konsist
.scopeFromTest()
.functions()
.withAnnotationOf(Test::class)
.withoutAnnotationOf(Ignore::class)
.size
println("Number of unit tests: $numberOfTests")
assertThat(numberOfTests).isGreaterThan(2000)
}
@Test
fun `Classes name containing @Test must end with 'Test'`() {
Konsist
.scopeFromTest()
.classes()
.withoutName("S", "T")
.withFunction { it.hasAnnotationOf(Test::class) }
.assertTrue { it.name.endsWith("Test") }
}
@Test
fun `Function which creates Presenter in test MUST be named 'createPresenterName'`() {
Konsist
.scopeFromTest()
.functions()
.withReturnType { it.name.endsWith("Presenter") }
.withoutOverrideModifier()
.assertTrue(
additionalMessage = "The function can also be named 'createPresenter'. To please Konsist in this case, just remove the return type."
) { functionDeclaration ->
functionDeclaration.name == "create${functionDeclaration.returnType?.name}"
}
}
@Test
fun `assertion methods must be imported`() {
Konsist
.scopeFromTest()
.functions()
// Exclude self
.withoutName("assertion methods must be imported")
.assertFalse(
additionalMessage = "Import methods from Truth, instead of using for instance Truth.assertThat(...)"
) { functionDeclaration ->
functionDeclaration.text.contains("Truth.")
}
}
@Test
fun `use isFalse() instead of isEqualTo(false)`() {
Konsist
.scopeFromTest()
.functions()
// Exclude self
.withoutName("use isFalse() instead of isEqualTo(false)")
.assertFalse { functionDeclaration ->
functionDeclaration.text.contains("isEqualTo(false)")
}
}
@Test
fun `use isTrue() instead of isEqualTo(true)`() {
Konsist
.scopeFromTest()
.functions()
// Exclude self
.withoutName("use isTrue() instead of isEqualTo(true)")
.assertFalse { functionDeclaration ->
functionDeclaration.text.contains("isEqualTo(true)")
}
}
@Test
fun `use isEmpty() instead of isEqualTo(empty)`() {
Konsist
.scopeFromTest()
.functions()
// Exclude self
.withoutName("use isEmpty() instead of isEqualTo(empty)")
.assertFalse { functionDeclaration ->
functionDeclaration.text.contains("isEqualTo(empty")
}
}
@Test
fun `use isNull() instead of isEqualTo(null)`() {
Konsist
.scopeFromTest()
.functions()
// Exclude self
.withoutName("use isNull() instead of isEqualTo(null)")
.assertFalse { functionDeclaration ->
functionDeclaration.text.contains("isEqualTo(null)")
}
}
}

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)
}
}
}

1
tests/uitests/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,59 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-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.
*/
import extension.allFeaturesImpl
import extension.allLibrariesImpl
import extension.allServicesImpl
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.paparazzi)
}
android {
// Keep it as short as possible
namespace = "ui"
}
tasks.withType(Test::class.java) {
// Don't fail the test run if there are no tests, this can happen if we run them with screenshot test disabled
failOnNoDiscoveredTests = false
}
dependencies {
// Paparazzi 1.3.2 workaround (see https://github.com/cashapp/paparazzi/blob/master/CHANGELOG.md#132---2024-01-13)
constraints.add("testImplementation", "com.google.guava:guava") {
attributes {
attribute(
TargetJvmEnvironment.TARGET_JVM_ENVIRONMENT_ATTRIBUTE,
objects.named(TargetJvmEnvironment::class.java, TargetJvmEnvironment.STANDARD_JVM)
)
}
because(
"LayoutLib and sdk-common depend on Guava's -jre published variant." +
"See https://github.com/cashapp/paparazzi/issues/906."
)
}
implementation(libs.showkase)
// TODO There is a Resources.NotFoundException maybe due to the mipmap, even if we have
// `testOptions { unitTests.isIncludeAndroidResources = true }` in the app build.gradle.kts file
// implementation(projects.app)
implementation(projects.appnav)
allLibrariesImpl()
allServicesImpl()
allFeaturesImpl(project)
implementation(projects.appicon.element)
implementation(projects.appicon.enterprise)
testImplementation(libs.test.junit)
testImplementation(libs.test.parameter.injector)
testImplementation(projects.libraries.designsystem)
testImplementation(libs.test.composable.preview.scanner)
}

View File

View File

@@ -0,0 +1,18 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-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 base
import app.cash.paparazzi.DeviceConfig
enum class BaseDeviceConfig(
val deviceConfig: DeviceConfig,
) {
NEXUS_5(DeviceConfig.NEXUS_5),
// PIXEL_C(DeviceConfig.PIXEL_C),
}

View File

@@ -0,0 +1,71 @@
/*
* 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.
*/
@file:Suppress("DEPRECATION")
package base
import com.google.testing.junit.testparameterinjector.TestParameterValuesProvider
import sergio.sastre.composable.preview.scanner.android.AndroidComposablePreviewScanner
import sergio.sastre.composable.preview.scanner.android.AndroidPreviewInfo
import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview
// Make sure we don't import Compound previews by mistake
private val PACKAGE_TREES = arrayOf(
"io.element.android.features",
"io.element.android.libraries",
"io.element.android.services",
"io.element.android.appicon",
"io.element.android.appnav",
"io.element.android.x",
)
object ComposablePreviewProvider : TestParameterValuesProvider() {
val values: List<IndexedValue<ComposablePreview<AndroidPreviewInfo>>> by lazy {
AndroidComposablePreviewScanner()
.scanPackageTrees(*PACKAGE_TREES)
.getPreviews()
.filter { composablePreview -> composablePreview.methodName.endsWith("A11yPreview").not() }
.withIndex()
.toList()
}
override fun provideValues(context: Context): List<IndexedValue<ComposablePreview<AndroidPreviewInfo>>> = values
}
object ComposableA11yPreviewProvider : TestParameterValuesProvider() {
private val values: List<ComposablePreview<AndroidPreviewInfo>> by lazy {
AndroidComposablePreviewScanner()
.scanPackageTrees(*PACKAGE_TREES)
.getPreviews()
.filter { composablePreview -> composablePreview.methodName.endsWith("A11yPreview") }
.toList()
}
override fun provideValues(context: Context): List<ComposablePreview<AndroidPreviewInfo>> = values
}
object Shard1ComposablePreviewProvider : TestParameterValuesProvider() {
override fun provideValues(context: Context): List<ComposablePreview<AndroidPreviewInfo>> =
ComposablePreviewProvider.values.filter { it.index % 4 == 0 }.map { it.value }
}
object Shard2ComposablePreviewProvider : TestParameterValuesProvider() {
override fun provideValues(context: Context): List<ComposablePreview<AndroidPreviewInfo>> =
ComposablePreviewProvider.values.filter { it.index % 4 == 1 }.map { it.value }
}
object Shard3ComposablePreviewProvider : TestParameterValuesProvider() {
override fun provideValues(context: Context): List<ComposablePreview<AndroidPreviewInfo>> =
ComposablePreviewProvider.values.filter { it.index % 4 == 2 }.map { it.value }
}
object Shard4ComposablePreviewProvider : TestParameterValuesProvider() {
override fun provideValues(context: Context): List<ComposablePreview<AndroidPreviewInfo>> =
ComposablePreviewProvider.values.filter { it.index % 4 == 3 }.map { it.value }
}

View File

@@ -0,0 +1,139 @@
/*
* 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 base
import android.content.res.Configuration
import android.os.LocaleList
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.unit.Density
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import app.cash.paparazzi.RenderExtension
import app.cash.paparazzi.TestName
import com.android.resources.NightMode
import io.element.android.compound.theme.ElementTheme
import sergio.sastre.composable.preview.scanner.android.AndroidPreviewInfo
import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview
import java.util.Locale
object ScreenshotTest {
val defaultDeviceConfig = BaseDeviceConfig.NEXUS_5.deviceConfig
fun runTest(
paparazzi: Paparazzi,
preview: ComposablePreview<AndroidPreviewInfo>,
localeStr: String,
) {
val locale = localeStr.toLocale()
// Needed for regional settings, as first day of week
Locale.setDefault(locale)
paparazzi.fixScreenshotName(preview, localeStr)
paparazzi.snapshot {
CompositionLocalProvider(
LocalInspectionMode provides true,
LocalDensity provides Density(
density = LocalDensity.current.density,
fontScale = 1.0f,
),
LocalConfiguration provides Configuration().apply {
setLocales(LocaleList(locale))
uiMode = preview.previewInfo.uiMode
},
) {
ElementTheme {
Box(
modifier = Modifier
.background(ElementTheme.colors.bgCanvasDefault)
) {
preview()
}
}
}
}
}
}
private val testNameField = Paparazzi::class.java.getDeclaredField("testName").apply {
isAccessible = true
}
private fun Paparazzi.fixScreenshotName(preview: ComposablePreview<AndroidPreviewInfo>, locale: String) {
val id = listOf(createScreenshotIdFor(preview), locale)
.filter { it.isNotEmpty() }
.joinToString("_")
val packageName = preview.declaringClass
// Remove common prefix
.replace("io.element.android.", "")
.split(".")
// Remove class name
.dropLast(1)
.joinToString(".")
val testName = TestName(
packageName = packageName,
className = preview.methodName.replace("Preview", ""),
methodName = id
)
testNameField.set(this, testName)
}
private fun String.toLocale(): Locale {
return when (this) {
"en" -> Locale.US
"fr" -> Locale.FRANCE
"de" -> Locale.GERMAN
else -> Locale.Builder().setLanguage(this).build()
}
}
fun createScreenshotIdFor(preview: ComposablePreview<AndroidPreviewInfo>) = buildList {
// `name` here can be `Day`, `Night`, or nothing at all
if (preview.previewInfo.name.isNotEmpty()) {
add(preview.previewInfo.name)
}
if (preview.previewInfo.group.isNotEmpty()) {
add(preview.previewInfo.group)
}
// If it's a day/night preview, we should add an index to be consistent even if there is only version of this composable
val needsIndex = preview.previewInfo.name == "Day" || preview.previewInfo.name == "Night"
if (preview.previewIndex != null || needsIndex) {
add((preview.previewIndex ?: 0).toString())
}
}.joinToString("_")
object PaparazziPreviewRule {
fun createFor(
preview: ComposablePreview<AndroidPreviewInfo>,
locale: String,
deviceConfig: DeviceConfig = ScreenshotTest.defaultDeviceConfig,
renderExtensions: Set<RenderExtension> = setOf(),
): Paparazzi {
val densityScale = deviceConfig.density.dpiValue / 160f
val customScreenHeight = preview.previewInfo.heightDp.takeIf { it >= 0 }?.let { it * densityScale }?.toInt()
return Paparazzi(
deviceConfig = deviceConfig.copy(
nightMode = when (preview.previewInfo.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES) {
true -> NightMode.NIGHT
false -> NightMode.NOTNIGHT
},
locale = locale,
softButtons = false,
screenHeight = customScreenHeight ?: deviceConfig.screenHeight,
),
maxPercentDifference = 0.01,
renderExtensions = renderExtensions,
)
}
}

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 translations
import android.content.res.Configuration
import base.ComposablePreviewProvider
import base.PaparazziPreviewRule
import base.ScreenshotTest
import com.google.testing.junit.testparameterinjector.TestParameter
import com.google.testing.junit.testparameterinjector.TestParameterInjector
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import sergio.sastre.composable.preview.scanner.android.AndroidPreviewInfo
import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview
/**
* Test that takes a preview and a locale and runs a screenshot test on it.
*/
@RunWith(TestParameterInjector::class)
class TranslationsScreenshotTest(
@TestParameter(valuesProvider = ComposablePreviewProvider::class)
val indexedPreview: IndexedValue<ComposablePreview<AndroidPreviewInfo>>,
@TestParameter(value = ["de"])
val localeStr: String,
) {
@get:Rule
val paparazziRule = PaparazziPreviewRule.createFor(indexedPreview.value, locale = localeStr)
@Test
fun snapshot() {
val (_, preview) = indexedPreview
// Skip for dark mode screenshots
if (preview.previewInfo.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES) {
return
}
// Skip for design system screenshots
if (preview.previewInfo.name.startsWith("io.element.android.libraries.designsystem")) {
return
}
ScreenshotTest.runTest(paparazzi = paparazziRule, preview = preview, localeStr = localeStr)
}
}

View File

@@ -0,0 +1,43 @@
/*
* 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 ui
import app.cash.paparazzi.accessibility.AccessibilityRenderExtension
import base.ComposableA11yPreviewProvider
import base.PaparazziPreviewRule
import base.ScreenshotTest
import com.google.testing.junit.testparameterinjector.TestParameter
import com.google.testing.junit.testparameterinjector.TestParameterInjector
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import sergio.sastre.composable.preview.scanner.android.AndroidPreviewInfo
import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview
/**
* Test that takes a preview and runs a screenshot test on it.
* It uses [ComposableA11yPreviewProvider] to test only previews that ends with "A11yPreview".
*/
@RunWith(TestParameterInjector::class)
class PreviewA11yTest(
@TestParameter(valuesProvider = ComposableA11yPreviewProvider::class)
val preview: ComposablePreview<AndroidPreviewInfo>,
) {
@get:Rule
val paparazziRule = PaparazziPreviewRule.createFor(
preview = preview,
locale = "en",
renderExtensions = setOf(AccessibilityRenderExtension()),
)
@Test
fun snapshot() {
ScreenshotTest.runTest(paparazzi = paparazziRule, preview = preview, localeStr = "en")
}
}

View File

@@ -0,0 +1,38 @@
/*
* 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 ui
import base.PaparazziPreviewRule
import base.ScreenshotTest
import base.Shard1ComposablePreviewProvider
import com.google.testing.junit.testparameterinjector.TestParameter
import com.google.testing.junit.testparameterinjector.TestParameterInjector
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import sergio.sastre.composable.preview.scanner.android.AndroidPreviewInfo
import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview
/**
* Test that takes a preview and runs a screenshot test on it.
* It uses a sharded preview provider so multiple 'shards' can run in parallel, optimizing CPU and time usage.
*/
@RunWith(TestParameterInjector::class)
class PreviewShard1Test(
@TestParameter(valuesProvider = Shard1ComposablePreviewProvider::class)
val preview: ComposablePreview<AndroidPreviewInfo>,
) {
@get:Rule
val paparazziRule = PaparazziPreviewRule.createFor(preview, locale = "en")
@Test
fun snapshot() {
ScreenshotTest.runTest(paparazzi = paparazziRule, preview = preview, localeStr = "en")
}
}

View File

@@ -0,0 +1,38 @@
/*
* 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 ui
import base.PaparazziPreviewRule
import base.ScreenshotTest
import base.Shard2ComposablePreviewProvider
import com.google.testing.junit.testparameterinjector.TestParameter
import com.google.testing.junit.testparameterinjector.TestParameterInjector
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import sergio.sastre.composable.preview.scanner.android.AndroidPreviewInfo
import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview
/**
* Test that takes a preview and runs a screenshot test on it.
* It uses a sharded preview provider so multiple 'shards' can run in parallel, optimizing CPU and time usage.
*/
@RunWith(TestParameterInjector::class)
class PreviewShard2Test(
@TestParameter(valuesProvider = Shard2ComposablePreviewProvider::class)
val preview: ComposablePreview<AndroidPreviewInfo>,
) {
@get:Rule
val paparazziRule = PaparazziPreviewRule.createFor(preview, locale = "en")
@Test
fun snapshot() {
ScreenshotTest.runTest(paparazzi = paparazziRule, preview = preview, localeStr = "en")
}
}

View File

@@ -0,0 +1,38 @@
/*
* 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 ui
import base.PaparazziPreviewRule
import base.ScreenshotTest
import base.Shard3ComposablePreviewProvider
import com.google.testing.junit.testparameterinjector.TestParameter
import com.google.testing.junit.testparameterinjector.TestParameterInjector
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import sergio.sastre.composable.preview.scanner.android.AndroidPreviewInfo
import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview
/**
* Test that takes a preview and runs a screenshot test on it.
* It uses a sharded preview provider so multiple 'shards' can run in parallel, optimizing CPU and time usage.
*/
@RunWith(TestParameterInjector::class)
class PreviewShard3Test(
@TestParameter(valuesProvider = Shard3ComposablePreviewProvider::class)
val preview: ComposablePreview<AndroidPreviewInfo>,
) {
@get:Rule
val paparazziRule = PaparazziPreviewRule.createFor(preview, locale = "en")
@Test
fun snapshot() {
ScreenshotTest.runTest(paparazzi = paparazziRule, preview = preview, localeStr = "en")
}
}

View File

@@ -0,0 +1,38 @@
/*
* 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 ui
import base.PaparazziPreviewRule
import base.ScreenshotTest
import base.Shard4ComposablePreviewProvider
import com.google.testing.junit.testparameterinjector.TestParameter
import com.google.testing.junit.testparameterinjector.TestParameterInjector
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import sergio.sastre.composable.preview.scanner.android.AndroidPreviewInfo
import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview
/**
* Test that takes a preview and runs a screenshot test on it.
* It uses a sharded preview provider so multiple 'shards' can run in parallel, optimizing CPU and time usage.
*/
@RunWith(TestParameterInjector::class)
class PreviewShard4Test(
@TestParameter(valuesProvider = Shard4ComposablePreviewProvider::class)
val preview: ComposablePreview<AndroidPreviewInfo>,
) {
@get:Rule
val paparazziRule = PaparazziPreviewRule.createFor(preview, locale = "en")
@Test
fun snapshot() {
ScreenshotTest.runTest(paparazzi = paparazziRule, preview = preview, localeStr = "en")
}
}

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0db3553ee5ed730fda10fa78bb19f3714c7ecdf2b2b82b0946335aaede2c3c61
size 13557

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:901b31047da757e8f0cd391dd7bbbf9d7c20e66bb4c21497acbe1616648926ba
size 7084

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d588b54e770dd21a63208bdd596f3e7f3c4074b8cbfa33affcc8750c4bf89c2d
size 16187

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:56c6325a42172a4e9b8675e9777292b329f2262435cc90cb2c918171656d6156
size 17021

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:398dd5c12a4561286d3d9471e7bfd4e2797d2832ae51dec830d6c97b76413fc2
size 16190

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:96a867cb12498cbdc97957bee07855dfaa13602baddaf933aff2b666ef4c7650
size 3642

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dfcbb11744ad6ab80a011067542140a926e86862380d3be25069649b665b5b3d
size 8279

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d42f53b0b77ce6f20c61c0ee491df157e878611a7eeeb44c9cb6411b348638da
size 37102

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:249cb85b4d269ca7829a00587f6840cbf1f10e51e28568186e419b38e1126610
size 24285

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5bb36ccd718f3fec5b04f1bc812dc7718b5ea7fa4619c8b031466297a8d016fd
size 3659

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:11fb4712defab1a03444cb18403d3555e6d6f6069915252a36bea0c2c80a2187
size 7024

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f0569db10eadf71f6443a0693c67f92189eb7ca52eb73c038a9c1733c856b9a7
size 35103

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:75bc869a9591043ab8bf5ab9944c2152b7f72dccac2ab8a541d65fc2c97199ae
size 22311

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e0df5eff771a20895cb1f79b00b1991a84ea505b0b60741db8d72bc46173ad6c
size 8775

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1a89875acd53deed14d47775c283323036960476d0bb52b016f5eaf5bd800a8a
size 7408

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:071914fc3e7bbc2fc6ef8a1b4544b9126c05dd08b2123666d320223582423226
size 5632

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5e9df6017bd0771e1337f82b75d13a0a8a572156e6900ae72f208e9bb2aaddb7
size 7717

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:811bd4534f4f16530cea1489b62d393e2fe7bf1b18b859a924333cd9d309f8dd
size 5566

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:030971f2c5f8c19f47fed2fa207c6d84d536c603fa7705ea6c288736684b7868
size 7553

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b33e598d33b6f514510adceb319573e0fded75719b2ab01265dce08ef180d9ec
size 25833

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:153ec618066e0dc9738a337bac5bbcae459e8143e5acac2846f144a4ad62d48e
size 27953

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9d27f8fbaed8cdbb6ad03414cf6f8c2d38b3269731ee72f5fe9eab1ad6afe9df
size 21819

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a82a01623f008b3a374cfb4c08b1a592bf7eedfca57b64fd2e819b53056be626
size 24309

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:97fc1dfcde1dcaf67c6cfacc0fb172b053668d418165cb1d8f67eb18a220a563
size 26313

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4227570b201034be916a714dcf27c9ab4d17d6cc3d47a8eab9ffae3b9b61bb4a
size 19967

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f78f491a8643f02587d8cd8845e82071483e76977b2e7735d2791869c6f5fe8e
size 22370

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0a7b8718daff4a22061e556001827e4cf58330e0de36cfa331874d9d2f6638c6
size 18493

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:46dc3f8fec381c5102af2a08ebaa6bb5d5f2a05ecd5254bb8cae14766bb74072
size 21167

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0aa4f30dac0e0eadadf40f2b699c446fe4d236dbc9b373aeb1d115d08ce76e8b
size 17440

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0297e771c4370ede91623c024d63cf750826578381d2f08885e8246fc4c8c82f
size 83236

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:407a886dee6739c1075f8865603250ab0a2a7c79d4b8cdf8ed2f7617c8cdd9aa
size 82078

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b5cfe3523c7a0fe50dbac6fe6ce842999c60b205d7347697c72b89074bf29c56
size 76369

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4bdb9e6d6d8423e9d7a847095b2a96fb47b2ed6fee977c6e163682b5f20e43db
size 74563

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:48f54197f74cf0675e04a83efbb278728568024bcec38a8553b623cdba384e50
size 60460

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b2e2356a3f8521649d6ad2c10f654142f87141e3f747db5a6f6ba25483c8171a
size 59516

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:56f3fe80ef0161c25a8bc52a1649dde2c113fd2e91f0e7462bf2329d45d83508
size 9451

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1284c9d7c90881d5affcabbc5a14dad86234ff9da2c32d2575f55876e89642e6
size 11705

Some files were not shown because too many files have changed in this diff Show More