Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
package com.telefonica.loggerazzi

import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4

import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

import org.junit.Rule

/**
* Instrumented test, which will execute on an Android device.
*
Expand All @@ -17,11 +16,12 @@ class ExampleInstrumentedTest {

private val recorder = FakeTestRecorder()

@JvmField
@Rule
@get:Rule
val loggerazziRule = LoggerazziRule(
recorder = recorder
)
@get:Rule
val screenshotsRule = ScreenshotsRule()

@Test
fun testSingleLog() {
Expand All @@ -41,16 +41,24 @@ class ExampleInstrumentedTest {
}

@Test
@IgnoreLoggerazzi
@IgnoreLogs
fun testIgnoreLoggerazzi() {
recorder.record("My log")
}

@Test
@IgnoreLoggerazzi
@IgnoreLogs
fun testIgnoreLoggerazziWithoutGoldenFile() {
recorder.record("My log")
}

@Test
@IgnoreLogs
fun testLaunchActivity() {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New test to check a basic screenshot

ActivityScenario.launch(MainActivity::class.java).onActivity {
screenshotsRule.compareScreenshot(it, name = "launch_activity")
}
}
}

class FakeTestRecorder: LogsRecorder<String> {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions build-tools/detekt/detekt.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ complexity:
active: false
LongMethod:
ignoreAnnotated: 'Composable'
NestedBlockDepth:
threshold: 5

style:
NewLineAtEndOfFile:
Expand Down
4 changes: 4 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ detekt = "1.23.6"
espresso-core = "3.6.1"
junit = "4.13.2"
publish = "2.0.0"
ui-test-junit4-android = "1.8.2"

[libraries]
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
Expand All @@ -24,6 +25,9 @@ espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref =
espresso-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-junit" }
junit = { module = "junit:junit", version.ref = "junit" }
material = { module = "com.google.android.material:material", version.ref = "material" }
androidx-test-runner = { module = "androidx.test:runner", version = "1.6.2" }
androidx-ui-test-junit4-android = { group = "androidx.compose.ui", name = "ui-test-junit4-android", version.ref = "ui-test-junit4-android" }
differ = "com.dropbox.differ:differ:0.3.0"

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,8 @@ class LoggerazziPlugin @Inject constructor(
file.delete()
}
}
lastFile?.renameTo(File(this, "$key.txt"))

lastFile?.renameTo(File(this, key))
Comment on lines -150 to +151
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm including the extension in the original file so no need to include it here

}
}
}
8 changes: 8 additions & 0 deletions loggerazzi/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,18 @@ android {
}
}

kotlin {
explicitApi()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added to force the classes to define their visibility.

}

dependencies {
api(libs.differ)

implementation(libs.core.ktx)
implementation(libs.junit)
implementation(libs.androidx.test.monitor)
implementation(libs.androidx.test.runner)
implementation(libs.androidx.ui.test.junit4.android)
}

apply("${rootProject.projectDir}/mavencentral.gradle")
18 changes: 18 additions & 0 deletions loggerazzi/src/main/java/com/telefonica/loggerazzi/BitmapImage.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.telefonica.loggerazzi

import android.graphics.Bitmap
import com.dropbox.differ.Color
import com.dropbox.differ.Image
import androidx.core.graphics.get

internal class BitmapImage(private val src: Bitmap) : Image {
override val width: Int get() = src.width
override val height: Int get() = src.height
override fun getPixel(x: Int, y: Int): Color {
try {
return Color(src[x, y])
} catch (e: IllegalArgumentException) {
throw IllegalArgumentException("Can't request pixel {x = $x, y = $y} from image {width = $width, height = $height}", e)
}
}
}

This file was deleted.

10 changes: 10 additions & 0 deletions loggerazzi/src/main/java/com/telefonica/loggerazzi/Ignores.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.telefonica.loggerazzi
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've created a new annotation to ignore the screenshots checks and rename the logs one



@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION)
public annotation class IgnoreLogs

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION)
public annotation class IgnoreScreenshots
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ package com.telefonica.loggerazzi

import java.lang.StringBuilder

interface LogComparator<LogType> {
fun compare(recorded: List<LogType>, golden: List<LogType>): String?
public interface LogComparator<LogType> {
public fun compare(recorded: List<LogType>, golden: List<LogType>): String?
}

class DefaultLogComparator<LogType> : LogComparator<LogType> {
public class DefaultLogComparator<LogType> : LogComparator<LogType> {
override fun compare(recorded: List<LogType>, golden: List<LogType>): String? {
if (recorded.size != golden.size) {
return "Different number of lines: recorded=${recorded.size}, golden=${golden.size}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import org.junit.rules.TestWatcher
import org.junit.runner.Description
import java.io.File

class LoggerazziRule(
public class LoggerazziRule(
recorder: LogsRecorder<String>,
comparator: LogComparator<String> = DefaultLogComparator(),
) : GenericLoggerazziRule<String>(
Expand All @@ -18,8 +18,8 @@ class LoggerazziRule(
comparator = comparator,
)

open class GenericLoggerazziRule<LogType>(
val recorder: LogsRecorder<LogType>,
public open class GenericLoggerazziRule<LogType>(
public val recorder: LogsRecorder<LogType>,
private val stringMapper: StringMapper<LogType>,
private val comparator: LogComparator<LogType> = DefaultLogComparator(),
) : TestWatcher() {
Expand Down Expand Up @@ -48,10 +48,10 @@ open class GenericLoggerazziRule<LogType>(
override fun succeeded(description: Description?) {
super.succeeded(description)

val isTestIgnored = description?.getAnnotation(IgnoreLoggerazzi::class.java) != null
val isTestIgnored = description?.getAnnotation(IgnoreLogs::class.java) != null

val testName = "${description?.className}_${description?.methodName}"
val fileName = "${testName}.${System.nanoTime()}"
val fileName = "${testName}.txt.${System.nanoTime()}"

val recordedLogs = recorder.getRecordedLogs()
val log = recordedLogs.joinToString("\n") { stringMapper.fromLog(it) }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.telefonica.loggerazzi

interface LogsRecorder<LogType> {
fun clear()
fun getRecordedLogs(): List<LogType>
public interface LogsRecorder<LogType> {
public fun clear()
public fun getRecordedLogs(): List<LogType>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.telefonica.loggerazzi
Copy link
Contributor Author

@jeslat jeslat Jun 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy from dropshots, it allows you to customize the threshold


import com.dropbox.differ.ImageComparator
import kotlin.math.roundToInt

/**
* A function used to validate the comparison result.
*/
public typealias ResultValidator = (result: ImageComparator.ComparisonResult) -> Boolean

