diff --git a/.github/workflows/CompareScreenshotComment.yml b/.github/workflows/CompareScreenshotComment.yml index 1ba4efdee..f303dfba3 100644 --- a/.github/workflows/CompareScreenshotComment.yml +++ b/.github/workflows/CompareScreenshotComment.yml @@ -61,7 +61,7 @@ jobs: shell: bash run: | # Find all the files ending with _compare.png - mapfile -t files_to_add < <(find . -type f -name "*_compare.png") + mapfile -t files_to_add < <(find . -type f -name "*_compare.*") # Check for invalid file names and add only valid ones exist_valid_files="false" @@ -79,7 +79,7 @@ jobs: BRANCH_NAME: companion_${{ github.event.workflow_run.head_branch }} run: | # Find all the files ending with _compare.png - files_to_add=$(find . -type f -name "*_compare.png") + files_to_add=$(find . -type f -name "*_compare.*") # Check for invalid file names and add only valid ones for file in $files_to_add; do @@ -99,7 +99,7 @@ jobs: shell: bash run: | # Find all the files ending with _compare.png in roborazzi folder - files=$(find . -type f -name "*_compare.png" | grep "roborazzi/" | grep -E "^[a-zA-Z0-9_./-]+$") + files=$(find . -type f -name "*_compare.*" | grep "roborazzi/" | grep -E "^[a-zA-Z0-9_./-]+$") delimiter="$(openssl rand -hex 8)" { echo "reports<<${delimiter}" diff --git a/README.md b/README.md index d5f2457d7..24cb19c59 100644 --- a/README.md +++ b/README.md @@ -692,7 +692,7 @@ fun captureRoboGifSample() { -### Automatically generate gif with test rule +### Generate gif with test rule > **Note** > You **don't need to use RoborazziRule** if you're using captureRoboImage(). @@ -732,7 +732,7 @@ class RuleTestWithOnlyFail { } ``` -### Automatically generate Jetpack Compose gif with test rule +### Generate Jetpack Compose gif with test rule Test target @@ -869,94 +869,6 @@ class RoborazziRule private constructor( } ``` -### Roborazzi options - -```kotlin -data class RoborazziOptions( - val captureType: CaptureType = if (isNativeGraphicsEnabled()) CaptureType.Screenshot() else CaptureType.Dump(), - val compareOptions: CompareOptions = CompareOptions(), - val recordOptions: RecordOptions = RecordOptions(), -) { - sealed interface CaptureType { - class Screenshot : CaptureType - - data class Dump( - val takeScreenShot: Boolean = isNativeGraphicsEnabled(), - val basicSize: Int = 600, - val depthSlideSize: Int = 30, - val query: ((RoboComponent) -> Boolean)? = null, - val explanation: ((RoboComponent) -> String?) = DefaultExplanation, - ) : CaptureType { - companion object { - val DefaultExplanation: ((RoboComponent) -> String) = { - it.text - } - val AccessibilityExplanation: ((RoboComponent) -> String) = { - it.accessibilityText - } - } - } - } - - data class CompareOptions( - val roborazziCompareReporter: RoborazziCompareReporter = RoborazziCompareReporter(), - val resultValidator: (result: ImageComparator.ComparisonResult) -> Boolean, - ) { - constructor( - roborazziCompareReporter: RoborazziCompareReporter = RoborazziCompareReporter(), - /** - * This value determines the threshold of pixel change at which the diff image is output or not. - * The value should be between 0 and 1 - */ - changeThreshold: Float = 0.01F, - ) : this(roborazziCompareReporter, ThresholdValidator(changeThreshold)) - } - - interface RoborazziCompareReporter { - fun report(compareReportCaptureResult: CompareReportCaptureResult) - - companion object { - operator fun invoke(): RoborazziCompareReporter { - ... - } - } - - class JsonOutputRoborazziCompareReporter : RoborazziCompareReporter { - ... - - override fun report(compareReportCaptureResult: CompareReportCaptureResult) { - ... - } - } - - class VerifyRoborazziCompareReporter : RoborazziCompareReporter { - override fun report(compareReportCaptureResult: CompareReportCaptureResult) { - ... - } - } - } - - data class RecordOptions( - val resizeScale: Double = roborazziDefaultResizeScale(), - val applyDeviceCrop: Boolean = false, - val pixelBitConfig: PixelBitConfig = PixelBitConfig.Argb8888, - ) - - enum class PixelBitConfig { - Argb8888, - Rgb565; - - fun toBitmapConfig(): Bitmap.Config { - ... - } - - fun toBufferedImageType(): Int { - ... - } - } -} -``` - #### Image comparator custom settings When comparing images, you may encounter differences due to minor changes related to antialiasing. You can use the options below to avoid this. ```kotlin @@ -977,12 +889,48 @@ val roborazziRule = RoborazziRule( ) ``` +### Experimental WebP support and other image formats + +You can set `roborazzi.record.image.extension` to `webp` in your `gradle.properties` file to generate WebP images. + +```kotlin +roborazzi.record.image.extension=webp +``` + +WebP is a lossy image format by default, which can make managing image differences challenging. To address this, we provide a lossless WebP image comparison feature. +To enable WebP support, add `testImplementation("io.github.darkxanter:webp-imageio:0.3.3")` to your `build.gradle.kts` file. + +```kotlin +onView(ViewMatchers.withId(R.id.textview_first)) + .captureRoboImage( + roborazziOptions = RoborazziOptions( + recordOptions = RoborazziOptions.RecordOptions( + imageIoFormat = LosslessWebPImageIoFormat(), + ), + ) + ) +``` + +You can also use other image formats by implementing your own `AwtImageWriter` and `AwtImageLoader`. + +```kotlin +data class JvmImageIoFormat( + val awtImageWriter: AwtImageWriter, + val awtImageLoader: AwtImageLoader +) : ImageIoFormat + +``` + ### Dump mode If you are having trouble debugging your test, try Dump mode as follows. ![image](https://user-images.githubusercontent.com/1386930/226364158-a07a0fb0-d8e7-46b7-a495-8dd217faaadb.png) +### Roborazzi options + +Please check out [RoborazziOptions](https://github.com/takahirom/roborazzi/blob/main/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/RoborazziOptions.kt) for available Roborazzi options. +
diff --git a/docs/topics/how_to_use.md b/docs/topics/how_to_use.md index a0a6c1c41..e95be494a 100644 --- a/docs/topics/how_to_use.md +++ b/docs/topics/how_to_use.md @@ -369,7 +369,7 @@ fun captureRoboGifSample() { -### Automatically generate gif with test rule +### Generate gif with test rule > **Note** > You **don't need to use RoborazziRule** if you're using captureRoboImage(). @@ -409,7 +409,7 @@ class RuleTestWithOnlyFail { } ``` -### Automatically generate Jetpack Compose gif with test rule +### Generate Jetpack Compose gif with test rule Test target @@ -546,94 +546,6 @@ class RoborazziRule private constructor( } ``` -### Roborazzi options - -```kotlin -data class RoborazziOptions( - val captureType: CaptureType = if (isNativeGraphicsEnabled()) CaptureType.Screenshot() else CaptureType.Dump(), - val compareOptions: CompareOptions = CompareOptions(), - val recordOptions: RecordOptions = RecordOptions(), -) { - sealed interface CaptureType { - class Screenshot : CaptureType - - data class Dump( - val takeScreenShot: Boolean = isNativeGraphicsEnabled(), - val basicSize: Int = 600, - val depthSlideSize: Int = 30, - val query: ((RoboComponent) -> Boolean)? = null, - val explanation: ((RoboComponent) -> String?) = DefaultExplanation, - ) : CaptureType { - companion object { - val DefaultExplanation: ((RoboComponent) -> String) = { - it.text - } - val AccessibilityExplanation: ((RoboComponent) -> String) = { - it.accessibilityText - } - } - } - } - - data class CompareOptions( - val roborazziCompareReporter: RoborazziCompareReporter = RoborazziCompareReporter(), - val resultValidator: (result: ImageComparator.ComparisonResult) -> Boolean, - ) { - constructor( - roborazziCompareReporter: RoborazziCompareReporter = RoborazziCompareReporter(), - /** - * This value determines the threshold of pixel change at which the diff image is output or not. - * The value should be between 0 and 1 - */ - changeThreshold: Float = 0.01F, - ) : this(roborazziCompareReporter, ThresholdValidator(changeThreshold)) - } - - interface RoborazziCompareReporter { - fun report(compareReportCaptureResult: CompareReportCaptureResult) - - companion object { - operator fun invoke(): RoborazziCompareReporter { - ... - } - } - - class JsonOutputRoborazziCompareReporter : RoborazziCompareReporter { - ... - - override fun report(compareReportCaptureResult: CompareReportCaptureResult) { - ... - } - } - - class VerifyRoborazziCompareReporter : RoborazziCompareReporter { - override fun report(compareReportCaptureResult: CompareReportCaptureResult) { - ... - } - } - } - - data class RecordOptions( - val resizeScale: Double = roborazziDefaultResizeScale(), - val applyDeviceCrop: Boolean = false, - val pixelBitConfig: PixelBitConfig = PixelBitConfig.Argb8888, - ) - - enum class PixelBitConfig { - Argb8888, - Rgb565; - - fun toBitmapConfig(): Bitmap.Config { - ... - } - - fun toBufferedImageType(): Int { - ... - } - } -} -``` - #### Image comparator custom settings When comparing images, you may encounter differences due to minor changes related to antialiasing. You can use the options below to avoid this. ```kotlin @@ -654,8 +566,44 @@ val roborazziRule = RoborazziRule( ) ``` +### Experimental WebP support and other image formats + +You can set `roborazzi.record.image.extension` to `webp` in your `gradle.properties` file to generate WebP images. + +```kotlin +roborazzi.record.image.extension=webp +``` + +WebP is a lossy image format by default, which can make managing image differences challenging. To address this, we provide a lossless WebP image comparison feature. +To enable WebP support, add `testImplementation("io.github.darkxanter:webp-imageio:0.3.3")` to your `build.gradle.kts` file. + +```kotlin +onView(ViewMatchers.withId(R.id.textview_first)) + .captureRoboImage( + roborazziOptions = RoborazziOptions( + recordOptions = RoborazziOptions.RecordOptions( + imageIoFormat = LosslessWebPImageIoFormat(), + ), + ) + ) +``` + +You can also use other image formats by implementing your own `AwtImageWriter` and `AwtImageLoader`. + +```kotlin +data class JvmImageIoFormat( + val awtImageWriter: AwtImageWriter, + val awtImageLoader: AwtImageLoader +) : ImageIoFormat + +``` + ### Dump mode If you are having trouble debugging your test, try Dump mode as follows. ![image](https://user-images.githubusercontent.com/1386930/226364158-a07a0fb0-d8e7-46b7-a495-8dd217faaadb.png) + +### Roborazzi options + +Please check out [RoborazziOptions](https://github.com/takahirom/roborazzi/blob/main/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/RoborazziOptions.kt) for available Roborazzi options. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2b207971a..2075d4120 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,6 +44,7 @@ kotlinx-io = "0.3.3" webjar-material-design-icons = "4.0.0" webjar-materialize = "1.0.0" webjars-locator-lite = "0.0.6" +webpImageio = "0.3.3" composable-preview-scanner = "0.4.0" @@ -110,3 +111,4 @@ kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.re webjars-material-design-icons = { module = "org.webjars:material-design-icons", version.ref = "webjar-material-design-icons" } webjars-materialize = { module = "org.webjars:materializecss", version.ref = "webjar-materialize" } webjars-locator-lite = { module = "org.webjars:webjars-locator-lite", version.ref = "webjars-locator-lite" } +webp-imageio = { module = "io.github.darkxanter:webp-imageio", version.ref = "webpImageio" } diff --git a/include-build/roborazzi-core/build.gradle b/include-build/roborazzi-core/build.gradle index 726eb4f03..165fa51ac 100644 --- a/include-build/roborazzi-core/build.gradle +++ b/include-build/roborazzi-core/build.gradle @@ -53,6 +53,9 @@ kotlin { commonJvmMain { dependencies { implementation libs.junit + // The library is a little bit heavy, so we use compileOnly here. + // Users need to add this library to their dependencies. + compileOnly(libs.webp.imageio) } } commonJvmTest { diff --git a/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/DefaultFileNameGenerator.kt b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/DefaultFileNameGenerator.kt index 0a68fb3cb..b258376c7 100644 --- a/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/DefaultFileNameGenerator.kt +++ b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/DefaultFileNameGenerator.kt @@ -37,7 +37,7 @@ object DefaultFileNameGenerator { } @InternalRoborazziApi - fun generateFilePath(extension: String): String { + fun generateFilePath(extension: String = provideRoborazziContext().imageExtension): String { val roborazziContext = provideRoborazziContext() val fileCreator = roborazziContext.fileProvider val description = roborazziContext.description diff --git a/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/ImageIoFormat.commonJvm.kt b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/ImageIoFormat.commonJvm.kt new file mode 100644 index 000000000..b2766d9b1 --- /dev/null +++ b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/ImageIoFormat.commonJvm.kt @@ -0,0 +1,123 @@ +package com.github.takahirom.roborazzi + +import com.luciad.imageio.webp.WebPWriteParam +import java.awt.image.BufferedImage +import java.awt.image.RenderedImage +import java.io.File +import javax.imageio.IIOImage +import javax.imageio.ImageIO +import javax.imageio.ImageTypeSpecifier +import javax.imageio.ImageWriteParam +import javax.imageio.ImageWriter +import javax.imageio.metadata.IIOMetadata +import javax.imageio.metadata.IIOMetadataFormatImpl +import javax.imageio.metadata.IIOMetadataNode +import javax.imageio.stream.FileImageOutputStream + +@ExperimentalRoborazziApi +@Suppress("FunctionName") +actual fun LosslessWebPImageIoFormat(): ImageIoFormat { + return JvmImageIoFormat( + awtImageWriter = losslessWebPWriter() + ) +} + +@ExperimentalRoborazziApi +actual fun ImageIoFormat(): ImageIoFormat { + return JvmImageIoFormat() +} + +@ExperimentalRoborazziApi +fun interface AwtImageWriter { + fun write( + destFile: File, + contextData: Map, + image: BufferedImage + ) +} + +@ExperimentalRoborazziApi +fun interface AwtImageLoader { + fun load(inputFile: File): BufferedImage +} + +@ExperimentalRoborazziApi +data class JvmImageIoFormat( + val awtImageWriter: AwtImageWriter = AwtImageWriter { file, contextData, bufferedImage -> + val imageExtension = file.extension.ifBlank { "png" } + if (contextData.isEmpty()) { + ImageIO.write( + bufferedImage, + imageExtension, + file + ) + return@AwtImageWriter + } + val writer = getWriter(bufferedImage, imageExtension) + val meta = writer.writeMetadata(contextData, bufferedImage) + writer.output = ImageIO.createImageOutputStream(file) + writer.write(IIOImage(bufferedImage, null, meta)) + }, + val awtImageLoader: AwtImageLoader = AwtImageLoader { ImageIO.read(it) } +) : ImageIoFormat + + +@ExperimentalRoborazziApi +fun getWriter(renderedImage: RenderedImage, extension: String): ImageWriter { + val typeSpecifier = ImageTypeSpecifier.createFromRenderedImage(renderedImage) + val iterator: Iterator<*> = ImageIO.getImageWriters(typeSpecifier, extension) + return if (iterator.hasNext()) { + iterator.next() as ImageWriter + } else { + throw IllegalArgumentException("No ImageWriter found for $extension") + } +} + +@ExperimentalRoborazziApi +fun ImageWriter.writeMetadata( + contextData: Map, + bufferedImage: BufferedImage, +): IIOMetadata? { + val meta = getDefaultImageMetadata(ImageTypeSpecifier(bufferedImage), null) ?: run { + // If we use WebP, it seems that we can't get the metadata + return null + } + + val root = IIOMetadataNode(IIOMetadataFormatImpl.standardMetadataFormatName) + contextData.forEach { (key, value) -> + val textEntry = IIOMetadataNode("TextEntry") + textEntry.setAttribute("keyword", key) + textEntry.setAttribute("value", value.toString()) + val text = IIOMetadataNode("Text") + text.appendChild(textEntry) + root.appendChild(text) + } + + meta.mergeTree(IIOMetadataFormatImpl.standardMetadataFormatName, root) + return meta +} + +/** + * Add testImplementation("io.github.darkxanter:webp-imageio:0.3.3") to use this + */ +private fun losslessWebPWriter(): AwtImageWriter = + AwtImageWriter { file, context, bufferedImage -> + val writer: ImageWriter = + ImageIO.getImageWritersByMIMEType("image/webp").next() + try { + val writeParam = WebPWriteParam(writer.getLocale()) + writeParam.compressionMode = ImageWriteParam.MODE_EXPLICIT + writeParam.compressionType = + writeParam.getCompressionTypes() + .get(WebPWriteParam.LOSSLESS_COMPRESSION) + + + writer.setOutput(FileImageOutputStream(file)) + + writer.write(null, IIOImage(bufferedImage, null, null), writeParam) + } catch (e: NoClassDefFoundError) { + throw IllegalStateException("Add testImplementation(\"io.github.darkxanter:webp-imageio:0.3.0\") to use this") + } finally { + writer.dispose() + } + } \ No newline at end of file diff --git a/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/RoborazziContext.kt b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/RoborazziContext.kt index d1e710b97..e0fa87c36 100644 --- a/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/RoborazziContext.kt +++ b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/RoborazziContext.kt @@ -10,6 +10,7 @@ class RoborazziContextImpl { private var ruleOverrideFileProvider: FileProvider? = null private var ruleOverrideDescription: Description? = null + private var ruleOverrideImageExtension: String? = null @InternalRoborazziApi fun setRuleOverrideOutputDirectory(outputDirectory: String) { @@ -51,6 +52,21 @@ class RoborazziContextImpl { ruleOverrideDescription = null } + // TODO provide this in Rule + @InternalRoborazziApi + fun setImageExtension(extension: String) { + ruleOverrideImageExtension = extension + } + + @InternalRoborazziApi + fun clearImageExtension() { + ruleOverrideImageExtension = null + } + + @InternalRoborazziApi + val imageExtension: String + get() = ruleOverrideImageExtension ?: roborazziSystemPropertyImageExtension() + @InternalRoborazziApi val outputDirectory: String get() = ruleOverrideOutputDirectory ?: roborazziSystemPropertyOutputDirectory() diff --git a/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/RoborazziOptions.kt b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/RoborazziOptions.kt index 9d3106bcf..8c24bae78 100644 --- a/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/RoborazziOptions.kt +++ b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/RoborazziOptions.kt @@ -235,6 +235,7 @@ data class RoborazziOptions( val resizeScale: Double = roborazziDefaultResizeScale(), val applyDeviceCrop: Boolean = false, val pixelBitConfig: PixelBitConfig = PixelBitConfig.Argb8888, + val imageIoFormat: ImageIoFormat = ImageIoFormat(), ) enum class PixelBitConfig { diff --git a/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/processOutputImageAndReport.kt b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/processOutputImageAndReport.kt index 61a3d66cc..d0b3cd613 100644 --- a/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/processOutputImageAndReport.kt +++ b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/processOutputImageAndReport.kt @@ -120,7 +120,8 @@ fun processOutputImageAndReport( .save( path = comparisonFile.absolutePath, resizeScale = resizeScale, - contextData = contextData + contextData = contextData, + imageIoFormat = recordOptions.imageIoFormat, ) debugLog { "processOutputImageAndReport(): compareCanvas is saved " + @@ -141,7 +142,8 @@ fun processOutputImageAndReport( .save( path = actualFile.absolutePath, resizeScale = resizeScale, - contextData = contextData + contextData = contextData, + imageIoFormat = recordOptions.imageIoFormat, ) val aiOptions = compareOptions.aiAssertionOptions val aiResult = if (aiOptions != null && aiOptions.aiAssertions.isNotEmpty()) { @@ -200,7 +202,8 @@ fun processOutputImageAndReport( newRoboCanvas.save( path = goldenFile.absolutePath, resizeScale = resizeScale, - contextData = contextData + contextData = contextData, + imageIoFormat = recordOptions.imageIoFormat, ) debugLog { "processOutputImageAndReport: \n" + diff --git a/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/ImageIoFormat.kt b/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/ImageIoFormat.kt new file mode 100644 index 000000000..e13af6c23 --- /dev/null +++ b/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/ImageIoFormat.kt @@ -0,0 +1,11 @@ +package com.github.takahirom.roborazzi + +@ExperimentalRoborazziApi +interface ImageIoFormat + +@ExperimentalRoborazziApi +@Suppress("FunctionName") +expect fun LosslessWebPImageIoFormat() : ImageIoFormat + +@ExperimentalRoborazziApi +expect fun ImageIoFormat() : ImageIoFormat \ No newline at end of file diff --git a/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/RoboCanvas.kt b/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/RoboCanvas.kt index f24027be8..275a3c6de 100644 --- a/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/RoboCanvas.kt +++ b/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/RoboCanvas.kt @@ -11,9 +11,9 @@ interface RoboCanvas { path: String, resizeScale: Double, contextData: Map, + imageIoFormat: ImageIoFormat, ) fun differ(other: RoboCanvas, resizeScale: Double, imageComparator: ImageComparator): ImageComparator.ComparisonResult fun release() - } diff --git a/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/RoborazziProperties.kt b/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/RoborazziProperties.kt index d44905030..b3874fb36 100644 --- a/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/RoborazziProperties.kt +++ b/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/RoborazziProperties.kt @@ -8,6 +8,11 @@ fun roborazziSystemPropertyOutputDirectory(): String { return getSystemProperty("roborazzi.output.dir", DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH) } +@ExperimentalRoborazziApi +fun roborazziSystemPropertyImageExtension(): String { + return getSystemProperty("roborazzi.record.image.extension", "png") +} + @ExperimentalRoborazziApi fun roborazziSystemPropertyResultDirectory(): String { return getSystemProperty("roborazzi.result.dir", diff --git a/include-build/roborazzi-core/src/iosMain/kotlin/com/github/takahirom/roborazzi/ImageIoFormat.ios.kt b/include-build/roborazzi-core/src/iosMain/kotlin/com/github/takahirom/roborazzi/ImageIoFormat.ios.kt new file mode 100644 index 000000000..fcd0d53d1 --- /dev/null +++ b/include-build/roborazzi-core/src/iosMain/kotlin/com/github/takahirom/roborazzi/ImageIoFormat.ios.kt @@ -0,0 +1,10 @@ +package com.github.takahirom.roborazzi + +@Suppress("FunctionName") +actual fun LosslessWebPImageIoFormat(): ImageIoFormat { + TODO("NOT IMPLEMENTED YET") +} + +actual fun ImageIoFormat(): ImageIoFormat { + TODO("NOT IMPLEMENTED YET") +} \ No newline at end of file diff --git a/roborazzi-compose-desktop/src/commonMain/kotlin/io/github/takahirom/roborazzi/RoborazziDesktop.kt b/roborazzi-compose-desktop/src/commonMain/kotlin/io/github/takahirom/roborazzi/RoborazziDesktop.kt index a301cfa21..bcf0ea342 100644 --- a/roborazzi-compose-desktop/src/commonMain/kotlin/io/github/takahirom/roborazzi/RoborazziDesktop.kt +++ b/roborazzi-compose-desktop/src/commonMain/kotlin/io/github/takahirom/roborazzi/RoborazziDesktop.kt @@ -17,7 +17,7 @@ import java.awt.image.BufferedImage import java.io.File fun SemanticsNodeInteraction.captureRoboImage( - filePath: String = DefaultFileNameGenerator.generateFilePath("png"), + filePath: String = DefaultFileNameGenerator.generateFilePath(), roborazziOptions: RoborazziOptions = provideRoborazziContext().options, ) { if (!roborazziOptions.taskType.isEnabled()) { @@ -57,7 +57,7 @@ fun SemanticsNodeInteraction.captureRoboImage( } fun ImageBitmap.captureRoboImage( - filePath: String = DefaultFileNameGenerator.generateFilePath("png"), + filePath: String = DefaultFileNameGenerator.generateFilePath(), roborazziOptions: RoborazziOptions = provideRoborazziContext().options, ) { if (!roborazziOptions.taskType.isEnabled()) { @@ -114,7 +114,11 @@ fun processOutputImageAndReportWithDefaults( ) }, canvasFactoryFromFile = { file, bufferedImageType -> - AwtRoboCanvas.load(file, bufferedImageType) + AwtRoboCanvas.load( + file = file, + bufferedImageType = bufferedImageType, + imageIoFormat = roborazziOptions.recordOptions.imageIoFormat + ) }, comparisonCanvasFactory = { goldenCanvas, actualCanvas, resizeScale, bufferedImageType -> AwtRoboCanvas.generateCompareCanvas( diff --git a/roborazzi-compose-preview-scanner-support/src/main/java/com/github/takahirom/roborazzi/RoborazziPreviewScannerSupport.kt b/roborazzi-compose-preview-scanner-support/src/main/java/com/github/takahirom/roborazzi/RoborazziPreviewScannerSupport.kt index 7cdf7cc0a..14ed93a80 100644 --- a/roborazzi-compose-preview-scanner-support/src/main/java/com/github/takahirom/roborazzi/RoborazziPreviewScannerSupport.kt +++ b/roborazzi-compose-preview-scanner-support/src/main/java/com/github/takahirom/roborazzi/RoborazziPreviewScannerSupport.kt @@ -9,7 +9,7 @@ import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview @ExperimentalRoborazziApi fun ComposablePreview.captureRoboImage( - filePath: String = DefaultFileNameGenerator.generateFilePath("png"), + filePath: String = DefaultFileNameGenerator.generateFilePath(), roborazziOptions: RoborazziOptions = RoborazziOptions(), ) { val composablePreview = this diff --git a/roborazzi-compose/src/main/java/com/github/takahirom/roborazzi/RoborazziCompose.kt b/roborazzi-compose/src/main/java/com/github/takahirom/roborazzi/RoborazziCompose.kt index 0b51d9800..7c024de03 100644 --- a/roborazzi-compose/src/main/java/com/github/takahirom/roborazzi/RoborazziCompose.kt +++ b/roborazzi-compose/src/main/java/com/github/takahirom/roborazzi/RoborazziCompose.kt @@ -13,7 +13,7 @@ import org.robolectric.Shadows import java.io.File fun captureRoboImage( - filePath: String = DefaultFileNameGenerator.generateFilePath("png"), + filePath: String = DefaultFileNameGenerator.generateFilePath(), roborazziOptions: RoborazziOptions = provideRoborazziContext().options, content: @Composable () -> Unit, ) { diff --git a/roborazzi-junit-rule/src/main/java/com/github/takahirom/roborazzi/RoborazziRule.kt b/roborazzi-junit-rule/src/main/java/com/github/takahirom/roborazzi/RoborazziRule.kt index 62dddcb7a..521819723 100644 --- a/roborazzi-junit-rule/src/main/java/com/github/takahirom/roborazzi/RoborazziRule.kt +++ b/roborazzi-junit-rule/src/main/java/com/github/takahirom/roborazzi/RoborazziRule.kt @@ -211,7 +211,7 @@ class RoborazziRule private constructor( if (!isOnlyFail || result.result.isFailure) { if (captureType is CaptureType.AllImage) { result.saveAllImage { - fileWithRecordFilePathStrategy(DefaultFileNameGenerator.generateFilePath("png")) + fileWithRecordFilePathStrategy(DefaultFileNameGenerator.generateFilePath()) } } else { val file = @@ -232,7 +232,7 @@ class RoborazziRule private constructor( } if (!captureType.onlyFail || result.isFailure) { val outputFile = - fileWithRecordFilePathStrategy(DefaultFileNameGenerator.generateFilePath("png")) + fileWithRecordFilePathStrategy(DefaultFileNameGenerator.generateFilePath()) when (captureRoot) { is CaptureRoot.Compose -> captureRoot.semanticsNodeInteraction.captureRoboImage( file = outputFile, diff --git a/roborazzi-painter/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/AwtRoboCanvas.kt b/roborazzi-painter/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/AwtRoboCanvas.kt index 47cea63e6..05903cf08 100644 --- a/roborazzi-painter/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/AwtRoboCanvas.kt +++ b/roborazzi-painter/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/AwtRoboCanvas.kt @@ -18,15 +18,7 @@ import java.awt.font.TextLayout import java.awt.geom.AffineTransform import java.awt.image.AffineTransformOp import java.awt.image.BufferedImage -import java.awt.image.RenderedImage import java.io.File -import javax.imageio.IIOImage -import javax.imageio.ImageIO -import javax.imageio.ImageTypeSpecifier -import javax.imageio.ImageWriter -import javax.imageio.metadata.IIOMetadata -import javax.imageio.metadata.IIOMetadataFormatImpl -import javax.imageio.metadata.IIOMetadataNode class AwtRoboCanvas(width: Int, height: Int, filled: Boolean, bufferedImageType: Int) : RoboCanvas { private val bufferedImage = BufferedImage(width, height, bufferedImageType) @@ -219,7 +211,12 @@ class AwtRoboCanvas(width: Int, height: Int, filled: Boolean, bufferedImageType: pendingDrawList.add(pendingDraw) } - override fun save(path: String, resizeScale: Double, contextData: Map) { + override fun save( + path: String, + resizeScale: Double, + contextData: Map, + imageIoFormat: ImageIoFormat, + ) { val file = File(path) drawPendingDraw() val directory = file.parentFile @@ -231,47 +228,12 @@ class AwtRoboCanvas(width: Int, height: Int, filled: Boolean, bufferedImageType: // ignore } val scaledBufferedImage = croppedImage.scale(resizeScale) - if (contextData.isEmpty()) { - ImageIO.write( - scaledBufferedImage, - "png", - file + (imageIoFormat as JvmImageIoFormat) + .awtImageWriter.write( + destFile = file, + contextData = contextData, + image = scaledBufferedImage ) - return - } - val writer = getWriter(croppedImage, "png") - val meta = writer.writeMetadata(contextData) - writer.output = ImageIO.createImageOutputStream(file) - writer.write(IIOImage(scaledBufferedImage, null, meta)) - } - - private fun ImageWriter.writeMetadata( - contextData: Map - ): IIOMetadata? { - val meta = getDefaultImageMetadata(ImageTypeSpecifier(croppedImage), null) - - val root = IIOMetadataNode(IIOMetadataFormatImpl.standardMetadataFormatName) - contextData.forEach { (key, value) -> - val textEntry = IIOMetadataNode("TextEntry") - textEntry.setAttribute("keyword", key) - textEntry.setAttribute("value", value.toString()) - val text = IIOMetadataNode("Text") - text.appendChild(textEntry) - root.appendChild(text) - } - - meta.mergeTree(IIOMetadataFormatImpl.standardMetadataFormatName, root) - return meta - } - - private fun getWriter(renderedImage: RenderedImage, extension: String): ImageWriter { - val typeSpecifier = ImageTypeSpecifier.createFromRenderedImage(renderedImage) - val var3: Iterator<*> = ImageIO.getImageWriters(typeSpecifier, extension) - return if (var3.hasNext()) { - var3.next() as ImageWriter - } else { - throw IllegalArgumentException("No ImageWriter found for $extension") - } } override fun differ( @@ -308,8 +270,8 @@ class AwtRoboCanvas(width: Int, height: Int, filled: Boolean, bufferedImageType: } companion object { - fun load(file: File, bufferedImageType: Int): AwtRoboCanvas { - val loadedImage: BufferedImage = ImageIO.read(file) + fun load(file: File, bufferedImageType: Int, imageIoFormat: ImageIoFormat): AwtRoboCanvas { + val loadedImage: BufferedImage = (imageIoFormat as JvmImageIoFormat).awtImageLoader.load(file) val awtRoboCanvas = AwtRoboCanvas( loadedImage.width, height = loadedImage.height, @@ -595,3 +557,4 @@ private fun BufferedImage.graphics(block: (Graphics2D) -> T): T { graphics.dispose() return result } + diff --git a/roborazzi/build.gradle b/roborazzi/build.gradle index 1c1ef567f..416d73790 100644 --- a/roborazzi/build.gradle +++ b/roborazzi/build.gradle @@ -22,7 +22,6 @@ dependencies { compileOnly libs.androidx.compose.ui.test.junit4 compileOnly libs.robolectric - implementation libs.androidx.core.ktx api libs.dropbox.differ api libs.androidx.test.espresso.core diff --git a/roborazzi/src/main/java/com/github/takahirom/roborazzi/Roborazzi.kt b/roborazzi/src/main/java/com/github/takahirom/roborazzi/Roborazzi.kt index ad07d18ed..336b0c126 100644 --- a/roborazzi/src/main/java/com/github/takahirom/roborazzi/Roborazzi.kt +++ b/roborazzi/src/main/java/com/github/takahirom/roborazzi/Roborazzi.kt @@ -19,9 +19,14 @@ import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.core.view.drawToBitmap import androidx.test.core.app.ApplicationProvider -import androidx.test.espresso.* +import androidx.test.espresso.Espresso import androidx.test.espresso.Espresso.onIdle import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.NoActivityResumedException +import androidx.test.espresso.Root +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.ViewInteraction import androidx.test.espresso.base.RootsOracle_Factory import androidx.test.platform.app.InstrumentationRegistry import com.dropbox.differ.ImageComparator @@ -29,11 +34,11 @@ import org.hamcrest.Matcher import org.hamcrest.Matchers import org.hamcrest.core.IsEqual import java.io.File -import java.util.* +import java.util.Locale fun ViewInteraction.captureRoboImage( - filePath: String = DefaultFileNameGenerator.generateFilePath("png"), + filePath: String = DefaultFileNameGenerator.generateFilePath(), roborazziOptions: RoborazziOptions = provideRoborazziContext().options, ) { if (!roborazziOptions.taskType.isEnabled()) return @@ -59,7 +64,7 @@ fun ViewInteraction.captureRoboImage( } fun View.captureRoboImage( - filePath: String = DefaultFileNameGenerator.generateFilePath("png"), + filePath: String = DefaultFileNameGenerator.generateFilePath(), roborazziOptions: RoborazziOptions = provideRoborazziContext().options, ) { if (!roborazziOptions.taskType.isEnabled()) return @@ -116,7 +121,7 @@ fun View.captureRoboImage( */ @ExperimentalRoborazziApi fun captureScreenRoboImage( - filePath: String = DefaultFileNameGenerator.generateFilePath("png"), + filePath: String = DefaultFileNameGenerator.generateFilePath(), roborazziOptions: RoborazziOptions = provideRoborazziContext().options, ) { if (!roborazziOptions.taskType.isEnabled()) return @@ -165,7 +170,7 @@ fun captureScreenRoboImage( } fun Bitmap.captureRoboImage( - filePath: String = DefaultFileNameGenerator.generateFilePath("png"), + filePath: String = DefaultFileNameGenerator.generateFilePath(), roborazziOptions: RoborazziOptions = provideRoborazziContext().options, ) { if (!roborazziOptions.taskType.isEnabled()) return @@ -225,7 +230,7 @@ fun ViewInteraction.captureRoboGif( } fun ViewInteraction.captureRoboLastImage( - filePath: String = DefaultFileNameGenerator.generateFilePath("png"), + filePath: String = DefaultFileNameGenerator.generateFilePath(), roborazziOptions: RoborazziOptions = provideRoborazziContext().options, block: () -> Unit ) { @@ -261,7 +266,7 @@ fun ViewInteraction.captureRoboAllImage( } fun SemanticsNodeInteraction.captureRoboImage( - filePath: String = DefaultFileNameGenerator.generateFilePath("png"), + filePath: String = DefaultFileNameGenerator.generateFilePath(), roborazziOptions: RoborazziOptions = provideRoborazziContext().options, ) { if (!roborazziOptions.taskType.isEnabled()) return @@ -644,7 +649,7 @@ fun processOutputImageAndReportWithDefaults( ) }, canvasFactoryFromFile = { file, bufferedImageType -> - AwtRoboCanvas.load(file, bufferedImageType) + AwtRoboCanvas.load(file, bufferedImageType, roborazziOptions.recordOptions.imageIoFormat) }, comparisonCanvasFactory = { goldenCanvas, actualCanvas, resizeScale, bufferedImageType -> AwtRoboCanvas.generateCompareCanvas( diff --git a/sample-android/build.gradle b/sample-android/build.gradle index c480187ea..6a967196a 100644 --- a/sample-android/build.gradle +++ b/sample-android/build.gradle @@ -71,6 +71,7 @@ dependencies { testImplementation libs.androidx.compose.ui.test.junit4 debugImplementation libs.androidx.compose.ui.test.manifest implementation libs.androidx.activity.compose + testImplementation(libs.webp.imageio) implementation libs.androidx.core.ktx implementation libs.androidx.appcompat diff --git a/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/boxed/LosslessWebpTest.kt b/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/boxed/LosslessWebpTest.kt new file mode 100644 index 000000000..b87afce4d --- /dev/null +++ b/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/boxed/LosslessWebpTest.kt @@ -0,0 +1,121 @@ +package com.github.takahirom.roborazzi.sample.boxed + +import android.widget.TextView +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH +import com.github.takahirom.roborazzi.DefaultFileNameGenerator +import com.github.takahirom.roborazzi.ROBORAZZI_DEBUG +import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import com.github.takahirom.roborazzi.RoborazziOptions +import com.github.takahirom.roborazzi.RoborazziTaskType +import com.github.takahirom.roborazzi.LosslessWebPImageIoFormat +import com.github.takahirom.roborazzi.captureRoboImage +import com.github.takahirom.roborazzi.nameWithoutExtension +import com.github.takahirom.roborazzi.provideRoborazziContext +import com.github.takahirom.roborazzi.sample.MainActivity +import com.github.takahirom.roborazzi.sample.R +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File + + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config( + sdk = [30], + qualifiers = RobolectricDeviceQualifiers.NexusOne +) +class LosslessWebpTest { + @get:Rule + val composeTestRule = createAndroidComposeRule() + val recordOptions = RoborazziOptions.RecordOptions( + imageIoFormat = LosslessWebPImageIoFormat(), + ) + + @Test + fun whenCompareSameImageTheCompareImageShouldNotBeGenerated() { + boxedEnvironment { + ROBORAZZI_DEBUG = true + provideRoborazziContext().setImageExtension("webp") + val prefix = + DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH + "/" + DefaultFileNameGenerator.generateFilePath().nameWithoutExtension + val expectedOutput = + File("$prefix.webp") + val expectedCompareOutput = File("${prefix}_compare.webp") + expectedOutput.delete() + expectedCompareOutput.delete() + try { + + onView(ViewMatchers.withId(R.id.textview_first)) + .captureRoboImage( + filePath = expectedOutput.absolutePath, + roborazziOptions = RoborazziOptions( + taskType = RoborazziTaskType.Record, + recordOptions = recordOptions + ), + ) + DefaultFileNameGenerator.reset() + + onView(ViewMatchers.withId(R.id.textview_first)) + .captureRoboImage( + roborazziOptions = RoborazziOptions( + taskType = RoborazziTaskType.Compare, + recordOptions = recordOptions + ) + ) + assert(expectedOutput.exists()) + assert(!expectedCompareOutput.exists()) + } finally { + expectedCompareOutput.delete() + } + } + } + + + @Test + fun whenCompareDifferentImageTheCompareImageShouldBeGenerated() { + boxedEnvironment { + ROBORAZZI_DEBUG = true + provideRoborazziContext().setImageExtension("webp") + val prefix = + DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH + "/" + DefaultFileNameGenerator.generateFilePath().nameWithoutExtension + val expectedOutput = + File("$prefix.webp") + val expectedCompareOutput = File("${prefix}_compare.webp") + expectedOutput.delete() + expectedCompareOutput.delete() + try { + onView(ViewMatchers.withId(R.id.textview_first)) + .captureRoboImage( + filePath = expectedOutput.absolutePath, + roborazziOptions = RoborazziOptions( + taskType = RoborazziTaskType.Record, + recordOptions = recordOptions + ), + ) + composeTestRule.activity.findViewById(R.id.textview_first) + .text = "Hello, Roborazzi! This is a test for size change." + DefaultFileNameGenerator.reset() + + onView(ViewMatchers.withId(R.id.textview_first)) + .captureRoboImage( + filePath = expectedOutput.absolutePath, + roborazziOptions = RoborazziOptions( + taskType = RoborazziTaskType.Compare, + recordOptions = recordOptions + ) + ) + assert(expectedOutput.exists()) + assert(expectedCompareOutput.exists()) + } finally { + expectedCompareOutput.delete() + } + } + } +}