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/.github/workflows/dependency-diff.yaml b/.github/workflows/dependency-diff.yaml index 547ae440a..7782370d8 100644 --- a/.github/workflows/dependency-diff.yaml +++ b/.github/workflows/dependency-diff.yaml @@ -19,8 +19,17 @@ jobs: configuration: 'releaseRuntimeClasspath' modules: | roborazzi-compose-ios|commonMainImplementationDependenciesMetadata + roborazzi-compose-ios|iosArm64CompilationApi + roborazzi-compose-ios|iosArm64CompileKlibraries roborazzi-compose-desktop|commonMainImplementationDependenciesMetadata + roborazzi-ai-gemini|commonMainImplementationDependenciesMetadata + roborazzi-ai-gemini|androidReleaseRuntimeClasspath + roborazzi-ai-openai|commonMainImplementationDependenciesMetadata + roborazzi-ai-openai|androidReleaseRuntimeClasspath + roborazzi-painter|commonMainImplementationDependenciesMetadata + roborazzi-painter|jvmRuntimeClasspath roborazzi + roborazzi-junit-rule roborazzi-compose roborazzi-compose-preview-scanner-support @@ -33,7 +42,10 @@ jobs: uses: yumemi-inc/gradle-dependency-diff-report@v2 id: report-plugin with: - modules: 'roborazzi-gradle-plugin' + modules: | + roborazzi-core|commonMainImplementationDependenciesMetadata + roborazzi-core|androidReleaseRuntimeClasspath + roborazzi-gradle-plugin configuration: 'runtimeClasspath' project-dir: 'include-build' diff --git a/README.md b/README.md index 9f30048d4..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. +
@@ -1063,6 +1011,115 @@ Currently, we don't support all the annotation options provided by the Compose P You can check the supported annotations in the [source code](https://github.com/takahirom/roborazzi/blob/main/roborazzi-compose-preview-scanner-support/src/main/java/com/github/takahirom/roborazzi/RobolectricPreviewInfosApplier.kt). We are looking forward to your contributions to support more annotation options. +
+
+ + +# Experimental AI-Powered Image Assertion + +Roborazzi supports AI-powered image assertion. +AI-powered image assertion is an experimental feature. Screenshot tests are a great way to verify your app's UI, but verifying the content of the images can be a tedious and time-consuming task. This manual effort reduces scalability. Roborazzi can help automate this process through AI-powered image assertion, making it more efficient and scalable. + +There are two new library modules: `io.github.takahirom.roborazzi:roborazzi-ai-gemini` and `io.github.takahirom.roborazzi:roborazzi-ai-openai` for AI-powered image assertion. + +`roborazzi-ai-gemini` leverages [Gemini](https://gemini.google.com/) and [generative-ai-kmp](https://github.com/PatilShreyas/generative-ai-kmp), while `roborazzi-ai-openai` utilizes the [OpenAI API](https://platform.openai.com/) through raw HTTP API calls implemented with Ktor and KotlinX Serialization + +```kotlin +... +@get:Rule +val composeTestRule = createAndroidComposeRule() + +@get:Rule +val roborazziRule = RoborazziRule( + options = RoborazziRule.Options( + roborazziOptions = RoborazziOptions( + compareOptions = RoborazziOptions.CompareOptions( + aiAssertionOptions = AiAssertionOptions( + aiAssertionModel = GeminiAiAssertionModel( + // DO NOT HARDCODE your API key in your code. + // This is an example passing API Key through unitTests.all{ environment(key, value) } + apiKey = System.getenv("gemini_api_key") ?: "" + ), + ) + ) + ) + ) +) + +@Test +fun captureWithAi() { + ROBORAZZI_DEBUG = true + onView(ViewMatchers.isRoot()) + .captureRoboImage( + roborazziOptions = provideRoborazziContext().options.addedAiAssertions( + AiAssertionOptions.AiAssertion( + assertionPrompt = "it should have PREVIOUS button", + requiredFulfillmentPercent = 90, + ), + AiAssertionOptions.AiAssertion( + assertionPrompt = "it should show First Fragment", + requiredFulfillmentPercent = 90, + ) + ) + ) +} +``` + +## Behavior of AI-Powered Image Assertion + +AI-Powered Image Assertion runs only when the images are different. If the images are the same, AI-Powered Image Assertion is skipped. +This is because AI-Powered Image Assertion can be slow and expensive. + +## Manual Image Assertion + +You can use manual image assertion with Roborazzi. This allows you to utilize local LLMs or other LLMs. Manual Image Assertion doesn't require adding any dependencies other than Roborazzi itself. + +You must provide the `AiAssertionModel` to `RoborazziOptions` to use manual image assertion. + +```kotlin +interface AiAssertionModel { + fun assert( + referenceImageFilePath: String, + comparisonImageFilePath: String, + actualImageFilePath: String, + aiAssertionOptions: AiAssertionOptions + ): AiAssertionResults +} +``` + +```kotlin +compareOptions = RoborazziOptions.CompareOptions( + aiAssertionOptions = AiAssertionOptions( + aiAssertionModel = object : AiAssertionOptions.AiAssertionModel { + override fun assert( + comparisonImageFilePath: String, + aiAssertionOptions: AiAssertionOptions + ): AiAssertionResults { + // You can use any LLMs here to create AiAssertionResults + return AiAssertionResults( + aiAssertionResults = aiAssertionOptions.aiAssertions.map { assertion -> + AiAssertionResult( + assertionPrompt = assertion.assertionPrompt, + fulfillmentPercent = fulfillmentPercent, + requiredFulfillmentPercent = assertion.requiredFulfillmentPercent, + failIfNotFulfilled = assertion.failIfNotFulfilled, + explanation = "This is a manual test.", + ) + } + ) + } + }, + aiAssertions = listOf( + AiAssertionOptions.AiAssertion( + assertionPrompt = "it should have PREVIOUS button", + requiredFulfillmentPercent = 90, + ), + ), + ) +) + ... +``` +
@@ -1204,13 +1261,6 @@ kotlin { } } ... - -// Roborazzi Desktop support uses Context Receivers - tasks.withType().configureEach { - kotlinOptions { - freeCompilerArgs += "-Xcontext-receivers" - } - } ``` Test target Composable function @@ -1289,7 +1339,8 @@ The sample image # Roborazzi gradle.properties Options and Recommendations -You can configure the following options in your `gradle.properties` file: +You can configure the following options in your `gradle.properties` file. +You can also use `-P` to set the options in the command line. For example, `./gradlew test -Proborazzi.test.record=true`. ## roborazzi.test @@ -1332,6 +1383,15 @@ This option enables you to define the naming strategy for the recorded image. Th roborazzi.record.namingStrategy=testClassAndMethod ``` +## roborazzi.cleanupOldScreenshots + +This option allows you to clean up old screenshots. By default, this option is set to false. +The reason why Roborazzi does not delete old screenshots by default is that Roborazzi doesn't know if you are running filtered tests or not. If you are running filtered tests, Roborazzi will delete the screenshots that are not related to the current test run. + +``` +roborazzi.cleanupOldScreenshots=true +``` + ## Robolectric Options ### robolectric.pixelCopyRenderMode @@ -1482,6 +1542,11 @@ android { It is discussed in [this issue](https://github.com/takahirom/roborazzi/issues/272). Additionally, it might be worth trying to run your tests with VisualVM to monitor memory usage and identify potential leaks. +### Q: [IDEA Plugin] Roborazzi Gradle task is not displayed in Tool Window. + +**A:** It is discussed in [this issue](https://github.com/takahirom/roborazzi/issues/493). +To enable the display of Roborazzi tasks, please enable ***Configure all Gradle tasks during Gradle Sync (this can make Gradle Sync slower)*** in the Settings | Experimental | Gradle. +
### LICENSE diff --git a/README.template.md b/README.template.md index 84bbd4d99..a80ea1111 100644 --- a/README.template.md +++ b/README.template.md @@ -9,6 +9,8 @@
+
+
diff --git a/docs/roborazzi-docs.tree b/docs/roborazzi-docs.tree index 30e3e59b6..5c426f3cf 100644 --- a/docs/roborazzi-docs.tree +++ b/docs/roborazzi-docs.tree @@ -11,6 +11,7 @@ + diff --git a/docs/topics/ai_powered_image_assertion.md b/docs/topics/ai_powered_image_assertion.md new file mode 100644 index 000000000..87c62b6bb --- /dev/null +++ b/docs/topics/ai_powered_image_assertion.md @@ -0,0 +1,104 @@ +# Experimental AI-Powered Image Assertion + +Roborazzi supports AI-powered image assertion. +AI-powered image assertion is an experimental feature. Screenshot tests are a great way to verify your app's UI, but verifying the content of the images can be a tedious and time-consuming task. This manual effort reduces scalability. Roborazzi can help automate this process through AI-powered image assertion, making it more efficient and scalable. + +There are two new library modules: `io.github.takahirom.roborazzi:roborazzi-ai-gemini` and `io.github.takahirom.roborazzi:roborazzi-ai-openai` for AI-powered image assertion. + +`roborazzi-ai-gemini` leverages [Gemini](https://gemini.google.com/) and [generative-ai-kmp](https://github.com/PatilShreyas/generative-ai-kmp), while `roborazzi-ai-openai` utilizes the [OpenAI API](https://platform.openai.com/) through raw HTTP API calls implemented with Ktor and KotlinX Serialization + +```kotlin +... +@get:Rule +val composeTestRule = createAndroidComposeRule() + +@get:Rule +val roborazziRule = RoborazziRule( + options = RoborazziRule.Options( + roborazziOptions = RoborazziOptions( + compareOptions = RoborazziOptions.CompareOptions( + aiAssertionOptions = AiAssertionOptions( + aiAssertionModel = GeminiAiAssertionModel( + // DO NOT HARDCODE your API key in your code. + // This is an example passing API Key through unitTests.all{ environment(key, value) } + apiKey = System.getenv("gemini_api_key") ?: "" + ), + ) + ) + ) + ) +) + +@Test +fun captureWithAi() { + ROBORAZZI_DEBUG = true + onView(ViewMatchers.isRoot()) + .captureRoboImage( + roborazziOptions = provideRoborazziContext().options.addedAiAssertions( + AiAssertionOptions.AiAssertion( + assertionPrompt = "it should have PREVIOUS button", + requiredFulfillmentPercent = 90, + ), + AiAssertionOptions.AiAssertion( + assertionPrompt = "it should show First Fragment", + requiredFulfillmentPercent = 90, + ) + ) + ) +} +``` + +## Behavior of AI-Powered Image Assertion + +AI-Powered Image Assertion runs only when the images are different. If the images are the same, AI-Powered Image Assertion is skipped. +This is because AI-Powered Image Assertion can be slow and expensive. + +## Manual Image Assertion + +You can use manual image assertion with Roborazzi. This allows you to utilize local LLMs or other LLMs. Manual Image Assertion doesn't require adding any dependencies other than Roborazzi itself. + +You must provide the `AiAssertionModel` to `RoborazziOptions` to use manual image assertion. + +```kotlin +interface AiAssertionModel { + fun assert( + referenceImageFilePath: String, + comparisonImageFilePath: String, + actualImageFilePath: String, + aiAssertionOptions: AiAssertionOptions + ): AiAssertionResults +} +``` + +```kotlin +compareOptions = RoborazziOptions.CompareOptions( + aiAssertionOptions = AiAssertionOptions( + aiAssertionModel = object : AiAssertionOptions.AiAssertionModel { + override fun assert( + comparisonImageFilePath: String, + aiAssertionOptions: AiAssertionOptions + ): AiAssertionResults { + // You can use any LLMs here to create AiAssertionResults + return AiAssertionResults( + aiAssertionResults = aiAssertionOptions.aiAssertions.map { assertion -> + AiAssertionResult( + assertionPrompt = assertion.assertionPrompt, + fulfillmentPercent = fulfillmentPercent, + requiredFulfillmentPercent = assertion.requiredFulfillmentPercent, + failIfNotFulfilled = assertion.failIfNotFulfilled, + explanation = "This is a manual test.", + ) + } + ) + } + }, + aiAssertions = listOf( + AiAssertionOptions.AiAssertion( + assertionPrompt = "it should have PREVIOUS button", + requiredFulfillmentPercent = 90, + ), + ), + ) +) + ... +``` diff --git a/docs/topics/compose_multiplatform.md b/docs/topics/compose_multiplatform.md index 4f0dbfb8d..45e250af8 100644 --- a/docs/topics/compose_multiplatform.md +++ b/docs/topics/compose_multiplatform.md @@ -122,13 +122,6 @@ kotlin { } } ... - -// Roborazzi Desktop support uses Context Receivers - tasks.withType().configureEach { - kotlinOptions { - freeCompilerArgs += "-Xcontext-receivers" - } - } ``` Test target Composable function diff --git a/docs/topics/faq.md b/docs/topics/faq.md index f9434c080..414ec06f8 100644 --- a/docs/topics/faq.md +++ b/docs/topics/faq.md @@ -126,3 +126,9 @@ android { ``` It is discussed in [this issue](https://github.com/takahirom/roborazzi/issues/272). Additionally, it might be worth trying to run your tests with VisualVM to monitor memory usage and identify potential leaks. + +### Q: [IDEA Plugin] Roborazzi Gradle task is not displayed in Tool Window. + +**A:** It is discussed in [this issue](https://github.com/takahirom/roborazzi/issues/493). +To enable the display of Roborazzi tasks, please enable ***Configure all Gradle tasks during Gradle Sync (this can make Gradle Sync slower)*** in the Settings | Experimental | Gradle. + \ No newline at end of file diff --git a/docs/topics/gradle_properties_options.md b/docs/topics/gradle_properties_options.md index a209c9648..2146e2b73 100644 --- a/docs/topics/gradle_properties_options.md +++ b/docs/topics/gradle_properties_options.md @@ -1,6 +1,7 @@ # Roborazzi gradle.properties Options and Recommendations -You can configure the following options in your `gradle.properties` file: +You can configure the following options in your `gradle.properties` file. +You can also use `-P` to set the options in the command line. For example, `./gradlew test -Proborazzi.test.record=true`. ## roborazzi.test @@ -43,6 +44,15 @@ This option enables you to define the naming strategy for the recorded image. Th roborazzi.record.namingStrategy=testClassAndMethod ``` +## roborazzi.cleanupOldScreenshots + +This option allows you to clean up old screenshots. By default, this option is set to false. +The reason why Roborazzi does not delete old screenshots by default is that Roborazzi doesn't know if you are running filtered tests or not. If you are running filtered tests, Roborazzi will delete the screenshots that are not related to the current test run. + +``` +roborazzi.cleanupOldScreenshots=true +``` + ## Robolectric Options ### robolectric.pixelCopyRenderMode 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.properties b/gradle.properties index a8697e5b5..1f0af1fe9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -VERSION_NAME=1.29.0 +VERSION_NAME=1.32.2 GROUP=io.github.takahirom.roborazzi # Project-wide Gradle settings. @@ -60,3 +60,4 @@ kotlin.incremental.native=true # To debug roborazzi.test.record=true #roborazzi.test.verify=true +#roborazzi.test.compare=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8516544b4..866068e6e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,20 +7,21 @@ kotlin = "1.9.22" mavenPublish = "0.25.3" composeCompiler = "1.5.10" composeMultiplatform = "1.6.2" -robolectric = "4.12.2" +robolectric = "4.14" +generativeaiGoogle = "0.9.0-1.0.1" robolectric-android-all = "Q-robolectric-5415296" roborazzi-for-replacing-by-include-build = "1.0.0" androidx-activity = "1.7.2" androidx-appcompat = "1.7.0" -androidx-compose-material = "1.6.8" -androidx-compose-material3 = "1.3.0" +androidx-compose-material = "1.7.5" +androidx-compose-material3 = "1.3.1" androidx-compose-foundation = "1.6.8" androidx-compose-runtime = "1.6.8" androidx-compose-ui = "1.7.5" androidx-compose-ui-test = "1.6.8" -androidx-compose-ui-test-junit4 = "1.7.4" +androidx-compose-ui-test-junit4 = "1.7.5" androidx-compose-ui-test-manifest = "1.4.0" androidx-compose-ui-tooling = "1.4.0" androidx-compose-ui-tooling-preview = "1.4.0" @@ -35,15 +36,17 @@ kim = "0.17.7" dropbox-differ = "0.3.0" google-android-material = "1.5.0" junit = "4.13.2" -ktor-serialization-kotlinx-xml = "2.3.11" +ktor = "2.3.11" kotlinx-serialization = "1.6.3" +kotlinx-coroutines = "1.6.0" squareup-okhttp = "5.0.0-alpha.11" 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.3.2" +composable-preview-scanner = "0.4.0" [libraries] roborazzi = { module = "io.github.takahirom.roborazzi:roborazzi", version.ref = "roborazzi-for-replacing-by-include-build" } @@ -55,6 +58,8 @@ kotlin-stdlib-jdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit" } +generativeai-google = { module = "dev.shreyaspatil.generativeai:generativeai-google", version.ref = "generativeaiGoogle" } + # for sample composable-preview-scanner = { module = "io.github.sergio-sastre.ComposablePreviewScanner:android", version.ref = "composable-preview-scanner" } @@ -90,7 +95,14 @@ dropbox-differ = { module = "com.dropbox.differ:differ", version.ref = "dropbox- google-android-material = { module = "com.google.android.material:material", version.ref = "google-android-material" } junit = { module = "junit:junit", version.ref = "junit" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } -ktor-serialization-kotlinx-xml = { module = "io.ktor:ktor-serialization-kotlinx-xml", version.ref = "ktor-serialization-kotlinx-xml" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +ktor-serialization-kotlinx-xml = { module = "io.ktor:ktor-serialization-kotlinx-xml", version.ref = "ktor" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor-client-contentnegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-gson = { module = "io.ktor:ktor-serialization-gson", version.ref = "ktor" } +ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } squareup-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "squareup-okhttp" } squareup-okhttp-coroutines = { module = "com.squareup.okhttp3:okhttp-coroutines", version.ref = "squareup-okhttp" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } @@ -99,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/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 09523c0e5..df97d72b8 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/include-build/gradle/wrapper/gradle-wrapper.jar b/include-build/gradle/wrapper/gradle-wrapper.jar index e6441136f..a4b76b953 100644 Binary files a/include-build/gradle/wrapper/gradle-wrapper.jar and b/include-build/gradle/wrapper/gradle-wrapper.jar differ diff --git a/include-build/gradle/wrapper/gradle-wrapper.properties b/include-build/gradle/wrapper/gradle-wrapper.properties index 09523c0e5..df97d72b8 100644 --- a/include-build/gradle/wrapper/gradle-wrapper.properties +++ b/include-build/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/include-build/gradlew b/include-build/gradlew index b740cf133..f5feea6d6 100755 --- a/include-build/gradlew +++ b/include-build/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -84,7 +86,8 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum diff --git a/include-build/gradlew.bat b/include-build/gradlew.bat index 7101f8e46..9b42019c7 100644 --- a/include-build/gradlew.bat +++ b/include-build/gradlew.bat @@ -13,6 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## 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..15ce447e7 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,11 +52,26 @@ 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() - @InternalRoborazziApi + @ExperimentalRoborazziApi val options: RoborazziOptions get() = ruleOverrideRoborazziOptions ?: RoborazziOptions() 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 273a7f3f6..230ec4400 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 @@ -100,8 +100,10 @@ data class RoborazziOptions( val outputDirectoryPath: String = roborazziSystemPropertyOutputDirectory(), val imageComparator: ImageComparator = DefaultImageComparator, val comparisonStyle: ComparisonStyle = ComparisonStyle.Grid(), + val aiAssertionOptions: AiAssertionOptions? = null, val resultValidator: (result: ImageComparator.ComparisonResult) -> Boolean = DefaultResultValidator, ) { + @ExperimentalRoborazziApi sealed interface ComparisonStyle { @ExperimentalRoborazziApi @@ -151,6 +153,44 @@ data class RoborazziOptions( } else { JsonOutputCaptureResultReporter().report(captureResult, roborazziTaskType) } + AiCaptureResultReporter().report(captureResult, roborazziTaskType) + } + } + + class AiCaptureResultReporter : CaptureResultReporter { + override fun report(captureResult: CaptureResult, roborazziTaskType: RoborazziTaskType) { + val aiResult = when (captureResult) { + is CaptureResult.Changed -> { + captureResult.aiAssertionResults + } + + is CaptureResult.Added -> { + captureResult.aiAssertionResults + } + + else -> { + null + } + } + aiResult?.aiAssertionResults + ?.filter { assertionResult -> assertionResult.requiredFulfillmentPercent != null && assertionResult.failIfNotFulfilled } + ?.forEach { assertionResult: AiAssertionResult -> + if (assertionResult.fulfillmentPercent < assertionResult.requiredFulfillmentPercent!!) { + throw AssertionError( + "The generated image did not meet the required prompt fulfillment percentage.\n" + + "* Condition:\n" + + " - assertionPrompt: ${assertionResult.assertionPrompt}\n" + + " - failIfNotFulfilled: ${assertionResult.failIfNotFulfilled}\n" + + " - requiredFulfillmentPercent: ${assertionResult.requiredFulfillmentPercent}\n" + + "* Result:\n" + + " - fulfillmentPercent: ${assertionResult.fulfillmentPercent}\n" + + " - explanation: ${assertionResult.explanation}\n" + + " - referenceFile: ${captureResult.goldenFile}\n" + + " - compareFile: ${captureResult.compareFile}\n" + + " - actualFile: ${captureResult.actualFile}\n" + ) + } + } } } @@ -195,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 { @@ -210,6 +251,30 @@ data class RoborazziOptions( } internal val shouldTakeBitmap: Boolean = captureType.shouldTakeScreenshot() + + @ExperimentalRoborazziApi + fun addedAiAssertion( + assertionPrompt: String, + requiredFulfillmentPercent: Int + ): RoborazziOptions { + return addedAiAssertions( + AiAssertionOptions.AiAssertion( + assertionPrompt = assertionPrompt, + requiredFulfillmentPercent = requiredFulfillmentPercent + ) + ) + } + + @ExperimentalRoborazziApi + fun addedAiAssertions(vararg assertions: AiAssertionOptions.AiAssertion): RoborazziOptions { + return copy( + compareOptions = compareOptions.copy( + aiAssertionOptions = compareOptions.aiAssertionOptions!!.copy( + aiAssertions = compareOptions.aiAssertionOptions.aiAssertions + assertions + ) + ) + ) + } } expect fun canScreenshot(): Boolean 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 b2b09ae9d..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 @@ -88,15 +88,16 @@ fun processOutputImageAndReport( // Only used by CaptureResult.Changed var diffPercentage: Float? = null + val compareOptions = roborazziOptions.compareOptions val changed = if (height == goldenRoboCanvas.height && width == goldenRoboCanvas.width) { val comparisonResult: ImageComparator.ComparisonResult = newRoboCanvas.differ( other = goldenRoboCanvas, resizeScale = resizeScale, - imageComparator = roborazziOptions.compareOptions.imageComparator + imageComparator = compareOptions.imageComparator ) diffPercentage = comparisonResult.pixelDifferences.toFloat() / comparisonResult.pixelCount - val changed = !roborazziOptions.compareOptions.resultValidator(comparisonResult) + val changed = !compareOptions.resultValidator(comparisonResult) reportLog("${goldenFile.name} The differ result :$comparisonResult changed:$changed") changed } else { @@ -106,7 +107,7 @@ fun processOutputImageAndReport( val result: CaptureResult = if (changed) { val comparisonFile = File( - roborazziOptions.compareOptions.outputDirectoryPath, + compareOptions.outputDirectoryPath, goldenFile.nameWithoutExtension + "_compare." + goldenFile.extension ) val comparisonCanvas = comparisonCanvasFactory( @@ -119,7 +120,8 @@ fun processOutputImageAndReport( .save( path = comparisonFile.absolutePath, resizeScale = resizeScale, - contextData = contextData + contextData = contextData, + imageIoFormat = recordOptions.imageIoFormat, ) debugLog { "processOutputImageAndReport(): compareCanvas is saved " + @@ -132,7 +134,7 @@ fun processOutputImageAndReport( goldenFile } else { File( - roborazziOptions.compareOptions.outputDirectoryPath, + compareOptions.outputDirectoryPath, goldenFile.nameWithoutExtension + "_actual." + goldenFile.extension ) } @@ -140,8 +142,20 @@ 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()) { + aiOptions.aiAssertionModel.assert( + referenceImageFilePath = goldenFile.absolutePath, + comparisonImageFilePath = comparisonFile.absolutePath, + actualImageFilePath = actualFile.absolutePath, + aiAssertionOptions = aiOptions ) + } else { + null + } debugLog { "processOutputImageAndReport(): actualCanvas is saved " + "actualFile:${actualFile.absolutePath}" @@ -153,6 +167,7 @@ fun processOutputImageAndReport( goldenFile = goldenFile.absolutePath, timestampNs = System.nanoTime(), diffPercentage = diffPercentage, + aiAssertionResults = aiResult, contextData = contextData, ) } else { @@ -161,6 +176,7 @@ fun processOutputImageAndReport( actualFile = actualFile.absolutePath, goldenFile = goldenFile.absolutePath, timestampNs = System.nanoTime(), + aiAssertionResults = aiResult, contextData = contextData, ) } @@ -186,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/AiAssertionOptions.kt b/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/AiAssertionOptions.kt new file mode 100644 index 000000000..d3f0766c4 --- /dev/null +++ b/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/AiAssertionOptions.kt @@ -0,0 +1,50 @@ +package com.github.takahirom.roborazzi + +/** + * If you want to use AI to compare images, you can specify the model and prompt. + */ +@ExperimentalRoborazziApi +data class AiAssertionOptions( + val aiAssertionModel: AiAssertionModel, + val aiAssertions: List = emptyList(), + val systemPrompt: String = """Evaluate the following assertion for fulfillment in the new image. +The evaluation should be based on the comparison between the original image on the left and the new image on the right, with differences highlighted in red in the center. Focus on whether the new image fulfills the requirement specified in the user input. + +Output: +For each assertion: +A fulfillment percentage from 0 to 100. +A brief explanation of how this percentage was determined.""", + val promptTemplate: String = """Assertions: +INPUT_PROMPT +""", + val inputPrompt: (AiAssertionOptions) -> String = { aiOptions -> + buildString { + aiOptions.aiAssertions.forEachIndexed { index, aiAssertion -> + appendLine("Assertion ${index + 1}: ${aiAssertion.assertionPrompt}\n") + } + } + }, +) { + @ExperimentalRoborazziApi + interface AiAssertionModel { + fun assert( + referenceImageFilePath: String, + comparisonImageFilePath: String, + actualImageFilePath: String, + aiAssertionOptions: AiAssertionOptions + ): AiAssertionResults + companion object { + const val DefaultMaxOutputTokens = 300 + const val DefaultTemperature = 0.4F + } + } + + data class AiAssertion( + val assertionPrompt: String, + val failIfNotFulfilled: Boolean = true, + /** + * If null, the AI result is not validated. But the fulfillment_percent are still included in the report. + */ + val requiredFulfillmentPercent: Int? = 80 + ) +} \ No newline at end of file diff --git a/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/CaptureResult.kt b/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/CaptureResult.kt index b3459a441..798a51bb0 100644 --- a/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/CaptureResult.kt +++ b/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/CaptureResult.kt @@ -22,7 +22,39 @@ sealed interface CaptureResult { val compareFile: String? val actualFile: String? val goldenFile: String? - val contextData: Map + val contextData: Map + + @InternalRoborazziApi + val aiAssertionResultsOrNull: AiAssertionResults? + get() = when (this) { + is Added -> aiAssertionResults + is Changed -> aiAssertionResults + else -> null + } + + @InternalRoborazziApi + fun reportText(): String { + return buildString { + append(reportFile.name) + if (contextData.isNotEmpty() && contextData.all { + it.value.toString() != "null" && it.value.toString().isNotEmpty() + }) { + appendLine("contextData:$contextData") + } + aiAssertionResultsOrNull?.aiAssertionResults?.forEach { assertionResult -> + appendLine("aiAssertionResults:") + appendLine( + "* Condition:\n" + + " - assertionPrompt: ${assertionResult.assertionPrompt}\n" + + " - failIfNotFulfilled: ${assertionResult.failIfNotFulfilled}\n" + + " - requiredFulfillmentPercent: ${assertionResult.requiredFulfillmentPercent}\n" + + "* Result:\n" + + " - fulfillmentPercent: ${assertionResult.fulfillmentPercent}\n" + + " - explanation: ${assertionResult.explanation}\n" + ) + } + } + } val reportFile: String get() = when (val result = this) { @@ -35,11 +67,11 @@ sealed interface CaptureResult { @Serializable data class Recorded( @SerialName("golden_file_path") - override val goldenFile:@Contextual String, + override val goldenFile: @Contextual String, @SerialName("timestamp") override val timestampNs: Long, @SerialName("context_data") - override val contextData: Map + override val contextData: Map ) : CaptureResult { override val type = "recorded" override val actualFile: String? @@ -51,15 +83,17 @@ sealed interface CaptureResult { @Serializable data class Added( @SerialName("compare_file_path") - override val compareFile:@Contextual String, + override val compareFile: @Contextual String, @SerialName("actual_file_path") - override val actualFile:@Contextual String, + override val actualFile: @Contextual String, @SerialName("golden_file_path") - override val goldenFile:@Contextual String, + override val goldenFile: @Contextual String, @SerialName("timestamp") override val timestampNs: Long, + @SerialName("ai_assertion_results") + val aiAssertionResults: AiAssertionResults?, @SerialName("context_data") - override val contextData: Map + override val contextData: Map ) : CaptureResult { override val type = "added" } @@ -67,17 +101,19 @@ sealed interface CaptureResult { @Serializable data class Changed( @SerialName("compare_file_path") - override val compareFile:@Contextual String, + override val compareFile: @Contextual String, @SerialName("golden_file_path") - override val goldenFile:@Contextual String, + override val goldenFile: @Contextual String, @SerialName("actual_file_path") - override val actualFile:@Contextual String, + override val actualFile: @Contextual String, @SerialName("timestamp") override val timestampNs: Long, @SerialName("diff_percentage") val diffPercentage: Float?, + @SerialName("ai_assertion_results") + val aiAssertionResults: AiAssertionResults?, @SerialName("context_data") - override val contextData: Map + override val contextData: Map ) : CaptureResult { override val type = "changed" } @@ -85,11 +121,11 @@ sealed interface CaptureResult { @Serializable data class Unchanged( @SerialName("golden_file_path") - override val goldenFile:@Contextual String, + override val goldenFile: @Contextual String, @SerialName("timestamp") override val timestampNs: Long, @SerialName("context_data") - override val contextData: Map + override val contextData: Map ) : CaptureResult { override val type = "unchanged" override val actualFile: String? @@ -122,12 +158,32 @@ sealed interface CaptureResult { require(decoder is JsonDecoder) val type = decoder.decodeJsonElement().jsonObject["type"]!!.jsonPrimitive.content return when (type) { - "recorded" -> decoder.decodeSerializableValue(Recorded.serializer()) - "changed" -> decoder.decodeSerializableValue(Changed.serializer()) - "unchanged" -> decoder.decodeSerializableValue(Unchanged.serializer()) - "added" -> decoder.decodeSerializableValue(Added.serializer()) - else -> throw IllegalArgumentException("Unknown type $type") + "recorded" -> decoder.decodeSerializableValue(Recorded.serializer()) + "changed" -> decoder.decodeSerializableValue(Changed.serializer()) + "unchanged" -> decoder.decodeSerializableValue(Unchanged.serializer()) + "added" -> decoder.decodeSerializableValue(Added.serializer()) + else -> throw IllegalArgumentException("Unknown type $type") } } } } + +@Serializable +data class AiAssertionResults( + @SerialName("ai_assertion_results") + val aiAssertionResults: List = emptyList() +) + +@Serializable +data class AiAssertionResult( + @SerialName("assert_prompt") + val assertionPrompt: String, + @SerialName("required_fulfillment_percent") + val requiredFulfillmentPercent: Int?, + @SerialName("fail_if_not_fulfilled") + val failIfNotFulfilled: Boolean, + @SerialName("fulfillment_percent") + val fulfillmentPercent: Int, + @SerialName("explanation") + val explanation: String?, +) \ No newline at end of file diff --git a/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/CaptureResults.kt b/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/CaptureResults.kt index ff94ee7a1..bed77daa6 100644 --- a/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/CaptureResults.kt +++ b/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/CaptureResults.kt @@ -132,15 +132,7 @@ data class CaptureResults( append("") images.forEach { image -> append("") - - val contextData = if (image.contextData.isNotEmpty() && image.contextData.all { - it.value.toString() != "null" && it.value.toString().isNotEmpty() - }) { - "
contextData:${image.contextData}" - } else { - "" - } - append("${image.reportFile.name}$contextData") + append("${image.reportText().lines().joinToString("
")}") append( ", + 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/include-build/roborazzi-core/src/jvmTest/kotlin/io/github/takahirom/roborazzi/CaptureResultTest.kt b/include-build/roborazzi-core/src/jvmTest/kotlin/io/github/takahirom/roborazzi/CaptureResultTest.kt index efcf8741c..e16038bd5 100644 --- a/include-build/roborazzi-core/src/jvmTest/kotlin/io/github/takahirom/roborazzi/CaptureResultTest.kt +++ b/include-build/roborazzi-core/src/jvmTest/kotlin/io/github/takahirom/roborazzi/CaptureResultTest.kt @@ -36,6 +36,7 @@ class CaptureResultTest { actualFile = "/actual_file", goldenFile = "/golden_file", timestampNs = 123456789, + aiAssertionResults = null, contextData = mapOf( "key" to 2, "keyDouble" to 2.5, @@ -47,6 +48,7 @@ class CaptureResultTest { actualFile = "/actual_file", timestampNs = 123456789, diffPercentage = 0.123f, + aiAssertionResults = null, contextData = mapOf("key" to Long.MAX_VALUE - 100), ), CaptureResult.Unchanged( diff --git a/include-build/roborazzi-gradle-plugin/src/integrationTest/java/io/github/takahirom/roborazzi/RoborazziGradleProject.kt b/include-build/roborazzi-gradle-plugin/src/integrationTest/java/io/github/takahirom/roborazzi/RoborazziGradleProject.kt index c3667a3bd..7a107a9f4 100644 --- a/include-build/roborazzi-gradle-plugin/src/integrationTest/java/io/github/takahirom/roborazzi/RoborazziGradleProject.kt +++ b/include-build/roborazzi-gradle-plugin/src/integrationTest/java/io/github/takahirom/roborazzi/RoborazziGradleProject.kt @@ -18,7 +18,11 @@ class RoborazziGradleRootProject(val testProjectDir: TemporaryFolder) { val appModule = AppModule(this, testProjectDir) val previewModule = PreviewModule(this, testProjectDir) - fun runTask(task: String, buildType: BuildType, additionalParameters: Array): BuildResult { + fun runTask( + task: String, + buildType: BuildType, + additionalParameters: Array + ): BuildResult { val buildResult = GradleRunner.create() .withProjectDir(testProjectDir.root) .withArguments( @@ -60,6 +64,11 @@ class AppModule(val rootProject: RoborazziGradleRootProject, val testProjectDir: return runTask(task) } + fun recordWithCleanupOldScreenshots(): BuildResult { + val task = "recordRoborazziDebug" + return runTask(task, additionalParameters = arrayOf("-Proborazzi.cleanupOldScreenshots=true")) + } + fun recordWithFilter1(): BuildResult { val task = "recordRoborazziDebug" return runTask( @@ -136,6 +145,11 @@ class AppModule(val rootProject: RoborazziGradleRootProject, val testProjectDir: return runTask(task) } + fun compareWithCleanupOldScreenshots(): BuildResult { + val task = "compareRoborazziDebug" + return runTask(task, additionalParameters = arrayOf("-Proborazzi.cleanupOldScreenshots=true")) + } + fun clear(): BuildResult { val task = "clearRoborazziDebug" return runTask(task) @@ -181,7 +195,7 @@ class AppModule(val rootProject: RoborazziGradleRootProject, val testProjectDir: buildGradle.addIncludeBuild() val buildResult = rootProject.runTask( - "app:"+task, + "app:" + task, buildType, additionalParameters ) @@ -192,8 +206,9 @@ class AppModule(val rootProject: RoborazziGradleRootProject, val testProjectDir: private val PATH = "app/build.gradle.kts" var removeOutputDirBeforeTestTypeTask = false var customOutputDirPath: String? = null + init { - addIncludeBuild() + addIncludeBuild() } fun addIncludeBuild() { diff --git a/include-build/roborazzi-gradle-plugin/src/integrationTest/java/io/github/takahirom/roborazzi/RoborazziGradleProjectTest.kt b/include-build/roborazzi-gradle-plugin/src/integrationTest/java/io/github/takahirom/roborazzi/RoborazziGradleProjectTest.kt index b207e37cb..fa1b27bcb 100644 --- a/include-build/roborazzi-gradle-plugin/src/integrationTest/java/io/github/takahirom/roborazzi/RoborazziGradleProjectTest.kt +++ b/include-build/roborazzi-gradle-plugin/src/integrationTest/java/io/github/takahirom/roborazzi/RoborazziGradleProjectTest.kt @@ -3,15 +3,25 @@ package io.github.takahirom.roborazzi import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder +import org.junit.rules.TestWatcher /** * Run this test with `cd include-build` and `./gradlew roborazzi-gradle-plugin:check` + * You can also run this test with the following command: + * ./gradlew roborazzi-gradle-plugin:integrationTest --tests "*RoborazziGradleProjectTest.record" */ class RoborazziGradleProjectTest { @get:Rule val testProjectDir = TemporaryFolder() + @get:Rule + val testNameOutputRule = object : TestWatcher() { + override fun starting(description: org.junit.runner.Description?) { + println("RoborazziGradleProjectTest.${description?.methodName} started") + } + } + private val className = "com.github.takahirom.integration_test_project.RoborazziTest" private var defaultBuildDir = "build" @@ -308,8 +318,7 @@ class RoborazziGradleProjectTest { removeTests() record() - // Summary file will be generated even if no test files - checkResultsSummaryFileExists() + checkResultsSummaryFileNotExists() // Test will be skipped when no source so no output checkResultFileNotExists(resultFileSuffix) } @@ -392,6 +401,21 @@ class RoborazziGradleProjectTest { } } + @Test + fun compareWithDeleteOldScreenshot() { + RoborazziGradleRootProject(testProjectDir).appModule.apply { + recordWithCleanupOldScreenshots() + changeScreen() + compareWithCleanupOldScreenshots() + + checkResultsSummaryFileExists() + checkRecordedFileExists("$screenshotAndName.testCapture.png") + checkResultFileExists(resultFileSuffix) + checkRecordedFileExists("$screenshotAndName.testCapture_compare.png") + checkRecordedFileExists("$screenshotAndName.testCapture_actual.png") + } + } + @Test fun compareWithSystemParameter() { println("start compareWithSystemParameter") @@ -420,6 +444,21 @@ class RoborazziGradleProjectTest { } } + @Test + fun ifWeUseCleanupOldScreenshotsTheOldScreenshotsShouldNotExit() { + RoborazziGradleRootProject(testProjectDir).appModule.apply { + recordWithCleanupOldScreenshots() + removeTests() + addTestCaptureWithCustomPathTest() + recordWithCleanupOldScreenshots() + + checkResultsSummaryFileExists() + checkRecordedFileNotExists("$screenshotAndName.testCapture.png") + checkRecordedFileExists("$customReferenceScreenshotAndName.png") + checkRecordedFileExists("app/build/outputs/roborazzi/custom_outputDirectoryPath_from_rule/custom_outputFileProvider-com.github.takahirom.integration_test_project.RoborazziTest.testCaptureWithCustomPath.png") + } + } + @Test fun compareWithCustomPath() { RoborazziGradleRootProject(testProjectDir).appModule.apply { diff --git a/include-build/roborazzi-gradle-plugin/src/main/java/io/github/takahirom/roborazzi/RoborazziPlugin.kt b/include-build/roborazzi-gradle-plugin/src/main/java/io/github/takahirom/roborazzi/RoborazziPlugin.kt index b6542cb22..b8ece69eb 100644 --- a/include-build/roborazzi-gradle-plugin/src/main/java/io/github/takahirom/roborazzi/RoborazziPlugin.kt +++ b/include-build/roborazzi-gradle-plugin/src/main/java/io/github/takahirom/roborazzi/RoborazziPlugin.kt @@ -15,8 +15,10 @@ import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.Task import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFile import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider import org.gradle.api.tasks.InputDirectory import org.gradle.api.tasks.Optional import org.gradle.api.tasks.OutputDirectory @@ -35,6 +37,7 @@ import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTargetWithTests import org.jetbrains.kotlin.gradle.targets.jvm.KotlinJvmTarget import org.jetbrains.kotlin.gradle.targets.native.KotlinNativeBinaryTestRun import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeTest +import java.io.File import java.io.FileNotFoundException import java.util.Locale import javax.inject.Inject @@ -56,6 +59,7 @@ open class RoborazziExtension @Inject constructor(objects: ObjectFactory) { } } +val KnownImageFileExtensions: Set = setOf("png", "gif", "jpg", "jpeg", "webp") @Suppress("unused") // From Paparazzi: https://github.com/cashapp/paparazzi/blob/a76702744a7f380480f323ffda124e845f2733aa/paparazzi/paparazzi-gradle-plugin/src/main/java/app/cash/paparazzi/gradle/PaparazziPlugin.kt @@ -77,7 +81,7 @@ abstract class RoborazziPlugin : Plugin { else -> throw IllegalStateException("Unsupported test task type: $this") } - @OptIn(InternalRoborazziApi::class) + @OptIn(InternalRoborazziApi::class, ExperimentalRoborazziApi::class) override fun apply(project: Project) { val extension = project.extensions.create("roborazzi", RoborazziExtension::class.java) @@ -153,7 +157,7 @@ abstract class RoborazziPlugin : Plugin { val outputDirFile = outputDir.get().asFile if (outputDirFile.exists()) { outputDirFile.walkTopDown().forEach { file -> - if (file.name.endsWith(".png") || file.name.endsWith(".gif")) { + if (KnownImageFileExtensions.contains(file.extension)) { file.delete() } } @@ -162,7 +166,7 @@ abstract class RoborazziPlugin : Plugin { val intermediateDirFile = intermediateDirForEachVariant.get().asFile if (intermediateDirFile.exists()) { intermediateDirFile.walkTopDown().forEach { file -> - if (file.name.endsWith(".png") || file.name.endsWith(".gif")) { + if (KnownImageFileExtensions.contains(file.extension)) { file.delete() } } @@ -279,34 +283,6 @@ abstract class RoborazziPlugin : Plugin { overwrite = true ) finalizeTestTask.infoln("Roborazzi: finalizeTestRoborazziTask Copy files from ${intermediateDir.get()} to ${outputDir.get()} end ${System.currentTimeMillis() - startCopy}ms") - - val results: List = resultDirFileTree.get().mapNotNull { - if (it.name.endsWith(".json")) { - CaptureResult.fromJsonFile(it.path) - } else { - null - } - } - val resultsSummaryFile = resultSummaryFileProperty.get().asFile - - val roborazziResults = CaptureResults.from(results) - finalizeTestTask.infoln("Roborazzi: Save result to ${resultsSummaryFile.absolutePath} with results:${results.size} summary:${roborazziResults.resultSummary}") - - val jsonResult = roborazziResults.toJson() - resultsSummaryFile.parentFile.mkdirs() - resultsSummaryFile.writeText(jsonResult) - val reportFile = reportFileProperty.get().asFile - - reportFile.parentFile.mkdirs() - WebAssets.create().writeToRoborazziReportsDir(reportFile.parentFile) - val reportHtml = readIndexHtmlFile() - ?: throw FileNotFoundException("index.html not found in resources/META-INF folder") - reportFile.writeText( - reportHtml.replace( - oldValue = "REPORT_TEMPLATE_BODY", - newValue = roborazziResults.toHtml(reportFile.parentFile.absolutePath) - ) - ) } } }) @@ -346,15 +322,15 @@ abstract class RoborazziPlugin : Plugin { test.infoln("Roborazzi: Set output dir $it to test task") it }) - test.outputs.dir(resultDirFileProperty.let { + test.outputs.dir(resultDirFileProperty.map { test.infoln("Roborazzi: Set output dir $it to test task") it }) - test.outputs.file(resultSummaryFileProperty.let { + test.outputs.file(resultSummaryFileProperty.map { test.infoln("Roborazzi: Set output file $it to test task") it }) - test.outputs.file(reportFileProperty.let { + test.outputs.file(reportFileProperty.map { test.infoln("Roborazzi: Set output file $it to test task") it }) @@ -420,6 +396,38 @@ abstract class RoborazziPlugin : Plugin { // Run only root suite return } + if (doesRoborazziRunProvider.get()) { + test.infoln("Roborazzi: test.afterSuite ${test.name}") + } else { + return + } + + val results: List = resultDirFileTree.get().mapNotNull { + if (it.name.endsWith(".json")) { + CaptureResult.fromJsonFile(it.path) + } else { + null + } + } + val resultsSummaryFile = resultSummaryFileProperty.get().asFile + + val roborazziResults = CaptureResults.from(results) + saveResults( + test = test, + resultsSummaryFile = resultsSummaryFile, + results = results, + roborazziResults = roborazziResults, + reportFileProperty = reportFileProperty + ) + // The reason why we do this in afterSuite() is that we want to change the tasks' output in the task execution phase. + cleanupOldScreenshotsIfNeeded( + test = test, + roborazziProperties = roborazziProperties, + outputDir = outputDir, + intermediateDir = intermediateDir, + roborazziResults = roborazziResults, + ) + // Copy all files from outputDir to intermediateDir // so that we can use Gradle's output caching test.infoln("Roborazzi: test.doLast Copy files from ${outputDir.get()} to ${intermediateDir.get()}") @@ -526,6 +534,70 @@ abstract class RoborazziPlugin : Plugin { } } + private fun saveResults( + test: AbstractTestTask, + resultsSummaryFile: File, + results: List, + roborazziResults: CaptureResults, + reportFileProperty: Provider + ) { + test.infoln("Roborazzi: Save result to ${resultsSummaryFile.absolutePath} with results:${results.size} summary:${roborazziResults.resultSummary}") + + val jsonResult = roborazziResults.toJson() + resultsSummaryFile.parentFile.mkdirs() + resultsSummaryFile.writeText(jsonResult) + val reportFile = reportFileProperty.get().asFile + + reportFile.parentFile.mkdirs() + WebAssets.create().writeToRoborazziReportsDir(reportFile.parentFile) + val reportHtml = readIndexHtmlFile() + ?: throw FileNotFoundException("index.html not found in resources/META-INF folder") + reportFile.writeText( + reportHtml.replace( + oldValue = "REPORT_TEMPLATE_BODY", + newValue = roborazziResults.toHtml(reportFile.parentFile.absolutePath) + ) + ) + } + + private fun cleanupOldScreenshotsIfNeeded( + test: AbstractTestTask, + roborazziProperties: Map, + outputDir: DirectoryProperty, + intermediateDir: DirectoryProperty, + roborazziResults: CaptureResults, + ) { + if (roborazziProperties["roborazzi.cleanupOldScreenshots"] == "true") { + // Delete all images from the intermediateDir + intermediateDir.get().asFile.walkTopDown().forEach { file -> + if (KnownImageFileExtensions.contains(file.extension)) { + file.delete() + } + } + + // Remove all files not in the results from the outputDir + val removingFiles: MutableSet = outputDir.get().asFile + .listFiles() + ?.toList() + .orEmpty() + .filter { it.isFile && KnownImageFileExtensions.contains(it.extension) } + .map { it.absolutePath } + .toMutableSet() + roborazziResults.captureResults.forEach { result -> + val latestFiles = listOfNotNull( + result.actualFile, + result.compareFile, + result.goldenFile + ).map { File(it).absolutePath } + removingFiles.removeAll(latestFiles) + } + test.infoln("Roborazzi: Cleanup old files $removingFiles") + removingFiles.forEach { file -> + File(file).delete() + } + } + } + private fun readIndexHtmlFile(): String? { return this::class.java.classLoader.getResource("META-INF/index.html")?.readText() } diff --git a/roborazzi-ai-gemini/.gitignore b/roborazzi-ai-gemini/.gitignore new file mode 100644 index 000000000..d16386367 --- /dev/null +++ b/roborazzi-ai-gemini/.gitignore @@ -0,0 +1 @@ +build/ \ No newline at end of file diff --git a/roborazzi-ai-gemini/build.gradle b/roborazzi-ai-gemini/build.gradle new file mode 100644 index 000000000..7850852e6 --- /dev/null +++ b/roborazzi-ai-gemini/build.gradle @@ -0,0 +1,61 @@ +plugins { + id "org.jetbrains.kotlin.multiplatform" + id "com.android.library" + id("org.jetbrains.kotlin.plugin.serialization") version libs.versions.kotlin +} +if (System.getenv("INTEGRATION_TEST") != "true") { + pluginManager.apply("com.vanniktech.maven.publish") +} + +android.compileSdk(34) + +kotlin { + applyDefaultHierarchyTemplate() + + iosX64() + iosArm64() + iosSimulatorArm64() + + jvm() + + androidTarget { + publishLibraryVariants("release") + } + + sourceSets { + commonMain { + dependencies { + implementation "io.github.takahirom.roborazzi:roborazzi-core:$VERSION_NAME" + // To expose generationConfigBuilder + api libs.generativeai.google + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + } + } + } + sourceSets.all { + it.languageSettings { + progressiveMode = true + optIn("com.github.takahirom.roborazzi.InternalRoborazziApi") + optIn("com.github.takahirom.roborazzi.ExperimentalRoborazziApi") + } + } +} + +android { + namespace 'com.github.takahirom.roborazzi.ai.gemini' + compileSdk 34 + + defaultConfig { + minSdk 21 + targetSdk 32 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + buildFeatures {} + testOptions { + unitTests { + includeAndroidResources = true + } + } +} diff --git a/roborazzi-ai-gemini/src/androidMain/kotlin/com/github/takahirom/roborazzi/GeminiRoborazziAi.android.kt b/roborazzi-ai-gemini/src/androidMain/kotlin/com/github/takahirom/roborazzi/GeminiRoborazziAi.android.kt new file mode 100644 index 000000000..70cc43731 --- /dev/null +++ b/roborazzi-ai-gemini/src/androidMain/kotlin/com/github/takahirom/roborazzi/GeminiRoborazziAi.android.kt @@ -0,0 +1,16 @@ +package com.github.takahirom.roborazzi + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import dev.shreyaspatil.ai.client.generativeai.type.PlatformImage +import java.io.ByteArrayOutputStream + +actual fun readByteArrayFromFile(filePath: String): PlatformImage { + val bitmap = BitmapFactory.decodeFile(filePath) + val outputStream = ByteArrayOutputStream() + val data = outputStream.use { + bitmap.compress(Bitmap.CompressFormat.JPEG, 80, it) + outputStream.toByteArray() + } + return PlatformImage(data = data) +} \ No newline at end of file diff --git a/roborazzi-ai-gemini/src/commonMain/kotlin/com/github/takahirom/roborazzi/GeminiRoborazziAi.kt b/roborazzi-ai-gemini/src/commonMain/kotlin/com/github/takahirom/roborazzi/GeminiRoborazziAi.kt new file mode 100644 index 000000000..64482c975 --- /dev/null +++ b/roborazzi-ai-gemini/src/commonMain/kotlin/com/github/takahirom/roborazzi/GeminiRoborazziAi.kt @@ -0,0 +1,116 @@ +package com.github.takahirom.roborazzi + +import com.github.takahirom.roborazzi.AiAssertionOptions.AiAssertionModel +import com.github.takahirom.roborazzi.AiAssertionOptions.AiAssertionModel.Companion.DefaultMaxOutputTokens +import com.github.takahirom.roborazzi.AiAssertionOptions.AiAssertionModel.Companion.DefaultTemperature +import dev.shreyaspatil.ai.client.generativeai.GenerativeModel +import dev.shreyaspatil.ai.client.generativeai.type.FunctionType +import dev.shreyaspatil.ai.client.generativeai.type.GenerationConfig +import dev.shreyaspatil.ai.client.generativeai.type.PlatformImage +import dev.shreyaspatil.ai.client.generativeai.type.Schema +import dev.shreyaspatil.ai.client.generativeai.type.content +import dev.shreyaspatil.ai.client.generativeai.type.generationConfig +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@ExperimentalRoborazziApi +class GeminiAiAssertionModel( + private val apiKey: String, + private val modelName: String = "gemini-1.5-flash", + private val generationConfigBuilder: GenerationConfig.Builder.() -> Unit = { + maxOutputTokens = DefaultMaxOutputTokens + temperature = DefaultTemperature + } +) : AiAssertionModel { + override fun assert( + referenceImageFilePath: String, + comparisonImageFilePath: String, + actualImageFilePath: String, + aiAssertionOptions: AiAssertionOptions + ): AiAssertionResults { + val systemPrompt = aiAssertionOptions.systemPrompt + val generativeModel = GenerativeModel( + modelName = modelName, + apiKey = apiKey, + systemInstruction = content { + text(systemPrompt) + }, + generationConfig = generationConfig { + responseMimeType = "application/json" + responseSchema = Schema( + name = "content", + description = "content", + type = FunctionType.ARRAY, + items = Schema( + name = "assert_results", + description = "An array of assertion results", + type = FunctionType.OBJECT, + properties = mapOf( + "fulfillment_percent" to Schema.int( + name = "fulfillment_percent", + description = "A fulfillment percentage from 0 to 100", + ), + "explanation" to Schema( + name = "explanation", + description = "A brief explanation of how this percentage was determined. If fulfillment_percent is 100, this field should be empty.", + type = FunctionType.STRING, + nullable = true, + ) + ), + required = listOf("fulfillment_percent") + ), + ) + generationConfigBuilder() + }, + ) + + val template = aiAssertionOptions.promptTemplate + + val inputPrompt = aiAssertionOptions.inputPrompt(aiAssertionOptions) + val inputContent = content { + image(readByteArrayFromFile(comparisonImageFilePath)) + val prompt = template.replace("INPUT_PROMPT", inputPrompt) + text(prompt) + + debugLog { + "RoborazziAi: prompt:$prompt" + } + } + + val response = runBlocking { generativeModel.generateContent(inputContent) } + debugLog { + "RoborazziAi: response: ${response.text}" + } + val geminiResult = CaptureResults.json.decodeFromString>( + requireNotNull( + response.text + ) + ) + return AiAssertionResults( + aiAssertionResults = aiAssertionOptions.aiAssertions.mapIndexed { index, condition -> + val assertResult = geminiResult.getOrNull(index) ?: GeminiAiConditionResult( + fulfillmentPercent = 0, + explanation = "AI model did not return a result for this assertion" + ) + AiAssertionResult( + assertionPrompt = condition.assertionPrompt, + requiredFulfillmentPercent = condition.requiredFulfillmentPercent, + failIfNotFulfilled = condition.failIfNotFulfilled, + fulfillmentPercent = assertResult.fulfillmentPercent, + explanation = assertResult.explanation, + ) + } + ) + } +} + + +@Serializable +private data class GeminiAiConditionResult( + @SerialName("fulfillment_percent") + val fulfillmentPercent: Int, + val explanation: String?, +) + +internal expect fun readByteArrayFromFile(filePath: String): PlatformImage diff --git a/roborazzi-ai-gemini/src/jvmMain/kotlin/com/github/takahirom/roborazzi/GeminiRoborazziAi.jvm.kt b/roborazzi-ai-gemini/src/jvmMain/kotlin/com/github/takahirom/roborazzi/GeminiRoborazziAi.jvm.kt new file mode 100644 index 000000000..a961f781a --- /dev/null +++ b/roborazzi-ai-gemini/src/jvmMain/kotlin/com/github/takahirom/roborazzi/GeminiRoborazziAi.jvm.kt @@ -0,0 +1,8 @@ +package com.github.takahirom.roborazzi + +import dev.shreyaspatil.ai.client.generativeai.type.PlatformImage +import java.io.File + +internal actual fun readByteArrayFromFile(filePath: String): PlatformImage { + return PlatformImage(File(filePath).readBytes()) +} \ No newline at end of file diff --git a/roborazzi-ai-gemini/src/nativeMain/kotlin/com/github/takahirom/roborazzi/GeminiRoborazziAi.native.kt b/roborazzi-ai-gemini/src/nativeMain/kotlin/com/github/takahirom/roborazzi/GeminiRoborazziAi.native.kt new file mode 100644 index 000000000..d5c5d842b --- /dev/null +++ b/roborazzi-ai-gemini/src/nativeMain/kotlin/com/github/takahirom/roborazzi/GeminiRoborazziAi.native.kt @@ -0,0 +1,9 @@ +package com.github.takahirom.roborazzi + +import dev.shreyaspatil.ai.client.generativeai.type.PlatformImage +import dev.shreyaspatil.ai.client.generativeai.type.asPlatformImage +import platform.UIKit.UIImage + +internal actual fun readByteArrayFromFile(filePath: String): PlatformImage { + return UIImage.imageWithContentsOfFile(filePath)!!.asPlatformImage() +} \ No newline at end of file diff --git a/roborazzi-ai-openai/.gitignore b/roborazzi-ai-openai/.gitignore new file mode 100644 index 000000000..d16386367 --- /dev/null +++ b/roborazzi-ai-openai/.gitignore @@ -0,0 +1 @@ +build/ \ No newline at end of file diff --git a/roborazzi-ai-openai/build.gradle b/roborazzi-ai-openai/build.gradle new file mode 100644 index 000000000..bb8b5b152 --- /dev/null +++ b/roborazzi-ai-openai/build.gradle @@ -0,0 +1,66 @@ +plugins { + id "org.jetbrains.kotlin.multiplatform" + id "com.android.library" + id("org.jetbrains.kotlin.plugin.serialization") version libs.versions.kotlin +} +if (System.getenv("INTEGRATION_TEST") != "true") { + pluginManager.apply("com.vanniktech.maven.publish") +} + +android.compileSdk(34) + +kotlin { + applyDefaultHierarchyTemplate() + + iosX64() + iosArm64() + iosSimulatorArm64() + + jvm() + + androidTarget { + publishLibraryVariants("release") + } + + sourceSets { + commonMain { + dependencies { + implementation "io.github.takahirom.roborazzi:roborazzi-core:$VERSION_NAME" + // To expose requestBuilderModifier + api(libs.ktor.client.core) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.ktor.serialization.json) + implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.logging) + implementation(libs.ktor.client.contentnegotiation) + implementation(libs.kotlinx.io.core) + implementation(libs.kotlinx.serialization.json) + } + } + } + sourceSets.all { + it.languageSettings { + progressiveMode = true + optIn("com.github.takahirom.roborazzi.InternalRoborazziApi") + optIn("com.github.takahirom.roborazzi.ExperimentalRoborazziApi") + } + } +} + +android { + namespace 'com.github.takahirom.roborazzi.ai.openai' + compileSdk 34 + + defaultConfig { + minSdk 21 + targetSdk 32 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + buildFeatures {} + testOptions { + unitTests { + includeAndroidResources = true + } + } +} \ No newline at end of file diff --git a/roborazzi-ai-openai/src/commonMain/kotlin/com/github/takahirom/roborazzi/OpenAiAiAssertionModel.kt b/roborazzi-ai-openai/src/commonMain/kotlin/com/github/takahirom/roborazzi/OpenAiAiAssertionModel.kt new file mode 100644 index 000000000..37a58435e --- /dev/null +++ b/roborazzi-ai-openai/src/commonMain/kotlin/com/github/takahirom/roborazzi/OpenAiAiAssertionModel.kt @@ -0,0 +1,304 @@ +package com.github.takahirom.roborazzi + +import com.github.takahirom.roborazzi.AiAssertionOptions.AiAssertionModel.Companion.DefaultMaxOutputTokens +import com.github.takahirom.roborazzi.AiAssertionOptions.AiAssertionModel.Companion.DefaultTemperature +import com.github.takahirom.roborazzi.CaptureResults.Companion.json +import io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.HttpTimeout.Plugin.INFINITE_TIMEOUT_MS +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logger +import io.ktor.client.plugins.logging.Logging +import io.ktor.client.plugins.logging.SIMPLE +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.runBlocking +import kotlinx.io.buffered +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import kotlinx.io.readByteArray +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonObject +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +@ExperimentalRoborazziApi +class OpenAiAiAssertionModel( + private val apiKey: String, + private val modelName: String = "gpt-4o", + private val baseUrl: String = "https://api.openai.com/v1/", + private val loggingEnabled: Boolean = false, + private val temperature: Float = DefaultTemperature, + private val maxTokens: Int = DefaultMaxOutputTokens, + private val seed: Int = 1566, + private val requestBuilderModifier: (HttpRequestBuilder.() -> Unit) = { + header("Authorization", "Bearer $apiKey") + }, + private val httpClient: HttpClient = HttpClient { + install(ContentNegotiation) { + json( + json = json + ) + } + install(HttpTimeout) { + requestTimeoutMillis = INFINITE_TIMEOUT_MS + socketTimeoutMillis = 80_000 + } + if (loggingEnabled) { + install(Logging) { + logger = object: Logger { + override fun log(message: String) { + Logger.SIMPLE.log(message.replace(apiKey, "****")) + } + } + level = LogLevel.ALL + } + } + }, +) : AiAssertionOptions.AiAssertionModel { + + override fun assert( + referenceImageFilePath: String, + comparisonImageFilePath: String, + actualImageFilePath: String, + aiAssertionOptions: AiAssertionOptions + ): AiAssertionResults { + val systemPrompt = aiAssertionOptions.systemPrompt + val template = aiAssertionOptions.promptTemplate + val inputPrompt = aiAssertionOptions.inputPrompt(aiAssertionOptions) + val imageBytes = readByteArrayFromFile(comparisonImageFilePath) + val imageBase64 = imageBytes.encodeBase64() + val messages = listOf( + Message( + role = "system", + content = listOf( + Content( + type = "text", + text = systemPrompt + ) + ), + ), + Message( + role = "user", + content = listOf( + Content( + type = "text", + text = template.replace("INPUT_PROMPT", inputPrompt) + ), + Content( + type = "image_url", + imageUrl = ImageUrl( + url = "data:image/png;base64,$imageBase64" + ) + ) + ) + ) + ) + val responseText = runBlocking { + chatCompletion( + messages = messages, + model = modelName + ) + } + debugLog { + "OpenAiAiModel: response: $responseText" + } + val aiConditionResults = parseOpenAiResponse(responseText, aiAssertionOptions) + return AiAssertionResults( + aiAssertionResults = aiConditionResults + ) + } + + private suspend fun chatCompletion( + messages: List, + model: String + ): String { + val requestBody = ChatCompletionRequest( + model = model, + messages = messages, + temperature = temperature, + maxTokens = maxTokens, + responseFormat = ResponseFormat( + type = "json_schema", + jsonSchema = buildJsonSchema(), + ), + seed = seed + ) + val response: HttpResponse = httpClient.post(baseUrl + "chat/completions") { + requestBuilderModifier() + contentType(ContentType.Application.Json) + setBody(requestBody) + } + val bodyText = response.bodyAsText() + debugLog { "OpenAiAiModel: response: $bodyText" } + val responseBody: ChatCompletionResponse = json.decodeFromString(bodyText) + return responseBody.choices.firstOrNull()?.message?.content ?: "" + } + + private fun buildJsonSchema(): JsonObject { + val schemaJson = """ + { + "name": "OpenAiResponse1", + "description": "Verify image", + "strict": true, + "schema": { + "type": "object", + "required": ["results"], + "additionalProperties": false, + "properties": { + "results": { + "type": "array", + "items": { + "type": "object", + "required": ["fulfillment_percent", "explanation"], + "additionalProperties": false, + "properties": { + "fulfillment_percent": { + "type": "integer" + }, + "explanation": { + "type": ["string", "null"] + } + } + } + } + } + } +} + """.trimIndent() + return json.parseToJsonElement(schemaJson).jsonObject + } +} + + +private fun parseOpenAiResponse( + responseText: String, + aiAssertionOptions: AiAssertionOptions +): List { + val openAiResult = try { + val element = json.parseToJsonElement(responseText) + val resultsElement = element.jsonObject["results"] + val results = if (resultsElement != null) { + json.decodeFromJsonElement>(resultsElement) + } else { + emptyList() + } + OpenAiResponse(results = results) + } catch (e: Exception) { + debugLog { "Failed to parse OpenAI response: ${e.message}" } + OpenAiResponse(results = emptyList()) + } + return aiAssertionOptions.aiAssertions.mapIndexed { index, condition -> + val result = openAiResult.results.getOrNull(index) + val fulfillmentPercent = result?.fulfillmentPercent ?: 0 + val explanation = result?.explanation ?: "AI model did not return a result for this assertion" + AiAssertionResult( + assertionPrompt = condition.assertionPrompt, + requiredFulfillmentPercent = condition.requiredFulfillmentPercent, + failIfNotFulfilled = condition.failIfNotFulfilled, + fulfillmentPercent = fulfillmentPercent, + explanation = explanation, + ) + } +} + +private fun readByteArrayFromFile(filePath: String): ByteArray { + return SystemFileSystem.source(path = Path(filePath)).buffered().readByteArray() +} + +@OptIn(ExperimentalEncodingApi::class) +private fun ByteArray.encodeBase64(): String { + return Base64.encode(this) +} + +// Request + +@Serializable +private data class ChatCompletionRequest( + val model: String, + val messages: List, + val temperature: Float, + @SerialName("max_tokens") val maxTokens: Int, + @SerialName("response_format") val responseFormat: ResponseFormat?, + val seed: Int, +) + +@Serializable +private data class ResponseFormat( + val type: String, + @SerialName("json_schema") val jsonSchema: JsonObject +) + +@Serializable +private data class Message( + val role: String, + val content: List +) + +@Serializable +private data class Content( + val type: String, + val text: String? = null, + @SerialName("image_url") val imageUrl: ImageUrl? = null +) + +@Serializable +private data class ImageUrl( + val url: String +) + +@Serializable +private data class ChatCompletionResponse( + val id: String, + val `object`: String, + val created: Long, + val model: String, + val choices: List, + val usage: Usage? = null +) + +@Serializable +private data class Choice( + val index: Int, + val message: ChoiceMessage, + @SerialName("finish_reason") val finishReason: String? = null, +) + +@Serializable +private data class ChoiceMessage( + val role: String, + val content: String +) + +@Serializable +private data class Usage( + @SerialName("prompt_tokens") val promptTokens: Int, + @SerialName("completion_tokens") val completionTokens: Int? = null, + @SerialName("total_tokens") val totalTokens: Int, +) + + +// Response + +@Serializable +private data class OpenAiResponse( + val results: List +) + +@Serializable +private data class OpenAiConditionResult( + @SerialName("fulfillment_percent") + val fulfillmentPercent: Int, + val explanation: String?, +) \ No newline at end of file diff --git a/roborazzi-compose-desktop/build.gradle b/roborazzi-compose-desktop/build.gradle index 8f09b073d..55839ca27 100644 --- a/roborazzi-compose-desktop/build.gradle +++ b/roborazzi-compose-desktop/build.gradle @@ -13,7 +13,6 @@ kotlin { compilations .named("main") .configure { compilation -> - compilation.kotlinOptions.freeCompilerArgs += "-Xcontext-receivers" } } sourceSets { 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 0f468e774..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 @@ -2,20 +2,22 @@ package io.github.takahirom.roborazzi import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toAwtImage -import androidx.compose.ui.test.DesktopComposeUiTest -import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.captureToImage import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp -import com.github.takahirom.roborazzi.* +import com.github.takahirom.roborazzi.AwtRoboCanvas +import com.github.takahirom.roborazzi.DefaultFileNameGenerator +import com.github.takahirom.roborazzi.RoboCanvas +import com.github.takahirom.roborazzi.RoborazziOptions +import com.github.takahirom.roborazzi.fileWithRecordFilePathStrategy +import com.github.takahirom.roborazzi.processOutputImageAndReport +import com.github.takahirom.roborazzi.provideRoborazziContext import java.awt.image.BufferedImage import java.io.File -context(DesktopComposeUiTest) -@OptIn(ExperimentalTestApi::class) fun SemanticsNodeInteraction.captureRoboImage( - filePath: String = DefaultFileNameGenerator.generateFilePath("png"), + filePath: String = DefaultFileNameGenerator.generateFilePath(), roborazziOptions: RoborazziOptions = provideRoborazziContext().options, ) { if (!roborazziOptions.taskType.isEnabled()) { @@ -27,8 +29,6 @@ fun SemanticsNodeInteraction.captureRoboImage( ) } -context(DesktopComposeUiTest) -@OptIn(ExperimentalTestApi::class) fun SemanticsNodeInteraction.captureRoboImage( file: File, roborazziOptions: RoborazziOptions @@ -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-ios/src/iosMain/kotlin/io/github/takahirom/roborazzi/RoborazziIos.kt b/roborazzi-compose-ios/src/iosMain/kotlin/io/github/takahirom/roborazzi/RoborazziIos.kt index f9054981d..f23c3a0e1 100644 --- a/roborazzi-compose-ios/src/iosMain/kotlin/io/github/takahirom/roborazzi/RoborazziIos.kt +++ b/roborazzi-compose-ios/src/iosMain/kotlin/io/github/takahirom/roborazzi/RoborazziIos.kt @@ -523,6 +523,7 @@ fun SemanticsNodeInteraction.captureRoboImage( actualFile = actualFilePath, goldenFile = goldenFilePath, timestampNs = getNanoTime(), + aiAssertionResults = null, contextData = emptyMap() ) writeJson(result, resultsDir, nameWithoutExtension) @@ -539,7 +540,8 @@ fun SemanticsNodeInteraction.captureRoboImage( actualFile = actualFilePath, goldenFile = goldenFilePath, timestampNs = getNanoTime(), - diffPercentage = Float.NaN, + diffPercentage = null, + aiAssertionResults = null, contextData = emptyMap() ) writeJson(result, resultsDir, nameWithoutExtension) @@ -566,6 +568,7 @@ fun SemanticsNodeInteraction.captureRoboImage( actualFile = actualFilePath, goldenFile = goldenFilePath, timestampNs = getNanoTime(), + aiAssertionResults = null, contextData = emptyMap() ) writeJson(result, resultsDir, nameWithoutExtension) @@ -582,7 +585,8 @@ fun SemanticsNodeInteraction.captureRoboImage( actualFile = actualFilePath, goldenFile = goldenFilePath, timestampNs = getNanoTime(), - diffPercentage = Float.NaN, + diffPercentage = null, + aiAssertionResults = null, contextData = emptyMap() ) writeJson(result, resultsDir, nameWithoutExtension) @@ -609,6 +613,7 @@ fun SemanticsNodeInteraction.captureRoboImage( actualFile = goldenFilePath, goldenFile = goldenFilePath, timestampNs = getNanoTime(), + aiAssertionResults = null, contextData = emptyMap() ) writeJson(result, resultsDir, nameWithoutExtension) @@ -625,7 +630,8 @@ fun SemanticsNodeInteraction.captureRoboImage( actualFile = goldenFilePath, goldenFile = goldenFilePath, timestampNs = getNanoTime(), - diffPercentage = Float.NaN, + diffPercentage = null, + aiAssertionResults = null, contextData = emptyMap() ) writeJson(result, resultsDir, nameWithoutExtension) @@ -652,6 +658,7 @@ fun SemanticsNodeInteraction.captureRoboImage( actualFile = goldenFilePath, goldenFile = goldenFilePath, timestampNs = getNanoTime(), + aiAssertionResults = null, contextData = emptyMap() ) writeJson(result, resultsDir, nameWithoutExtension) @@ -668,7 +675,8 @@ fun SemanticsNodeInteraction.captureRoboImage( actualFile = goldenFilePath, goldenFile = goldenFilePath, timestampNs = getNanoTime(), - diffPercentage = Float.NaN, + diffPercentage = null, + aiAssertionResults = null, contextData = emptyMap() ) writeJson(result, resultsDir, nameWithoutExtension) diff --git a/roborazzi-compose-preview-scanner-support/src/main/java/com/github/takahirom/roborazzi/RobolectricPreviewInfosApplier.kt b/roborazzi-compose-preview-scanner-support/src/main/java/com/github/takahirom/roborazzi/RobolectricPreviewInfosApplier.kt index fb8297132..e606c0d4c 100644 --- a/roborazzi-compose-preview-scanner-support/src/main/java/com/github/takahirom/roborazzi/RobolectricPreviewInfosApplier.kt +++ b/roborazzi-compose-preview-scanner-support/src/main/java/com/github/takahirom/roborazzi/RobolectricPreviewInfosApplier.kt @@ -4,12 +4,23 @@ import android.content.res.Configuration import org.robolectric.RuntimeEnvironment.setFontScale import org.robolectric.RuntimeEnvironment.setQualifiers import sergio.sastre.composable.preview.scanner.android.AndroidPreviewInfo +import sergio.sastre.composable.preview.scanner.android.device.domain.RobolectricDeviceQualifierBuilder import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview @ExperimentalRoborazziApi fun ComposablePreview.applyToRobolectricConfiguration() { val preview = this + fun setDevice(device: String){ + if (device.isNotBlank()) { + // Requires `io.github.sergio-sastre.ComposablePreviewScanner:android:0.4.0` or later + RobolectricDeviceQualifierBuilder.build(device)?.run { + setQualifiers(this) + } + } + } + setDevice(preview.previewInfo.device) + fun setUiMode(uiMode: Int) { val nightMode = when (uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES) { 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-idea-plugin/src/main/kotlin/com/github/takahirom/roborazzi/idea/settings/AppSettingsComponent.kt b/roborazzi-idea-plugin/src/main/kotlin/com/github/takahirom/roborazzi/idea/settings/AppSettingsComponent.kt index eb7636e0b..ddc1c1e78 100644 --- a/roborazzi-idea-plugin/src/main/kotlin/com/github/takahirom/roborazzi/idea/settings/AppSettingsComponent.kt +++ b/roborazzi-idea-plugin/src/main/kotlin/com/github/takahirom/roborazzi/idea/settings/AppSettingsComponent.kt @@ -3,26 +3,46 @@ package com.github.takahirom.roborazzi.idea.settings import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBTextField import com.intellij.util.ui.FormBuilder +import java.awt.Dimension +import javax.swing.Box +import javax.swing.BoxLayout import javax.swing.JComponent +import javax.swing.JLabel import javax.swing.JPanel +import javax.swing.JSeparator /** * Supports creating and managing a [JPanel] for the Settings Dialog. */ class AppSettingsComponent { - private val myMainPanel: JPanel - private val imagesPathFromModuleText: JBTextField = JBTextField() - - init { - myMainPanel = FormBuilder.createFormBuilder() - .addLabeledComponent(JBLabel("Enter images directory path from module: "), imagesPathFromModuleText, 1, false) - .addComponentFillVertically(JPanel(), 0) - .getPanel() - } + private val myMainPanel: JPanel + private val imagesPathFromModuleText: JBTextField = JBTextField() + + private val descriptionText = """ + To enable the display of Roborazzi tasks, please enable
+ Configure all Gradle tasks during Gradle Sync (this can make Gradle Sync slower) in the Settings | Experimental | Gradle. +""".trimIndent() + init { + myMainPanel = FormBuilder.createFormBuilder() + .addLabeledComponent( + JBLabel("Enter images directory path from module: "), + imagesPathFromModuleText, + 1, + false + ) + // adjust margin between components + .addVerticalGap(8) + .addComponent(createNoteSection()) + .addComponent(JBLabel(descriptionText).apply { + verticalAlignment = JBLabel.TOP + preferredSize = Dimension(400, 200) + }) + .addComponentFillVertically(JPanel(), 0) + .panel + } val panel: JPanel get() = myMainPanel - val preferredFocusedComponent: JComponent get() = imagesPathFromModuleText @@ -31,4 +51,20 @@ class AppSettingsComponent { set(newText) { imagesPathFromModuleText.setText(newText) } + + private fun createNoteSection(): JPanel { + val panel = JPanel() + panel.layout = BoxLayout(panel, BoxLayout.X_AXIS) + + val label = JLabel("Note") + val separator = JSeparator().apply { + alignmentY = 0f + } + + panel.add(label) + panel.add(Box.createHorizontalStrut(8)) + panel.add(separator) + + return panel + } } \ No newline at end of file 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 8be902e7c..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 @@ -28,6 +28,18 @@ class RoborazziRule private constructor( private val captureRoot: CaptureRoot, private val options: Options = Options() ) : TestWatcher() { + init { + try { + val clazz = Class.forName("com.github.takahirom.roborazzi.RoborazziAi") + // RoborazziAi is available + clazz.getDeclaredMethod("loadRoboAi").invoke(null) + } catch (ignored: ClassNotFoundException) { + // RoborazziAi is not available + } catch (ignored: NoSuchMethodException) { + // RoborazziAi is not available + } + } + /** * If you add this annotation to the test, the test will be ignored by * roborazzi's CaptureType.LastImage, CaptureType.AllImage and CaptureType.Gif. @@ -199,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 = @@ -220,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-painter/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/DifferBufferedImage.kt b/roborazzi-painter/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/DifferBufferedImage.kt index 922975e28..30d00c789 100644 --- a/roborazzi-painter/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/DifferBufferedImage.kt +++ b/roborazzi-painter/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/DifferBufferedImage.kt @@ -15,6 +15,11 @@ internal class DifferBufferedImage(private val bufferedImage: BufferedImage) : I // Waiting for dropbox differs next release to support size difference return Color(0, 0, 0, 0) } - return Color(bufferedImage.getRGB(x, y)) + val color = Color(bufferedImage.getRGB(x, y)) + if (color.a == 0F) { + // I'm not sure why, but WebP images return r = 1, g = 0, b = 0, a = 0 for transparent pixels. + return Color(0, 0, 0, 0) + } + return color } } \ No newline at end of file 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 4a7f8e7bf..6a967196a 100644 --- a/sample-android/build.gradle +++ b/sample-android/build.gradle @@ -4,6 +4,14 @@ plugins { id 'io.github.takahirom.roborazzi' } +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withInputStream { instream -> + localProperties.load(instream) + } +} + android { namespace 'com.github.takahirom.roborazzi.sample' compileSdk 34 @@ -35,6 +43,12 @@ android { includeAndroidResources = true it.all { environment "robolectric.logging.enabled", "true" + if (localProperties.getProperty("gemini_api_key") != null) { + environment "gemini_api_key", localProperties.getProperty("gemini_api_key") + } + if (localProperties.getProperty("openai_api_key") != null) { + environment "openai_api_key", localProperties.getProperty("openai_api_key") + } // To run coverage report in Android Studio jvmArgs '-noverify' // Set the max heap size for the tests JVM(s) @@ -46,6 +60,8 @@ android { dependencies { testImplementation project(":roborazzi") + testImplementation project(":roborazzi-ai-gemini") + testImplementation(project(":roborazzi-ai-openai")) testImplementation project(":roborazzi-compose") testImplementation project(":roborazzi-junit-rule") @@ -55,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/GeminiAiAiTest.kt b/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/GeminiAiAiTest.kt new file mode 100644 index 000000000..4e04d0227 --- /dev/null +++ b/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/GeminiAiAiTest.kt @@ -0,0 +1,74 @@ +package com.github.takahirom.roborazzi.sample + +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.AiAssertionOptions +import com.github.takahirom.roborazzi.DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH +import com.github.takahirom.roborazzi.GeminiAiAssertionModel +import com.github.takahirom.roborazzi.ROBORAZZI_DEBUG +import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import com.github.takahirom.roborazzi.RoborazziOptions +import com.github.takahirom.roborazzi.RoborazziRule +import com.github.takahirom.roborazzi.RoborazziTaskType +import com.github.takahirom.roborazzi.captureRoboImage +import com.github.takahirom.roborazzi.provideRoborazziContext +import com.github.takahirom.roborazzi.roboOutputName +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 GeminiAiAiTest { + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @get:Rule + val roborazziRule = RoborazziRule( + options = RoborazziRule.Options( + roborazziOptions = RoborazziOptions( + taskType = RoborazziTaskType.Compare, + compareOptions = RoborazziOptions.CompareOptions( + aiAssertionOptions = AiAssertionOptions( + aiAssertionModel = GeminiAiAssertionModel( + apiKey = System.getenv("gemini_api_key") ?: "" + ), + ) + ) + ) + ) + ) + + @Test + fun captureWithAi() { + ROBORAZZI_DEBUG = true + if (System.getenv("gemini_api_key") == null) { + println("Skip the test because gemini_api_key is not set.") + return + } + File(DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH + File.separator + roboOutputName() + ".png").delete() + onView(ViewMatchers.isRoot()) + .captureRoboImage( + roborazziOptions = provideRoborazziContext().options.addedAiAssertions( + AiAssertionOptions.AiAssertion( + assertionPrompt = "it should have PREVIOUS button", + requiredFulfillmentPercent = 90, + ), + AiAssertionOptions.AiAssertion( + assertionPrompt = "it should show First Fragment", + requiredFulfillmentPercent = 90, + ) + ) + ) + File(DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH + File.separator + roboOutputName() + "_compare.png").delete() + } +} \ No newline at end of file diff --git a/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/ManualTest.kt b/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/ManualTest.kt index 7bd95b6ea..1f7774e7a 100644 --- a/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/ManualTest.kt +++ b/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/ManualTest.kt @@ -164,7 +164,8 @@ class ManualTest { @Test fun captureRoboImageSampleWithQuery() { - val filePath = "${roborazziSystemPropertyOutputDirectory()}/manual_view_first_screen_with_query_view.png" + val filePath = + "${roborazziSystemPropertyOutputDirectory()}/manual_view_first_screen_with_query_view.png" onView(ViewMatchers.isRoot()) .captureRoboImage( filePath = filePath, diff --git a/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/OpenAiTest.kt b/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/OpenAiTest.kt new file mode 100644 index 000000000..ae9deb929 --- /dev/null +++ b/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/OpenAiTest.kt @@ -0,0 +1,75 @@ +package com.github.takahirom.roborazzi.sample + +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.AiAssertionOptions +import com.github.takahirom.roborazzi.DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH +import com.github.takahirom.roborazzi.OpenAiAiAssertionModel +import com.github.takahirom.roborazzi.ROBORAZZI_DEBUG +import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import com.github.takahirom.roborazzi.RoborazziOptions +import com.github.takahirom.roborazzi.RoborazziRule +import com.github.takahirom.roborazzi.RoborazziTaskType +import com.github.takahirom.roborazzi.captureRoboImage +import com.github.takahirom.roborazzi.provideRoborazziContext +import com.github.takahirom.roborazzi.roboOutputName +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 OpenAiTest { + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @get:Rule + val roborazziRule = RoborazziRule( + options = RoborazziRule.Options( + roborazziOptions = RoborazziOptions( + taskType = RoborazziTaskType.Compare, + compareOptions = RoborazziOptions.CompareOptions( + aiAssertionOptions = AiAssertionOptions( + aiAssertionModel = OpenAiAiAssertionModel( + apiKey = System.getenv("openai_api_key").orEmpty(), + modelName = "gpt-4o", + ), + ) + ) + ) + ) + ) + + @Test + fun captureWithAi() { + ROBORAZZI_DEBUG = true + if (System.getenv("openai_api_key") == null) { + println("Skip the test because openai_api_key is not set.") + return + } + File(DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH + File.separator + roboOutputName() + ".png").delete() + onView(ViewMatchers.isRoot()) + .captureRoboImage( + roborazziOptions = provideRoborazziContext().options.addedAiAssertions( + AiAssertionOptions.AiAssertion( + assertionPrompt = "it should have PREVIOUS button", + requiredFulfillmentPercent = 90, + ), + AiAssertionOptions.AiAssertion( + assertionPrompt = "it should show First Fragment", + requiredFulfillmentPercent = 90, + ) + ) + ) + File(DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH + File.separator + roboOutputName() + "_compare.png").delete() + } +} \ No newline at end of file diff --git a/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/boxed/AiManualTest.kt b/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/boxed/AiManualTest.kt new file mode 100644 index 000000000..dedfb9488 --- /dev/null +++ b/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/boxed/AiManualTest.kt @@ -0,0 +1,97 @@ +package com.github.takahirom.roborazzi.sample.boxed + +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.AiAssertionOptions +import com.github.takahirom.roborazzi.AiAssertionResult +import com.github.takahirom.roborazzi.AiAssertionResults +import com.github.takahirom.roborazzi.DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH +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.captureRoboImage +import com.github.takahirom.roborazzi.roboOutputName +import com.github.takahirom.roborazzi.sample.MainActivity +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 AiManualTest { + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test( + expected = AssertionError::class + ) + fun whenAiReturnError() { + boxedEnvironment { + try { + ROBORAZZI_DEBUG = true + composeTestRule.onRoot() + .captureRoboImage( + roborazziOptions = createOptionsFulfillmentPercent(0) + ) + }finally { + File(DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH +File.separator+ roboOutputName() + "_compare.png").delete() + } + } + } + + @Test + fun whenAiReturnSuccess() { + boxedEnvironment { + ROBORAZZI_DEBUG = true + composeTestRule.onRoot() + .captureRoboImage( + roborazziOptions = createOptionsFulfillmentPercent(100) + ) + File(DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH + File.separator + roboOutputName() + "_compare.png").delete() + } + } + + private fun createOptionsFulfillmentPercent(fulfillmentPercent: Int) = RoborazziOptions( + // Even compare task, it will be failed because of the manual AI model. + taskType = RoborazziTaskType.Compare, + compareOptions = RoborazziOptions.CompareOptions( + aiAssertionOptions = AiAssertionOptions( + aiAssertionModel = object : AiAssertionOptions.AiAssertionModel { + override fun assert( + referenceImageFilePath: String, + comparisonImageFilePath: String, + actualImageFilePath: String, + aiAssertionOptions: AiAssertionOptions + ): AiAssertionResults { + return AiAssertionResults( + aiAssertionResults = aiAssertionOptions.aiAssertions.map { assertion -> + AiAssertionResult( + assertionPrompt = assertion.assertionPrompt, + fulfillmentPercent = fulfillmentPercent, + requiredFulfillmentPercent = assertion.requiredFulfillmentPercent, + failIfNotFulfilled = assertion.failIfNotFulfilled, + explanation = "This is a manual test.", + ) + } + ) + } + }, + aiAssertions = listOf( + AiAssertionOptions.AiAssertion( + assertionPrompt = "it should have PREVIOUS button", + requiredFulfillmentPercent = 90, + ), + ), + ) + ) + ) +} \ No newline at end of file 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..657a08b4a --- /dev/null +++ b/sample-android/src/test/java/com/github/takahirom/roborazzi/sample/boxed/LosslessWebpTest.kt @@ -0,0 +1,216 @@ +package com.github.takahirom.roborazzi.sample.boxed + +import android.widget.TextView +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +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.LosslessWebPImageIoFormat +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.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 whenCompareSameImageWithGradientsTheCompareImageShouldNotBeGenerated() { + 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 { + val canvasUi = @Composable { + // Show Canvas transparent gradient + Canvas(modifier = Modifier.fillMaxSize()) { + drawRect( + color = Color(0xFF000000), + topLeft = Offset(0f, 0f), + size = Size(100f, 100f) + ) + drawRect( + brush = Brush.verticalGradient( + colors = listOf(Color(0xFF000000), Color(0x00000000)), + startY = 0f, + endY = 100f + ), + topLeft = Offset(100f, 0f), + size = Size(100f, 100f) + ) + // Color patterns + drawRect( + brush = Brush.horizontalGradient( + colors = listOf(Color(0xFFFF0000), Color(0x00000000)), + startX = 0f, + endX = 100f + ), + topLeft = Offset(0f, 100f), + size = Size(100f, 100f) + ) + drawRect( + brush = Brush.linearGradient( + colors = listOf(Color(0xFF00FF00), Color(0x00000000)), + start = Offset(100f, 100f), + end = Offset(200f, 200f) + ), + topLeft = Offset(100f, 100f), + size = Size(100f, 100f) + ) + drawRect( + brush = Brush.radialGradient( + colors = listOf(Color(0xFF0000FF), Color(0x00000000)), + center = Offset(150f, 150f), + radius = 50f + ), + topLeft = Offset(100f, 100f), + size = Size(100f, 100f) + ) + } + } + captureRoboImage( + filePath = expectedOutput.absolutePath, + roborazziOptions = RoborazziOptions( + taskType = RoborazziTaskType.Record, + recordOptions = recordOptions + ) + ) { + canvasUi() + } + DefaultFileNameGenerator.reset() + captureRoboImage( + filePath = expectedOutput.absolutePath, + roborazziOptions = RoborazziOptions( + taskType = RoborazziTaskType.Compare, + recordOptions = recordOptions + ), + ) { + canvasUi() + } + 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() + } + } + } +} diff --git a/sample-compose-desktop-jvm/build.gradle.kts b/sample-compose-desktop-jvm/build.gradle.kts index 5203802b2..b43a9a257 100644 --- a/sample-compose-desktop-jvm/build.gradle.kts +++ b/sample-compose-desktop-jvm/build.gradle.kts @@ -39,6 +39,5 @@ compose.desktop { tasks.withType().configureEach { kotlinOptions { incremental = false - freeCompilerArgs += "-Xcontext-receivers" } } \ No newline at end of file diff --git a/sample-compose-desktop-multiplatform/build.gradle.kts b/sample-compose-desktop-multiplatform/build.gradle.kts index 8e0c39660..afd3d7296 100644 --- a/sample-compose-desktop-multiplatform/build.gradle.kts +++ b/sample-compose-desktop-multiplatform/build.gradle.kts @@ -12,9 +12,6 @@ version = "1.0-SNAPSHOT" kotlin { jvm("desktop") { - val test by compilations.getting { - kotlinOptions.freeCompilerArgs += "-Xcontext-receivers" - } } sourceSets { val desktopMain by getting { @@ -49,6 +46,5 @@ compose.desktop { tasks.withType().configureEach { kotlinOptions { - freeCompilerArgs += "-Xcontext-receivers" } } \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 20e68d9aa..76a15c9b9 100644 --- a/settings.gradle +++ b/settings.gradle @@ -12,12 +12,14 @@ dependencyResolutionManagement { mavenCentral() } } -rootProject.name = "roborazzi" +rootProject.name = "roborazzi-root" include ':roborazzi' include ':roborazzi-junit-rule' include ':roborazzi-compose-desktop' include ':roborazzi-compose-ios' include ':roborazzi-compose' +include ':roborazzi-ai-gemini' +include ':roborazzi-ai-openai' include ':roborazzi-painter' include ':roborazzi-compose-preview-scanner-support'