/**
* Fails validation if there are more than `count` pixel differences.
*/
@Suppress("FunctionName")
public fun CountValidator(count: Int) : ResultValidator {
require(count >= 0) { "count must be greater than or equal to 0." }
return { result ->
result.pixelDifferences <= count
}
}

/**
* Fails validation if more than `threshold` percent of pixels are different.
*/
@Suppress("FunctionName")
public fun ThresholdValidator(threshold: Float) : ResultValidator {
require(threshold in 0f..1f) { "threshold must be in range 0.0..1.0"}
return { result ->
result.pixelDifferences <= (result.pixelCount * threshold).roundToInt()
}
}


149 changes: 149 additions & 0 deletions loggerazzi/src/main/java/com/telefonica/loggerazzi/ScreenshotsRule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package com.telefonica.loggerazzi

import android.app.Activity
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Build
import android.os.Environment
import androidx.annotation.RequiresApi
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onRoot
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.runner.screenshot.Screenshot
import com.dropbox.differ.ImageComparator
import com.dropbox.differ.Mask
import com.dropbox.differ.SimpleImageComparator
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
import java.io.File
import java.io.FileNotFoundException

public class ScreenshotsRule(
private val imageComparator: ImageComparator = SimpleImageComparator(maxDistance = 0.004f),
private val resultValidator: ResultValidator = CountValidator(0),
) : TestRule {

private var className: String = ""
private var testName: String = ""
private var isTestIgnored: Boolean = false
private val writeDiffImage = WriteDiffImage()

private val context = InstrumentationRegistry.getInstrumentation().context
private val downloadDir = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath
)
private val loggerazziDir = File(downloadDir, "loggerazzi-logs/${context.packageName}")
private val failuresDir = File(loggerazziDir, "failures")
private val recordedDir = File(loggerazziDir, "recorded")
Comment on lines +38 to +40

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I though we was going to stop using loggerazzi. I'm seeing it in many places (package, class names, etc), is there a renaming task in the roadmap or aren't we going to stop using that name?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the rename is pending, I will do it after this


