")
-
- 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'
|