Repository: takahirom/roborazzi
Pull request #427 (2024-07-09 03:02:27)

Add roborazzi-compose-preview-scanner-support module to handle preview settings

github-actions[bot]: Snapshot diff report
| File name | Image |
|-------|-------|
| [PreviewWithPropertie<br>s.Preview_Name_Previ<br>ew_Group_API_LEVEL_3<br>0_W320dp_H640dp_ja_F<br>ONT_1_5f_WITH_SYSTEM<br>_UI_WITH_BACKGROUND_<br>BG_COLOR_4278190335_<br>NIGHT_NEXUS_5_WALLPA<br>PER_GREEN_DOMINATED_<br>compare.png](https://github.com/takahirom/roborazzi/blob/companion_takahirom/add-roborazzi-compose-preview-scanner-support/2024-07-09/./screenshot-diff/sample-generate-preview-tests/build/outputs/roborazzi/PreviewWithProperties.Preview_Name_Preview_Group_API_LEVEL_30_W320dp_H640dp_ja_FONT_1_5f_WITH_SYSTEM_UI_WITH_BACKGROUND_BG_COLOR_4278190335_NIGHT_NEXUS_5_WALLPAPER_GREEN_DOMINATED_compare.png) | ![](https://github.com/takahirom/roborazzi/blob/companion_takahirom/add-roborazzi-compose-preview-scanner-support/2024-07-09/./screenshot-diff/sample-generate-preview-tests/build/outputs/roborazzi/PreviewWithProperties.Preview_Name_Preview_Group_API_LEVEL_30_W320dp_H640dp_ja_FONT_1_5f_WITH_SYSTEM_UI_WITH_BACKGROUND_BG_COLOR_4278190335_NIGHT_NEXUS_5_WALLPAPER_GREEN_DOMINATED_compare.png?raw=true) |
| [Preview2_compare.png<br>](https://github.com/takahirom/roborazzi/blob/companion_takahirom/add-roborazzi-compose-preview-scanner-support/2024-07-09/./screenshot-diff/sample-generate-preview-tests/build/outputs/roborazzi/Preview2_compare.png) | ![](https://github.com/takahirom/roborazzi/blob/companion_takahirom/add-roborazzi-compose-preview-scanner-support/2024-07-09/./screenshot-diff/sample-generate-preview-tests/build/outputs/roborazzi/Preview2_compare.png?raw=true) |
takahirom: I've added [the README section](https://github.com/takahirom/roborazzi/pull/427/commits/ad68387c385e3f10328283f4807396dedb1d9079).

sergio-sastre: > Snapshot diff report
> 
> File name	Image
> [PreviewNormal_compare.png](https://github.com/takahirom/roborazzi/blob/companion_takahirom/add-roborazzi-compose-preview-scanner-support/2024-07-09/./screenshot-diff/sample-generate-preview-tests/build/outputs/roborazzi/PreviewNormal_compare.png)	![](https://github.com/takahirom/roborazzi/blob/companion_takahirom/add-roborazzi-compose-preview-scanner-support/2024-07-09/./screenshot-diff/sample-generate-preview-tests/build/outputs/roborazzi/PreviewNormal_compare.png?raw=true)
> [PreviewWithProperties.Preview_Name_Preview_Group_API_LEVEL_30_W320dp_H640dp_ja-rJP_FONT_1_5f_WITH_SYSTEM_UI_WITH_BACKGROUND_BG_COLOR_4278190335_NIGHT_NEXUS_5_WALLPAPER_GREEN_DOMINATED_compare.png](https://github.com/takahirom/roborazzi/blob/companion_takahirom/add-roborazzi-compose-preview-scanner-support/2024-07-09/./screenshot-diff/sample-generate-preview-tests/build/outputs/roborazzi/PreviewWithProperties.Preview_Name_Preview_Group_API_LEVEL_30_W320dp_H640dp_ja-rJP_FONT_1_5f_WITH_SYSTEM_UI_WITH_BACKGROUND_BG_COLOR_4278190335_NIGHT_NEXUS_5_WALLPAPER_GREEN_DOMINATED_compare.png)	![](https://github.com/takahirom/roborazzi/blob/companion_takahirom/add-roborazzi-compose-preview-scanner-support/2024-07-09/./screenshot-diff/sample-generate-preview-tests/build/outputs/roborazzi/PreviewWithProperties.Preview_Name_Preview_Group_API_LEVEL_30_W320dp_H640dp_ja-rJP_FONT_1_5f_WITH_SYSTEM_UI_WITH_BACKGROUND_BG_COLOR_4278190335_NIGHT_NEXUS_5_WALLPAPER_GREEN_DOMINATED_compare.png?raw=true)
> [PreviewDarkMode.NIGHT_compare.png](https://github.com/takahirom/roborazzi/blob/companion_takahirom/add-roborazzi-compose-preview-scanner-support/2024-07-09/./screenshot-diff/sample-generate-preview-tests/build/outputs/roborazzi/PreviewDarkMode.NIGHT_compare.png)	![](https://github.com/takahirom/roborazzi/blob/companion_takahirom/add-roborazzi-compose-preview-scanner-support/2024-07-09/./screenshot-diff/sample-generate-preview-tests/build/outputs/roborazzi/PreviewDarkMode.NIGHT_compare.png?raw=true)

Minor:
I wonder what would be the name of the screenshot files if:
- A Preview with the same test name and preview parameters  is placed in another folder... Would be overwritten?
takahirom: @sergio-sastre 

> A Preview with the same test name and preview parameters is placed in another folder... Would be overwritten?

Yes, currently it is. Recently, I found that the Compose Preview Testing by Google has made a good approach to this( New format: pkg/class/method_suffix.png ). I think we can add it to roborazzi.record.filePathStrategy, but the issue is that we are creating a path for the preview in the DefaultRobolectricPreviewTest. To implement this, it seems we would need to obtain the package name and class name.
https://android.googlesource.com/platform/tools/base/+/565124e60ced74eb5ad4274377abc60a7283a34c

sergio-sastre: > @sergio-sastre
> 
> > A Preview with the same test name and preview parameters is placed in another folder... Would be overwritten?
> 
> Yes, currently it is. Recently, I found that the Compose Preview Screenshot Testing by Google has made a good approach to this( New format: pkg/class/method_suffix.png ). I think we can add it to roborazzi.record.filePathStrategy, but the issue is that we are creating a path for the preview in the DefaultRobolectricPreviewTest. To implement this, it seems we would need to obtain the package name and class name. https://android.googlesource.com/platform/tools/base/+/565124e60ced74eb5ad4274377abc60a7283a34c
> 
> We might have to be able to use the filePathStrategy for the preview.

All good. I suggest to document it is a known issue, and tackle it in one of the next releases
takahirom: ![image](https://github.com/takahirom/roborazzi/assets/1386930/8495a680-a622-4c55-8e06-1fa419f9cee8)
Now it is using `roborazzi.record.namingStrategy` for naming. So if we add a new strategy we can use it.
https://github.com/takahirom/roborazzi/pull/427/commits/f088830f47a40cc2697547712d4f42f1f358f318
https://github.com/takahirom/roborazzi?tab=readme-ov-file#roborazzirecordnamingstrategy
takahirom: I also changed the naming of `generateRobolectricPreviewTests` to `generateRobolectric**Compose**PreviewTests`.
We have Test Preview in our IDE plugin, and it would be a bit confusing otherwise.
sergio-sastre: > ![image](https://private-user-images.githubusercontent.com/1386930/347646522-8495a680-a622-4c55-8e06-1fa419f9cee8.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MjA2NzMwNDQsIm5iZiI6MTcyMDY3Mjc0NCwicGF0aCI6Ii8xMzg2OTMwLzM0NzY0NjUyMi04NDk1YTY4MC1hNjIyLTRjNTUtOGUwNi0xZmE0MTlmOWNlZTgucG5nP1gtQW16LUFsZ29yaXRobT1BV1M0LUhNQUMtU0hBMjU2JlgtQW16LUNyZWRlbnRpYWw9QUtJQVZDT0RZTFNBNTNQUUs0WkElMkYyMDI0MDcxMSUyRnVzLWVhc3QtMSUyRnMzJTJGYXdzNF9yZXF1ZXN0JlgtQW16LURhdGU9MjAyNDA3MTFUMDQzOTA0WiZYLUFtei1FeHBpcmVzPTMwMCZYLUFtei1TaWduYXR1cmU9Y2JiYzgyYjIwMjFlODVmODYyNDQ2ZWIyYzBiMWJmYjYyNDU4MWQzZjhkZmVmMTI3Yzc1Mjc0Zjc0ZjM0YWM1YyZYLUFtei1TaWduZWRIZWFkZXJzPWhvc3QmYWN0b3JfaWQ9MCZrZXlfaWQ9MCZyZXBvX2lkPTAifQ.D3E2ryj0avy6SWnfknXSU-Xx0Rpoc9bCyS5Go3DsbY4) Now it is using `roborazzi.record.namingStrategy` for naming. So if we add a new strategy we can use it. [f088830](https://github.com/takahirom/roborazzi/commit/f088830f47a40cc2697547712d4f42f1f358f318) https://github.com/takahirom/roborazzi?tab=readme-ov-file#roborazzirecordnamingstrategy

Looks good! 
I assume record naming strategy is the same for verifying right?

Just as remark: if the package name is too long it can lead to file not found errors/long name

I believe it is worth mentioning, in that case the user should choose another naming strategy

In my opinion, the first version looks ready 👍
takahirom: I'll release this. 
But I'll rename generateRobolectricComposePreviewTests to generateComposePreviewRobolectricTests.

Patch:
diff --git a/README.md b/README.md
index 5d1ed66c..e99f478c 100644
--- a/README.md
+++ b/README.md
@@ -944,6 +944,77 @@ 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)
 
+</div>
+<div name="topic_preview_support">
+
+<!-- Generated by docs/topics/preview_support.md. Do not edit this file. -->
+# Compose Preview Support
+
+Roborazzi provides support for generating screenshot tests and easy setup for Jetpack Compose Preview.
+This support uses [ComposePreviewScanner](https://github.com/sergio-sastre/ComposablePreviewScanner) to scan the Composable Previews in your project.
+
+## Generate Compose Preview screenshot tests
+
+You first need to add the Roborazzi plugin to your project. Please refer to the [setup guide](https://takahirom.github.io/roborazzi/build-setup.html) for more information.
+Then you can enable the Compose Preview screenshot test generation feature by adding the following configuration to your `build.gradle.kts` file:
+
+```kotlin
+roborazzi {
+  generateComposePreviewRobolectricTests {
+    enable = true
+  }
+}
+```
+
+The plugin will not automatically change your settings or add dependencies to prevent conflicts with your existing setup. However, it will provide instructions on what to do next, such as adding dependencies and required code.
+
+After that, you can run the `recordRoborazziDebug` task to generate screenshots using the generated tests, as described in the [setup guide](https://takahirom.github.io/roborazzi/build-setup.html).
+
+### Customizing the Preview screenshot test
+
+You can customize the generated test by adding the following configuration to your `build.gradle` file:
+
+```kotlin
+roborazzi {
+  generateComposePreviewRobolectricTests {
+    enable = true
+    // The package names to scan for Composable Previews.
+    packages = listOf("com.example")
+    // The fully qualified class name of the custom test class that implements [com.github.takahirom.roborazzi.RobolectricPreviewTest].
+    customTestQualifiedClassName = "com.example.MyCustomRobolectricPreviewTest"
+    // robolectricConfig will be passed to Robolectric's @Config annotation in the generated test class.
+    // See https://robolectric.org/configuring/ for more information.
+    robolectricConfig = mapOf(
+      "sdk" to "[32]",
+      "qualifiers" to "RobolectricDeviceQualifiers.Pixel5",
+    )
+  }
+}
+```
+
+## Manually adding Compose Preview screenshot tests
+
+Roborazzi provides a helper function for ComposePreviewScanner.
+You can add the following dependency to your project to use the helper function:
+
+`testImplementation("io.github.takahirom.roborazzi:roborazzi-compose-preview-scanner-support:[version]")`
+
+Then you can use the `ComposablePreview<AndroidPreviewInfo>.captureRoboImage()` function to capture the Composable Preview using the settings in Preview annotations.
+To obtain the `ComposablePreview` object, please refer to [ComposePreviewScanner](https://github.com/sergio-sastre/ComposablePreviewScanner).
+
+```kotlin
+fun ComposablePreview<AndroidPreviewInfo>.captureRoboImage(
+  filePath: String,
+  roborazziOptions: RoborazziOptions
+)
+```
+
+### The supported `@Preview` annotation options
+
+Currently, we don't support all the annotation options provided by the Compose Preview.
+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.
+
 </div>
 <div name="topic_idea_plugin">
 
diff --git a/README.template.md b/README.template.md
index ba49aafa..84bbd4d9 100644
--- a/README.template.md
+++ b/README.template.md
@@ -7,6 +7,8 @@
 </div>
 <div name="topic_how_to_use">
 </div>
+<div name="topic_preview_support">
+</div>
 <div name="topic_idea_plugin">
 </div>
 <div name="topic_compose_multiplatform">
diff --git a/build.gradle b/build.gradle
index b83676c2..714484d8 100644
--- a/build.gradle
+++ b/build.gradle
@@ -83,6 +83,14 @@ allprojects {
       }
     }
   }
+
+  configurations.all {
+    resolutionStrategy {
+      dependencySubstitution {
+        substitute(module("io.github.takahirom.roborazzi:roborazzi-compose-preview-scanner-support")).using(project(":roborazzi-compose-preview-scanner-support"))
+      }
+    }
+  }
 }
 
 class Topic {
diff --git a/docs/roborazzi-docs.tree b/docs/roborazzi-docs.tree
index c4e8d197..30e3e59b 100644
--- a/docs/roborazzi-docs.tree
+++ b/docs/roborazzi-docs.tree
@@ -10,6 +10,7 @@
     <toc-element topic="try_it_out.md"/>
     <toc-element topic="build_setup.md"/>
     <toc-element topic="how_to_use.md"/>
+    <toc-element topic="preview_support.md"/>
     <toc-element topic="idea_plugin.md"/>
     <toc-element topic="compose_multiplatform.md"/>
     <toc-element topic="gradle_properties_options.md"/>
diff --git a/docs/topics/preview_support.md b/docs/topics/preview_support.md
new file mode 100644
index 00000000..d37c11bb
--- /dev/null
+++ b/docs/topics/preview_support.md
@@ -0,0 +1,66 @@
+# Compose Preview Support
+
+Roborazzi provides support for generating screenshot tests and easy setup for Jetpack Compose Preview.
+This support uses [ComposePreviewScanner](https://github.com/sergio-sastre/ComposablePreviewScanner) to scan the Composable Previews in your project.
+
+## Generate Compose Preview screenshot tests
+
+You first need to add the Roborazzi plugin to your project. Please refer to the [setup guide](https://takahirom.github.io/roborazzi/build-setup.html) for more information.
+Then you can enable the Compose Preview screenshot test generation feature by adding the following configuration to your `build.gradle.kts` file:
+
+```kotlin
+roborazzi {
+  generateComposePreviewRobolectricTests {
+    enable = true
+  }
+}
+```
+
+The plugin will not automatically change your settings or add dependencies to prevent conflicts with your existing setup. However, it will provide instructions on what to do next, such as adding dependencies and required code.
+
+After that, you can run the `recordRoborazziDebug` task to generate screenshots using the generated tests, as described in the [setup guide](https://takahirom.github.io/roborazzi/build-setup.html).
+
+### Customizing the Preview screenshot test
+
+You can customize the generated test by adding the following configuration to your `build.gradle` file:
+
+```kotlin
+roborazzi {
+  generateComposePreviewRobolectricTests {
+    enable = true
+    // The package names to scan for Composable Previews.
+    packages = listOf("com.example")
+    // The fully qualified class name of the custom test class that implements [com.github.takahirom.roborazzi.RobolectricPreviewTest].
+    customTestQualifiedClassName = "com.example.MyCustomRobolectricPreviewTest"
+    // robolectricConfig will be passed to Robolectric's @Config annotation in the generated test class.
+    // See https://robolectric.org/configuring/ for more information.
+    robolectricConfig = mapOf(
+      "sdk" to "[32]",
+      "qualifiers" to "RobolectricDeviceQualifiers.Pixel5",
+    )
+  }
+}
+```
+
+## Manually adding Compose Preview screenshot tests
+
+Roborazzi provides a helper function for ComposePreviewScanner.
+You can add the following dependency to your project to use the helper function:
+
+`testImplementation("io.github.takahirom.roborazzi:roborazzi-compose-preview-scanner-support:[version]")`
+
+Then you can use the `ComposablePreview<AndroidPreviewInfo>.captureRoboImage()` function to capture the Composable Preview using the settings in Preview annotations.
+To obtain the `ComposablePreview` object, please refer to [ComposePreviewScanner](https://github.com/sergio-sastre/ComposablePreviewScanner).
+
+```kotlin
+fun ComposablePreview<AndroidPreviewInfo>.captureRoboImage(
+  filePath: String,
+  roborazziOptions: RoborazziOptions
+)
+```
+
+### The supported `@Preview` annotation options
+
+Currently, we don't support all the annotation options provided by the Compose Preview.
+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.
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 07265670..85b0da26 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -55,6 +55,9 @@ 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" }
 
+# for sample
+composable-preview-scanner = { module = "com.github.sergio-sastre.ComposablePreviewScanner:android", version.ref = "composable-preview-scanner" }
+
 androidx-activity = { module = "androidx.activity:activity", version.ref = "androidx-activity" }
 androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
 androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
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 4abb6f65..5464bd99 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
@@ -11,6 +11,19 @@ object DefaultFileNameGenerator {
     EscapedTestPackageAndClassAndMethod("escapedTestPackageAndClassAndMethod"),
     TestClassAndMethod("testClassAndMethod");
 
+    @ExperimentalRoborazziApi
+    fun generateOutputName(className: String, methodName: String?): String {
+      return when (this) {
+        TestPackageAndClassAndMethod -> "$className.$methodName"
+        EscapedTestPackageAndClassAndMethod -> className.replace(
+          ".",
+          "_"
+        ) + "." + methodName
+
+        TestClassAndMethod -> className.substringAfterLast(".") + "." + methodName
+      }
+    }
+
     companion object {
       fun fromOptionName(optionName: String): DefaultNamingStrategy {
         return values().firstOrNull { it.optionName == optionName } ?: TestPackageAndClassAndMethod
@@ -89,7 +102,7 @@ object DefaultFileNameGenerator {
       }
       ?: throw IllegalArgumentException("Roborazzi can't find method of test. Please specify file name or use Rule")
     val testName =
-      generateOutputName(stackTraceElement.className, stackTraceElement.methodName)
+      defaultNamingStrategy.generateOutputName(stackTraceElement.className, stackTraceElement.methodName)
     return testName
   }
 
@@ -112,19 +125,8 @@ object DefaultFileNameGenerator {
   internal fun generateOutputNameWithDescription(description: Description): String {
     val className = description.className
     val methodName = description.methodName
-    val testName = generateOutputName(className, methodName)
+    val testName = defaultNamingStrategy.generateOutputName(className, methodName)
     return testName
   }
 
-  private fun generateOutputName(className: String, methodName: String?): String {
-    return when (defaultNamingStrategy) {
-      DefaultNamingStrategy.TestPackageAndClassAndMethod -> "$className.$methodName"
-      DefaultNamingStrategy.EscapedTestPackageAndClassAndMethod -> className.replace(
-        ".",
-        "_"
-      ) + "." + methodName
-
-      DefaultNamingStrategy.TestClassAndMethod -> className.substringAfterLast(".") + "." + methodName
-    }
-  }
 }
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 04396fbd..004a5caa 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
@@ -43,21 +43,16 @@ import kotlin.reflect.KClass
 private const val DEFAULT_OUTPUT_DIR = "outputs/roborazzi"
 private const val DEFAULT_TEMP_DIR = "intermediates/roborazzi"
 
-/**
- * Experimental API
- * This class can be changed without notice.
- */
 open class RoborazziExtension @Inject constructor(objects: ObjectFactory) {
   val outputDir: DirectoryProperty = objects.directoryProperty()
 
-  // Configuration based APIs
   @ExperimentalRoborazziApi
-  val generateRobolectricPreviewTests: GenerateRobolectricPreviewTestsExtension =
-    objects.newInstance(GenerateRobolectricPreviewTestsExtension::class.java)
+  val generateComposePreviewRobolectricTests: GenerateComposePreviewRobolectricTestsExtension =
+    objects.newInstance(GenerateComposePreviewRobolectricTestsExtension::class.java)
 
   @ExperimentalRoborazziApi
-  fun generateRobolectricPreviewTests(action: GenerateRobolectricPreviewTestsExtension.() -> Unit) {
-    action(generateRobolectricPreviewTests)
+  fun generateComposePreviewRobolectricTests(action: GenerateComposePreviewRobolectricTestsExtension.() -> Unit) {
+    action(generateComposePreviewRobolectricTests)
   }
 }
 
@@ -440,7 +435,7 @@ abstract class RoborazziPlugin : Plugin<Project> {
         generateRobolectricPreviewTestsIfNeeded(
           project = project,
           variant = variant,
-          extension = extension.generateRobolectricPreviewTests,
+          extension = extension.generateComposePreviewRobolectricTests,
           androidExtension = project.extensions.getByType(TestedExtension::class.java),
           testTaskProvider = findTestTaskProvider(Test::class, testTaskName)
         )
diff --git a/roborazzi-compose-preview-scanner-support/.gitignore b/roborazzi-compose-preview-scanner-support/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/roborazzi-compose-preview-scanner-support/.gitignore
@@ -0,0 +1,1 @@
+/build
\ No newline at end of file
diff --git a/roborazzi-compose-preview-scanner-support/build.gradle b/roborazzi-compose-preview-scanner-support/build.gradle
new file mode 100644
index 00000000..32947ef6
--- /dev/null
+++ b/roborazzi-compose-preview-scanner-support/build.gradle
@@ -0,0 +1,28 @@
+plugins {
+  id 'com.android.library'
+  id 'org.jetbrains.kotlin.android'
+}
+if (System.getenv("INTEGRATION_TEST") != "true") {
+  pluginManager.apply("com.vanniktech.maven.publish")
+}
+repositories {
+  google()
+  mavenCentral()
+  maven { url = uri("https://jitpack.io") }
+}
+
+android.buildFeatures.compose = true
+
+// TODO: Use build-logic
+apply from: rootProject.file('gradle/android.gradle')
+
+android.namespace 'com.github.takahirom.roborazzi.preview.support'
+
+dependencies {
+  api project(":roborazzi")
+  api project(":roborazzi-compose")
+
+  compileOnly libs.androidx.compose.runtime
+  compileOnly libs.composable.preview.scanner
+  compileOnly libs.robolectric
+}
\ No newline at end of file
diff --git a/roborazzi-compose-preview-scanner-support/consumer-rules.pro b/roborazzi-compose-preview-scanner-support/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/roborazzi-compose-preview-scanner-support/proguard-rules.pro b/roborazzi-compose-preview-scanner-support/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/roborazzi-compose-preview-scanner-support/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+#   http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+#   public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/roborazzi-compose-preview-scanner-support/src/main/AndroidManifest.xml b/roborazzi-compose-preview-scanner-support/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..151554b4
--- /dev/null
+++ b/roborazzi-compose-preview-scanner-support/src/main/AndroidManifest.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+  <application>
+  </application>
+</manifest>
\ No newline at end of file
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
new file mode 100644
index 00000000..fb829713
--- /dev/null
+++ b/roborazzi-compose-preview-scanner-support/src/main/java/com/github/takahirom/roborazzi/RobolectricPreviewInfosApplier.kt
@@ -0,0 +1,30 @@
+package com.github.takahirom.roborazzi
+
+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.core.preview.ComposablePreview
+
+@ExperimentalRoborazziApi
+fun ComposablePreview<AndroidPreviewInfo>.applyToRobolectricConfiguration() {
+  val preview = this
+
+  fun setUiMode(uiMode: Int) {
+    val nightMode =
+      when (uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES) {
+        true -> "night"
+        false -> "notnight"
+      }
+    setQualifiers("+$nightMode")
+  }
+  setUiMode(preview.previewInfo.uiMode)
+
+  fun setLocale(locale: String) {
+    val localeWithFallback = locale.ifBlank { "en" }
+    setQualifiers("+$localeWithFallback")
+  }
+  setLocale(preview.previewInfo.locale)
+
+  setFontScale(preview.previewInfo.fontScale)
+}
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
new file mode 100644
index 00000000..b104f1bb
--- /dev/null
+++ b/roborazzi-compose-preview-scanner-support/src/main/java/com/github/takahirom/roborazzi/RoborazziPreviewScannerSupport.kt
@@ -0,0 +1,68 @@
+package com.github.takahirom.roborazzi
+
+import sergio.sastre.composable.preview.scanner.android.AndroidComposablePreviewScanner
+import sergio.sastre.composable.preview.scanner.android.AndroidPreviewInfo
+import sergio.sastre.composable.preview.scanner.android.screenshotid.AndroidPreviewScreenshotIdBuilder
+import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview
+
+@ExperimentalRoborazziApi
+fun ComposablePreview<AndroidPreviewInfo>.captureRoboImage(
+  filePath: String = DefaultFileNameGenerator.generateFilePath("png"),
+  roborazziOptions: RoborazziOptions = RoborazziOptions(),
+) {
+  val composablePreview = this
+  composablePreview.applyToRobolectricConfiguration()
+  captureRoboImage(filePath = filePath, roborazziOptions = roborazziOptions) {
+    composablePreview()
+  }
+}
+
+@ExperimentalRoborazziApi
+interface RobolectricPreviewTest {
+  fun previews(vararg packages: String): List<ComposablePreview<AndroidPreviewInfo>>
+
+  fun test(
+    preview: ComposablePreview<AndroidPreviewInfo>,
+  )
+}
+
+@InternalRoborazziApi
+class DefaultRobolectricPreviewTest : RobolectricPreviewTest {
+  override fun previews(vararg packages: String): List<ComposablePreview<AndroidPreviewInfo>> {
+    return AndroidComposablePreviewScanner()
+      .scanPackageTrees(*packages)
+      .getPreviews()
+  }
+
+  override fun test(preview: ComposablePreview<AndroidPreviewInfo>) {
+    val pathPrefix =
+      if (roborazziRecordFilePathStrategy() == RoborazziRecordFilePathStrategy.RelativePathFromCurrentDirectory) {
+        roborazziSystemPropertyOutputDirectory() + java.io.File.separator
+      } else {
+        ""
+      }
+    val name = roborazziDefaultNamingStrategy().generateOutputName(preview.declaringClass, createScreenshotIdFor(preview))
+    val filePath = "$pathPrefix$name.png"
+    preview.captureRoboImage(filePath)
+  }
+
+  private fun createScreenshotIdFor(preview: ComposablePreview<AndroidPreviewInfo>) =
+    AndroidPreviewScreenshotIdBuilder(preview)
+      .ignoreClassName()
+      .build()
+}
+
+@InternalRoborazziApi
+fun getRobolectricPreviewTest(customTestQualifiedClassName: String): RobolectricPreviewTest {
+  val customTestClass = try {
+    Class.forName(customTestQualifiedClassName)
+  } catch (e: ClassNotFoundException) {
+    throw IllegalArgumentException("The class $customTestQualifiedClassName not found")
+  }
+  if (!RobolectricPreviewTest::class.java.isAssignableFrom(customTestClass)) {
+    throw IllegalArgumentException("The class $customTestQualifiedClassName must implement RobolectricPreviewCapturer")
+  }
+  val robolectricPreviewTest =
+    customTestClass.getDeclaredConstructor().newInstance() as RobolectricPreviewTest
+  return robolectricPreviewTest
+}
diff --git a/roborazzi-idea-plugin/src/main/kotlin/com/github/takahirom/roborazzi/idea/preview/RoborazziPreviewTool.kt b/roborazzi-idea-plugin/src/main/kotlin/com/github/takahirom/roborazzi/idea/preview/RoborazziPreviewTool.kt
index 58d41a5a..f1a5c79d 100644
--- a/roborazzi-idea-plugin/src/main/kotlin/com/github/takahirom/roborazzi/idea/preview/RoborazziPreviewTool.kt
+++ b/roborazzi-idea-plugin/src/main/kotlin/com/github/takahirom/roborazzi/idea/preview/RoborazziPreviewTool.kt
@@ -13,7 +13,6 @@ import com.intellij.openapi.fileEditor.FileEditorManagerListener
 import com.intellij.openapi.options.ShowSettingsUtil
 import com.intellij.openapi.project.Project
 import com.intellij.openapi.roots.ProjectRootManager
-import com.intellij.openapi.util.IconLoader
 import com.intellij.openapi.vfs.VirtualFile
 import com.intellij.openapi.wm.ToolWindow
 import com.intellij.openapi.wm.ToolWindowFactory
diff --git a/settings.gradle b/settings.gradle
index 4691dc2b..20e68d9a 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -19,6 +19,7 @@ include ':roborazzi-compose-desktop'
 include ':roborazzi-compose-ios'
 include ':roborazzi-compose'
 include ':roborazzi-painter'
+include ':roborazzi-compose-preview-scanner-support'
 
 include ':roborazzi-idea-plugin'
 


Test patch:
diff --git a/include-build/roborazzi-gradle-plugin/src/main/java/io/github/takahirom/roborazzi/GenerateRobolectricPreviewTestsExtension.kt b/include-build/roborazzi-gradle-plugin/src/main/java/io/github/takahirom/roborazzi/GenerateRobolectricComposePreviewTestsExtension.kt
similarity index 71%
rename from include-build/roborazzi-gradle-plugin/src/main/java/io/github/takahirom/roborazzi/GenerateRobolectricPreviewTestsExtension.kt
rename to include-build/roborazzi-gradle-plugin/src/main/java/io/github/takahirom/roborazzi/GenerateRobolectricComposePreviewTestsExtension.kt
index 967f5397..51e45e69 100644
--- a/include-build/roborazzi-gradle-plugin/src/main/java/io/github/takahirom/roborazzi/GenerateRobolectricPreviewTestsExtension.kt
+++ b/include-build/roborazzi-gradle-plugin/src/main/java/io/github/takahirom/roborazzi/GenerateRobolectricComposePreviewTestsExtension.kt
@@ -10,6 +10,7 @@ import org.gradle.api.file.DirectoryProperty
 import org.gradle.api.logging.Logger
 import org.gradle.api.model.ObjectFactory
 import org.gradle.api.provider.ListProperty
+import org.gradle.api.provider.MapProperty
 import org.gradle.api.provider.Property
 import org.gradle.api.tasks.Input
 import org.gradle.api.tasks.OutputDirectory
@@ -20,16 +21,38 @@ import java.io.File
 import java.net.URLEncoder
 import javax.inject.Inject
 
-open class GenerateRobolectricPreviewTestsExtension @Inject constructor(objects: ObjectFactory) {
+open class GenerateComposePreviewRobolectricTestsExtension @Inject constructor(objects: ObjectFactory) {
   val enable: Property<Boolean> = objects.property(Boolean::class.java)
     .convention(false)
+
+  /**
+   * The package names to scan for the Composable Previews.
+   */
   val packages: ListProperty<String> = objects.listProperty(String::class.java)
+
+  /**
+   * The fully qualified class name of the custom test class that implements [com.github.takahirom.roborazzi.RobolectricPreviewTest].
+   */
+  val customTestQualifiedClassName: Property<String> = objects.property(String::class.java)
+    .convention("com.github.takahirom.roborazzi.DefaultRobolectricPreviewTest")
+
+  /**
+   * [robolectricConfig] will be passed to the Robolectric's @Config annotation in the generated test class.
+   * See https://robolectric.org/configuring/ for more information.
+   */
+  val robolectricConfig: MapProperty<String, String> = objects.mapProperty(String::class.java, String::class.java)
+    .convention(
+      mapOf(
+        "sdk" to "[33]",
+        "qualifiers" to "RobolectricDeviceQualifiers.Pixel4a",
+      )
+    )
 }
 
 fun generateRobolectricPreviewTestsIfNeeded(
   project: Project,
   variant: Variant,
-  extension: GenerateRobolectricPreviewTestsExtension,
+  extension: GenerateComposePreviewRobolectricTestsExtension,
   androidExtension: TestedExtension,
   testTaskProvider: TaskCollection<Test>
 ) {
@@ -37,7 +60,13 @@ fun generateRobolectricPreviewTestsIfNeeded(
     return
   }
   val logger = project.logger
-  setupGeneratePreviewTestsTask(project, variant, extension.packages)
+  setupGeneratePreviewTestsTask(
+    project = project,
+    variant = variant,
+    scanPackages = extension.packages,
+    customTestQualifiedClassName = extension.customTestQualifiedClassName,
+    robolectricConfig = extension.robolectricConfig
+  )
   project.afterEvaluate {
     // We use afterEvaluate only for verify
     assert(variant.unitTest == null) {
@@ -52,7 +81,9 @@ fun generateRobolectricPreviewTestsIfNeeded(
 private fun setupGeneratePreviewTestsTask(
   project: Project,
   variant: Variant,
-  scanPackages: ListProperty<String>
+  scanPackages: ListProperty<String>,
+  customTestQualifiedClassName: Property<String>,
+  robolectricConfig: MapProperty<String, String>,
 ) {
   assert(scanPackages.get().orEmpty().isNotEmpty()) {
     "Please set roborazzi.generateRobolectricPreviewTests.packages in the generatePreviewTests extension or set roborazzi.generateRobolectricPreviewTests.enable = false." +
@@ -67,6 +98,8 @@ private fun setupGeneratePreviewTestsTask(
     // The generated tests will be located in build/JAVA/generate[VariantName]PreviewScreenshotTests.
     it.outputDir.set(project.layout.buildDirectory.dir("generated/roborazzi/preview-screenshot"))
     it.scanPackageTrees.set(scanPackages)
+    it.customTestQualifiedClassName.set(customTestQualifiedClassName)
+    it.robolectricConfig.set(robolectricConfig)
   }
   // We need to use sources.java here; otherwise, the generate task will not be executed.
   // https://stackoverflow.com/a/76870110/4339442
@@ -85,8 +118,8 @@ private fun verifyMavenRepository(project: Project) {
   if (!hasJitpackRepo) {
     error(
       "Roborazzi: Please add the following 'maven' repository to the 'repositories' block in the 'build.gradle' file.\n" +
-        "build.gradle: maven { url 'https://jitpack.io' }\n" +
-        "build.gradle.kts: maven { url = uri(\"https://jitpack.io\") }\n" +
+        "build.gradle: \nrepositories {\nmaven { url 'https://jitpack.io' } \n}\n" +
+        "build.gradle.kts: \nrepositories {\nmaven { url = uri(\"https://jitpack.io\") } \n}\n" +
         "This is necessary to download the ComposablePreviewScanner."
     )
   }
@@ -113,11 +146,13 @@ private fun verifyTestConfig(
     }
     if (testTask.systemProperties["robolectric.pixelCopyRenderMode"] != "hardware") {
       val example = """
-        testOptions {
-          unitTests {
-            isIncludeAndroidResources = true
-            all {
-              it.systemProperties["robolectric.pixelCopyRenderMode"] = "hardware"
+        android {
+          testOptions {
+            unitTests {
+              isIncludeAndroidResources = true
+              all {
+                it.systemProperties["robolectric.pixelCopyRenderMode"] = "hardware"
+              }
             }
           }
         }
@@ -152,8 +187,7 @@ private fun verifyLibraryDependencies(
   }
 
   val requiredLibraries = listOf(
-    "io.github.takahirom.roborazzi:roborazzi-compose",
-    "io.github.takahirom.roborazzi:roborazzi",
+    "io.github.takahirom.roborazzi:roborazzi-compose-preview-scanner-support",
     "junit:junit",
     "org.robolectric:robolectric",
     "com.github.sergio-sastre.ComposablePreviewScanner:android",
@@ -168,17 +202,32 @@ abstract class GeneratePreviewScreenshotTestsTask : DefaultTask() {
   @get:Input
   var scanPackageTrees: ListProperty<String> = project.objects.listProperty(String::class.java)
 
+  @get:Input
+  abstract val customTestQualifiedClassName: Property<String>
+
+  @get:Input
+  abstract val robolectricConfig: MapProperty<String, String>
+
   @TaskAction
   fun generateTests() {
     val testDir = outputDir.get().asFile
     testDir.mkdirs()
 
     val packagesExpr = scanPackageTrees.get().joinToString(", ") { "\"$it\"" }
-    val scanPackageTreeExpr = ".scanPackageTrees($packagesExpr)"
 
-    val className = "RoborazziPreviewParameterizedTests"
-    File(testDir, "$className.kt").writeText(
+    val generatedClassFQDN = "com.github.takahirom.roborazzi.RoborazziPreviewParameterizedTests"
+    val packageName = generatedClassFQDN.substringBeforeLast(".")
+    val className = generatedClassFQDN.substringAfterLast(".")
+    val directory = File(testDir, packageName.replace(".", "/"))
+    directory.mkdirs()
+    val robolectricConfigString =
+      "@Config(" + robolectricConfig.get().entries.joinToString(", ") { (key, value) ->
+        "$key = $value"
+      } + ")"
+    val customTestQualifiedClassNameString = customTestQualifiedClassName.get()
+    File(directory, "$className.kt").writeText(
       """
+            package $packageName
             import org.junit.Test
             import org.junit.runner.RunWith
             import org.robolectric.ParameterizedRobolectricTestRunner
@@ -198,35 +247,24 @@ abstract class GeneratePreviewScreenshotTestsTask : DefaultTask() {
             ) {
 
                 companion object {
+                    // lazy for performance
                     val previews: List<ComposablePreview<AndroidPreviewInfo>> by lazy {
-                        AndroidComposablePreviewScanner()
-                            $scanPackageTreeExpr
-                            .getPreviews()
+                        getRobolectricPreviewTest("$customTestQualifiedClassNameString").previews(
+                            $packagesExpr
+                        )
                     }
                     @JvmStatic
-                    @ParameterizedRobolectricTestRunner.Parameters
+                    @ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
                     fun values(): List<ComposablePreview<AndroidPreviewInfo>> =
                         previews
                 }
                 
-                fun createScreenshotIdFor(preview: ComposablePreview<AndroidPreviewInfo>) = 
-                  AndroidPreviewScreenshotIdBuilder(preview)
-                         .ignoreClassName()
-                         .build()
                 
                 @GraphicsMode(GraphicsMode.Mode.NATIVE)
-                @Config(sdk = [30])
+                $robolectricConfigString
                 @Test
                 fun test() {
-                    val pathPrefix = if(roborazziRecordFilePathStrategy() == RoborazziRecordFilePathStrategy.RelativePathFromCurrentDirectory) {
-                        roborazziSystemPropertyOutputDirectory() + java.io.File.separator
-                    } else {
-                        ""
-                    }
-                    val filePath = pathPrefix + createScreenshotIdFor(preview) + ".png"
-                    captureRoboImage(filePath = filePath) {
-                        preview()
-                    }
+                    getRobolectricPreviewTest("$customTestQualifiedClassNameString").test(preview)
                 }
 
             }
diff --git a/sample-generate-preview-tests/build.gradle.kts b/sample-generate-preview-tests/build.gradle.kts
index f0141e12..5d56f701 100644
--- a/sample-generate-preview-tests/build.gradle.kts
+++ b/sample-generate-preview-tests/build.gradle.kts
@@ -6,7 +6,7 @@ plugins {
 }
 
 roborazzi {
-  generateRobolectricPreviewTests {
+  generateComposePreviewRobolectricTests {
     enable = true
     packages = listOf("com.github.takahirom.preview.tests")
   }
@@ -57,12 +57,13 @@ dependencies {
   implementation(libs.androidx.compose.material3)
   implementation(libs.androidx.compose.ui)
   implementation(libs.androidx.compose.ui.tooling)
+  implementation(libs.androidx.compose.runtime)
 
-  testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.20.0")
-  testImplementation("io.github.takahirom.roborazzi:roborazzi:1.20.0")
+  // replaced by dependency substitution
+  testImplementation("io.github.takahirom.roborazzi:roborazzi-compose-preview-scanner-support:0.1.0")
   testImplementation(libs.junit)
   testImplementation(libs.robolectric)
-  testImplementation("com.github.sergio-sastre.ComposablePreviewScanner:android:0.1.2")
+  testImplementation(libs.composable.preview.scanner)
   androidTestImplementation(libs.androidx.test.ext.junit)
   androidTestImplementation(libs.androidx.test.espresso.core)
 }
\ No newline at end of file
diff --git a/sample-generate-preview-tests/src/main/java/com/github/takahirom/preview/tests/Previews.kt b/sample-generate-preview-tests/src/main/java/com/github/takahirom/preview/tests/Previews.kt
index 75c8a626..1d8e46af 100644
--- a/sample-generate-preview-tests/src/main/java/com/github/takahirom/preview/tests/Previews.kt
+++ b/sample-generate-preview-tests/src/main/java/com/github/takahirom/preview/tests/Previews.kt
@@ -1,12 +1,14 @@
 package com.github.takahirom.preview.tests
 
 import android.content.res.Configuration
-import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.isSystemInDarkTheme
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.width
 import androidx.compose.material3.Card
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Text
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.tooling.preview.Devices
@@ -16,12 +18,36 @@ import androidx.compose.ui.unit.dp
 
 @Preview
 @Composable
-fun Preview2() {
+fun PreviewNormal() {
   MaterialTheme {
     Card(
       Modifier
-        .width(126.dp)
-        .height(80.dp)
+        .width(180.dp)
+    ) {
+      Text(
+        modifier = Modifier.padding(8.dp),
+        text = "Generate Preview Test Sample"
+      )
+    }
+  }
+}
+
+@Preview(
+  uiMode = Configuration.UI_MODE_NIGHT_YES
+)
+@Composable
+fun PreviewDarkMode() {
+  val isSystemInDarkTheme = isSystemInDarkTheme()
+  MaterialTheme(
+    colorScheme = if (isSystemInDarkTheme) {
+      darkColorScheme()
+    } else {
+      lightColorScheme()
+    }
+  ) {
+    Card(
+      Modifier
+        .width(180.dp)
     ) {
       Text(
         modifier = Modifier.padding(8.dp),
@@ -38,8 +64,23 @@ fun Preview2() {
   apiLevel = 30,
   widthDp = 320,
   heightDp = 640,
-  locale = "ja_JP",
+  locale = "ja-rJP",
   fontScale = 1.5f,
+)
+@Composable
+fun PreviewWithProperties1() {
+  Card(
+      Modifier
+          .width(100.dp)
+  ) {
+    Text(
+      modifier = Modifier.padding(8.dp),
+      text = "Hello, World!"
+    )
+  }
+}
+
+@Preview(
   showSystemUi = true,
   showBackground = true,
   backgroundColor = 0xFF0000FF,
@@ -48,11 +89,10 @@ fun Preview2() {
   wallpaper = Wallpapers.GREEN_DOMINATED_EXAMPLE,
 )
 @Composable
-fun PreviewWithProperties() {
+fun PreviewWithProperties2() {
   Card(
     Modifier
       .width(100.dp)
-      .height(50.dp)
   ) {
     Text(
       modifier = Modifier.padding(8.dp),