diff --git a/app/src/androidTest/java/com/telefonica/loggerazzi/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/telefonica/loggerazzi/ExampleInstrumentedTest.kt index 1c1d8fb..bd69b47 100644 --- a/app/src/androidTest/java/com/telefonica/loggerazzi/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/telefonica/loggerazzi/ExampleInstrumentedTest.kt @@ -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. * @@ -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() { @@ -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() { + ActivityScenario.launch(MainActivity::class.java).onActivity { + screenshotsRule.compareScreenshot(it, name = "launch_activity") + } + } } class FakeTestRecorder: LogsRecorder { diff --git a/app/src/androidTestDebug/assets/loggerazzi-golden-files/com.telefonica.loggerazzi.ExampleInstrumentedTest_launch_activity.png b/app/src/androidTestDebug/assets/loggerazzi-golden-files/com.telefonica.loggerazzi.ExampleInstrumentedTest_launch_activity.png new file mode 100644 index 0000000..b2fad07 Binary files /dev/null and b/app/src/androidTestDebug/assets/loggerazzi-golden-files/com.telefonica.loggerazzi.ExampleInstrumentedTest_launch_activity.png differ diff --git a/app/src/androidTestDebug/assets/loggerazzi-golden-files/com.telefonica.loggerazzi.ExampleInstrumentedTest_testLaunchActivity.txt b/app/src/androidTestDebug/assets/loggerazzi-golden-files/com.telefonica.loggerazzi.ExampleInstrumentedTest_testLaunchActivity.txt new file mode 100644 index 0000000..e69de29 diff --git a/build-tools/detekt/detekt.yml b/build-tools/detekt/detekt.yml index 8deead1..bcdaeb9 100644 --- a/build-tools/detekt/detekt.yml +++ b/build-tools/detekt/detekt.yml @@ -5,6 +5,8 @@ complexity: active: false LongMethod: ignoreAnnotated: 'Composable' + NestedBlockDepth: + threshold: 5 style: NewLineAtEndOfFile: diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3282602..f46492d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } @@ -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" } diff --git a/include-build/gradle-plugin/src/main/java/com/telefonica/loggerazzi/LoggerazziPlugin.kt b/include-build/gradle-plugin/src/main/java/com/telefonica/loggerazzi/LoggerazziPlugin.kt index 2cf8da7..648ed38 100644 --- a/include-build/gradle-plugin/src/main/java/com/telefonica/loggerazzi/LoggerazziPlugin.kt +++ b/include-build/gradle-plugin/src/main/java/com/telefonica/loggerazzi/LoggerazziPlugin.kt @@ -147,7 +147,8 @@ class LoggerazziPlugin @Inject constructor( file.delete() } } - lastFile?.renameTo(File(this, "$key.txt")) + + lastFile?.renameTo(File(this, key)) } } } diff --git a/loggerazzi/build.gradle.kts b/loggerazzi/build.gradle.kts index 6c78846..672f3f1 100644 --- a/loggerazzi/build.gradle.kts +++ b/loggerazzi/build.gradle.kts @@ -36,10 +36,18 @@ android { } } +kotlin { + explicitApi() +} + 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") diff --git a/loggerazzi/src/main/java/com/telefonica/loggerazzi/BitmapImage.kt b/loggerazzi/src/main/java/com/telefonica/loggerazzi/BitmapImage.kt new file mode 100644 index 0000000..a72460f --- /dev/null +++ b/loggerazzi/src/main/java/com/telefonica/loggerazzi/BitmapImage.kt @@ -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) + } + } +} diff --git a/loggerazzi/src/main/java/com/telefonica/loggerazzi/IgnoreLoggerazzi.kt b/loggerazzi/src/main/java/com/telefonica/loggerazzi/IgnoreLoggerazzi.kt deleted file mode 100644 index f8cab24..0000000 --- a/loggerazzi/src/main/java/com/telefonica/loggerazzi/IgnoreLoggerazzi.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.telefonica.loggerazzi - - -@Retention(AnnotationRetention.RUNTIME) -@Target(AnnotationTarget.FUNCTION) -annotation class IgnoreLoggerazzi diff --git a/loggerazzi/src/main/java/com/telefonica/loggerazzi/Ignores.kt b/loggerazzi/src/main/java/com/telefonica/loggerazzi/Ignores.kt new file mode 100644 index 0000000..0a1a224 --- /dev/null +++ b/loggerazzi/src/main/java/com/telefonica/loggerazzi/Ignores.kt @@ -0,0 +1,10 @@ +package com.telefonica.loggerazzi + + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION) +public annotation class IgnoreLogs + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION) +public annotation class IgnoreScreenshots diff --git a/loggerazzi/src/main/java/com/telefonica/loggerazzi/LogComparator.kt b/loggerazzi/src/main/java/com/telefonica/loggerazzi/LogComparator.kt index 91a0d17..96faab7 100644 --- a/loggerazzi/src/main/java/com/telefonica/loggerazzi/LogComparator.kt +++ b/loggerazzi/src/main/java/com/telefonica/loggerazzi/LogComparator.kt @@ -2,11 +2,11 @@ package com.telefonica.loggerazzi import java.lang.StringBuilder -interface LogComparator { - fun compare(recorded: List, golden: List): String? +public interface LogComparator { + public fun compare(recorded: List, golden: List): String? } -class DefaultLogComparator : LogComparator { +public class DefaultLogComparator : LogComparator { override fun compare(recorded: List, golden: List): String? { if (recorded.size != golden.size) { return "Different number of lines: recorded=${recorded.size}, golden=${golden.size}" diff --git a/loggerazzi/src/main/java/com/telefonica/loggerazzi/LoggerazziRule.kt b/loggerazzi/src/main/java/com/telefonica/loggerazzi/LoggerazziRule.kt index 9a988d8..6bf7d78 100644 --- a/loggerazzi/src/main/java/com/telefonica/loggerazzi/LoggerazziRule.kt +++ b/loggerazzi/src/main/java/com/telefonica/loggerazzi/LoggerazziRule.kt @@ -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, comparator: LogComparator = DefaultLogComparator(), ) : GenericLoggerazziRule( @@ -18,8 +18,8 @@ class LoggerazziRule( comparator = comparator, ) -open class GenericLoggerazziRule( - val recorder: LogsRecorder, +public open class GenericLoggerazziRule( + public val recorder: LogsRecorder, private val stringMapper: StringMapper, private val comparator: LogComparator = DefaultLogComparator(), ) : TestWatcher() { @@ -48,10 +48,10 @@ open class GenericLoggerazziRule( 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) } diff --git a/loggerazzi/src/main/java/com/telefonica/loggerazzi/LogsRecorder.kt b/loggerazzi/src/main/java/com/telefonica/loggerazzi/LogsRecorder.kt index 9d83162..2d6ca2b 100644 --- a/loggerazzi/src/main/java/com/telefonica/loggerazzi/LogsRecorder.kt +++ b/loggerazzi/src/main/java/com/telefonica/loggerazzi/LogsRecorder.kt @@ -1,6 +1,6 @@ package com.telefonica.loggerazzi -interface LogsRecorder { - fun clear() - fun getRecordedLogs(): List +public interface LogsRecorder { + public fun clear() + public fun getRecordedLogs(): List } diff --git a/loggerazzi/src/main/java/com/telefonica/loggerazzi/ResultValidator.kt b/loggerazzi/src/main/java/com/telefonica/loggerazzi/ResultValidator.kt new file mode 100644 index 0000000..a2f8ab5 --- /dev/null +++ b/loggerazzi/src/main/java/com/telefonica/loggerazzi/ResultValidator.kt @@ -0,0 +1,33 @@ +package com.telefonica.loggerazzi + +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() + } +} + + diff --git a/loggerazzi/src/main/java/com/telefonica/loggerazzi/ScreenshotsRule.kt b/loggerazzi/src/main/java/com/telefonica/loggerazzi/ScreenshotsRule.kt new file mode 100644 index 0000000..0f36341 --- /dev/null +++ b/loggerazzi/src/main/java/com/telefonica/loggerazzi/ScreenshotsRule.kt @@ -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") + + 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? + ) { + 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) + } + } + + 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} %)" + ) + } + } +} diff --git a/loggerazzi/src/main/java/com/telefonica/loggerazzi/StringMapper.kt b/loggerazzi/src/main/java/com/telefonica/loggerazzi/StringMapper.kt index 9b4494f..7991c6d 100644 --- a/loggerazzi/src/main/java/com/telefonica/loggerazzi/StringMapper.kt +++ b/loggerazzi/src/main/java/com/telefonica/loggerazzi/StringMapper.kt @@ -1,7 +1,7 @@ package com.telefonica.loggerazzi -interface StringMapper { - fun fromLog(log: LogType): String +public interface StringMapper { + public fun fromLog(log: LogType): String - fun toLog(stringLog: String): LogType + public fun toLog(stringLog: String): LogType } \ No newline at end of file diff --git a/loggerazzi/src/main/java/com/telefonica/loggerazzi/WriteDiffImage.kt b/loggerazzi/src/main/java/com/telefonica/loggerazzi/WriteDiffImage.kt new file mode 100644 index 0000000..40f034f --- /dev/null +++ b/loggerazzi/src/main/java/com/telefonica/loggerazzi/WriteDiffImage.kt @@ -0,0 +1,72 @@ +package com.telefonica.loggerazzi + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import androidx.core.graphics.createBitmap +import com.dropbox.differ.Mask +import java.io.File + +internal class WriteDiffImage { + + /** + * Writes the given screenshot to the external reference image directory, returning the + * file path of the file that was written. + */ + operator fun invoke( + failuresDir: File, + fileName: String, + screenshot: Bitmap, + referenceImage: Bitmap, + mask: Mask?, + ) { + val diffFile = File(failuresDir, fileName) + val diffImage = generateDiffImage(referenceImage, screenshot, mask) + diffFile.outputStream().use { + diffImage.compress(Bitmap.CompressFormat.PNG, 100, it) + } + } + + /** + * Generates a `Bitmap` consisting of the reference image, the test image, and + * an image that highlights the differences between the two. + */ + private fun generateDiffImage( + referenceImage: Bitmap, + testImage: Bitmap, + differenceMask: Mask? + ): Bitmap { + // Render the failed screenshots to an output image + val maskWidth = differenceMask?.width ?: 0 + val maskHeight = differenceMask?.height ?: 0 + val output = + createBitmap( + width = referenceImage.width + testImage.width + maskWidth, + height = maxOf(referenceImage.height, testImage.height, maskHeight) + ) + val canvas = Canvas(output) + canvas.drawBitmap(referenceImage, 0f, 0f, null) + canvas.drawBitmap(testImage, referenceImage.width.toFloat() + maskWidth, 0f, null) + + // If we have a mask, draw it between the reference image and the test image. + if (differenceMask != null) { + canvas.drawBitmap(referenceImage, referenceImage.width.toFloat(), 0f, null) + + val diffPaint = Paint().apply { + color = 0x3DFF0000 + strokeWidth = 0f + } + val otherPaint = Paint().apply { + color = 0x3D000000 + strokeWidth = 0f + } + for (y in 0 until differenceMask.height) { + for (x in 0 until differenceMask.width) { + val paint = if (differenceMask.getValue(x, y) > 0) diffPaint else otherPaint + canvas.drawPoint(referenceImage.width + x.toFloat(), y.toFloat(), paint) + } + } + } + return output + } +}