override fun apply(base: Statement, description: Description): Statement {
className = description.className
testName = description.methodName
isTestIgnored = description.getAnnotation(IgnoreScreenshots::class.java) != null

if (!failuresDir.exists()) {
failuresDir.mkdirs()
}
if (!recordedDir.exists()) {
recordedDir.mkdirs()
}
return base
}

@RequiresApi(Build.VERSION_CODES.O)
public fun compareScreenshot(
rule: ComposeTestRule,
name: String?,
) {
rule.waitForIdle()
val bitmap = rule.onRoot().captureToImage().asAndroidBitmap()
compareScreenshot(bitmap, name)
}

public fun compareScreenshot(
activity: Activity,
name: String?,
) {
val bitmap = Screenshot.capture(activity).bitmap
compareScreenshot(bitmap, name)
}

@Suppress("MemberVisibilityCanBePrivate")
public fun compareScreenshot(
bitmap: Bitmap,
name: String?
) {
Comment on lines +56 to +78
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've copied the same API that Shot has

val resourceName = "${className}_${name ?: testName}.png"
val fileName = "$resourceName.${System.nanoTime()}"
saveScreenshot(fileName, bitmap)

if (InstrumentationRegistry.getArguments().getString("record") != "true" && !isTestIgnored) {
val goldenBitmap = getGoldenBitmap(resourceName)
compareImagesSize(bitmap, goldenBitmap, fileName, name)
compareImages(bitmap, goldenBitmap, fileName, resourceName)
}
Comment on lines +83 to +87
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are not recording the baseline or this test is ignored, we compare the screenshot.

}

private fun saveScreenshot(fileName: String, bitmap: Bitmap) {
val testFile = File(recordedDir, fileName)
testFile.createNewFile()
testFile.outputStream().use {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)
}
}

private fun getGoldenBitmap(resourceName: String): Bitmap {
val goldenBitmap = try {
context.assets.open("loggerazzi-golden-files/$resourceName").use {
BitmapFactory.decodeStream(it)
}
} catch (e: FileNotFoundException) {
throw IllegalStateException(
"Failed to find golden image named $resourceName. If this is a new test, you may need to record screenshots",
e
)
}
return goldenBitmap
}

private fun compareImagesSize(
bitmap: Bitmap,
goldenBitmap: Bitmap,
fileName: String,
name: String?
) {
if (bitmap.width != goldenBitmap.width || bitmap.height != goldenBitmap.height) {
writeDiffImage(failuresDir, fileName, bitmap, goldenBitmap, null)
throw AssertionError(
"$name: Test image (w=${bitmap.width}, h=${bitmap.height}) differs in size" +
" from reference image (w=${goldenBitmap.width}, h=${goldenBitmap.height}).\n",
)
}
}

private fun compareImages(
bitmap: Bitmap,
goldenBitmap: Bitmap,
fileName: String,
resourceName: String
) {
val mask = Mask(bitmap.width, bitmap.height)
val result = try {
imageComparator.compare(BitmapImage(goldenBitmap), BitmapImage(bitmap), mask)
} catch (e: IllegalArgumentException) {
writeDiffImage(failuresDir, fileName, bitmap, goldenBitmap, mask)
throw AssertionError("Failed to compare images", e)
}

if (!resultValidator(result)) {
writeDiffImage(failuresDir, fileName, bitmap, goldenBitmap, mask)
throw AssertionError(
"\"$resourceName\" failed to match reference image. ${result.pixelDifferences} pixels differ " +
"(${(result.pixelDifferences / result.pixelCount.toFloat()) * 100} %)"
)
}
}
}
Loading