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