From 25e5dda1a84f724599502365396c9981acea891b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Sastre=20Fl=C3=B3rez?= Date: Fri, 24 Oct 2025 11:01:19 +0200 Subject: [PATCH 1/9] first working version --- paparazzi-plugin-tests/build.gradle.kts | 1 + .../ComposablePreviewPaparazziExtension.kt | 5 + .../ComposablePreviewPaparazziPlugin.kt | 1 + ...rateComposablePreviewPaparazziTestsTask.kt | 99 +++++++++++++++---- .../scanner/paparazzi/plugin/TaskSetup.kt | 1 + 5 files changed, 86 insertions(+), 21 deletions(-) diff --git a/paparazzi-plugin-tests/build.gradle.kts b/paparazzi-plugin-tests/build.gradle.kts index a12162f8..c6247d57 100644 --- a/paparazzi-plugin-tests/build.gradle.kts +++ b/paparazzi-plugin-tests/build.gradle.kts @@ -40,6 +40,7 @@ composablePreviewPaparazzi { includePrivatePreviews = true testClassName = "GeneratedPaparazziTests" testPackageName = "preview.generated" + numOfShards = 2 } dependencies { diff --git a/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/ComposablePreviewPaparazziExtension.kt b/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/ComposablePreviewPaparazziExtension.kt index c9c24f63..afd417f0 100644 --- a/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/ComposablePreviewPaparazziExtension.kt +++ b/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/ComposablePreviewPaparazziExtension.kt @@ -31,4 +31,9 @@ open class ComposablePreviewPaparazziExtension @Inject constructor(objects: Obje * The package name for the generated test class. */ val testPackageName: Property = objects.property(String::class.java) + + /** + * Number of shards to split the generated parameterized tests into. + */ + val numOfShards: Property = objects.property(Int::class.java) } \ No newline at end of file diff --git a/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/ComposablePreviewPaparazziPlugin.kt b/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/ComposablePreviewPaparazziPlugin.kt index 3ee9dcd8..d9c752a7 100644 --- a/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/ComposablePreviewPaparazziPlugin.kt +++ b/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/ComposablePreviewPaparazziPlugin.kt @@ -18,6 +18,7 @@ class ComposablePreviewPaparazziPlugin : Plugin { extension.includePrivatePreviews.convention(false) extension.testClassName.convention("GeneratedComposablePreviewPaparazziTests") extension.testPackageName.convention("generated.paparazzi.tests") + extension.numOfShards.convention(1) // Configure the task after project evaluation project.afterEvaluate { diff --git a/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/GenerateComposablePreviewPaparazziTestsTask.kt b/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/GenerateComposablePreviewPaparazziTestsTask.kt index c53b0787..cfe783f4 100644 --- a/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/GenerateComposablePreviewPaparazziTestsTask.kt +++ b/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/GenerateComposablePreviewPaparazziTestsTask.kt @@ -26,6 +26,9 @@ abstract class GenerateComposablePreviewPaparazziTestsTask : DefaultTask() { @get:Input abstract val testPackageName: Property + @get:Input + abstract val numOfShards: Property + @TaskAction fun generateTests() { val testDir = outputDir.get().asFile @@ -35,29 +38,78 @@ abstract class GenerateComposablePreviewPaparazziTestsTask : DefaultTask() { val includePrivatePreviewsExpr = includePrivatePreviews.get() val className = testClassName.get() val packageName = testPackageName.get() + val shards = numOfShards.get() val directory = File(testDir, packageName.replace(".", "/")) directory.mkdirs() - File(directory, "$className.kt").writeText( - generateTestFileContent( - packageName, - className, - packagesExpr, - includePrivatePreviewsExpr + if (shards < 1) { + logger.info("Number of shards must be at least 1") + } else if (shards == 1) { + File(directory, "$className.kt").writeText( + generateTestFileContent( + packageName, + className, + packagesExpr, + includePrivatePreviewsExpr, + shardIndex = null, + numShards = 1, + includeHeader = true + ) ) - ) - - logger.info("Generated Paparazzi test file: ${directory.absolutePath}/$className.kt") + logger.info("Generated Paparazzi test file: ${directory.absolutePath}/$className.kt") + } else { + val targetFile = File(directory, "$className.kt") + val content = buildString { + // First shard with header and shared code + append( + generateTestFileContent( + packageName, + "${className}Shard1", + packagesExpr, + includePrivatePreviewsExpr, + shardIndex = 0, + numShards = shards, + includeHeader = true + ) + ) + // Remaining shards: only class declarations + for (index in 1 until shards) { + append("\n\n") + append( + generateTestFileContent( + packageName, + "${className}Shard${index + 1}", + packagesExpr, + includePrivatePreviewsExpr, + shardIndex = index, + numShards = shards, + includeHeader = false + ) + ) + } + } + targetFile.writeText(content) + logger.info("Generated Paparazzi test file: ${directory.absolutePath}/$className.kt") + } } private fun generateTestFileContent( packageName: String, className: String, packagesExpr: String, - includePrivatePreviewsExpr: Boolean + includePrivatePreviewsExpr: Boolean, + shardIndex: Int?, + numShards: Int, + includeHeader: Boolean ): String { - return """ + val valuesExpr = if (shardIndex == null || numShards <= 1) { + "cachedPreviews" + } else { + "cachedPreviews.filterIndexed { index, _ -> index % $numShards == $shardIndex }" + } + + val header = """ package $packageName import android.content.res.Configuration.UI_MODE_NIGHT_MASK @@ -286,23 +338,26 @@ abstract class GenerateComposablePreviewPaparazziTestsTask : DefaultTask() { } } } - + + // Expensive scan cached once per file to be shared by all shard classes + private val cachedPreviews: List> by lazy { + AndroidComposablePreviewScanner() + .scanPackageTrees($packagesExpr) + ${if (includePrivatePreviewsExpr) ".includePrivatePreviews()" else ""} + .getPreviews() + } + """.trimIndent() + + val classSection = """ @RunWith(Parameterized::class) class $className( val preview: ComposablePreview, ) { companion object { - private val cachedPreviews: List> by lazy { - AndroidComposablePreviewScanner() - .scanPackageTrees($packagesExpr) - ${if (includePrivatePreviewsExpr) ".includePrivatePreviews()" else ""} - .getPreviews() - } - @JvmStatic @Parameterized.Parameters - fun values(): List> = cachedPreviews + fun values(): List> = $valuesExpr } @get:Rule @@ -343,6 +398,8 @@ abstract class GenerateComposablePreviewPaparazziTestsTask : DefaultTask() { } } } - """.trimIndent() + """.trimIndent() + + return if (includeHeader) "$header\n\n$classSection" else classSection } } \ No newline at end of file diff --git a/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/TaskSetup.kt b/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/TaskSetup.kt index 3d8b0d53..4b8a8feb 100644 --- a/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/TaskSetup.kt +++ b/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/TaskSetup.kt @@ -30,6 +30,7 @@ fun setupGenerateComposablePreviewPaparazziTestsTask( task.includePrivatePreviews.set(extension.includePrivatePreviews) task.testClassName.set(extension.testClassName) task.testPackageName.set(extension.testPackageName) + task.numOfShards.set(extension.numOfShards) } // The tests are now generated directly in src/test/kotlin, so no need to add source directories From 44b91de429dd31d048f78b4fc5343beee644ea90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Sastre=20Fl=C3=B3rez?= Date: Fri, 24 Oct 2025 13:50:38 +0200 Subject: [PATCH 2/9] read maxParallelForks correctly --- paparazzi-plugin-tests/build.gradle.kts | 8 +++++++- .../paparazzi/plugin/ComposablePreviewPaparazziPlugin.kt | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/paparazzi-plugin-tests/build.gradle.kts b/paparazzi-plugin-tests/build.gradle.kts index c6247d57..8033cca8 100644 --- a/paparazzi-plugin-tests/build.gradle.kts +++ b/paparazzi-plugin-tests/build.gradle.kts @@ -31,6 +31,12 @@ android { kotlinOptions { jvmTarget = "11" } + + testOptions { + unitTests.all { + it.maxParallelForks = Runtime.getRuntime().availableProcessors() / 2 + } + } } // Execute ./gradlew :paparazzi-plugin-tests:recordPaparazziDebug @@ -40,7 +46,7 @@ composablePreviewPaparazzi { includePrivatePreviews = true testClassName = "GeneratedPaparazziTests" testPackageName = "preview.generated" - numOfShards = 2 + //numOfShards = 2 } dependencies { diff --git a/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/ComposablePreviewPaparazziPlugin.kt b/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/ComposablePreviewPaparazziPlugin.kt index d9c752a7..c453338c 100644 --- a/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/ComposablePreviewPaparazziPlugin.kt +++ b/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/ComposablePreviewPaparazziPlugin.kt @@ -18,10 +18,16 @@ class ComposablePreviewPaparazziPlugin : Plugin { extension.includePrivatePreviews.convention(false) extension.testClassName.convention("GeneratedComposablePreviewPaparazziTests") extension.testPackageName.convention("generated.paparazzi.tests") - extension.numOfShards.convention(1) + // Do not set a convention for numOfShards here; we will derive it from Gradle's Test.maxParallelForks later. // Configure the task after project evaluation project.afterEvaluate { + // Default numOfShards to Gradle Test.maxParallelForks (users can still override via extension) + val tests = project.tasks.withType(org.gradle.api.tasks.testing.Test::class.java) + val maxForks = tests.findByName("test")?.maxParallelForks + ?: tests.maxOfOrNull { it.maxParallelForks } ?: 1 + extension.numOfShards.convention(maxForks) + if (extension.enable.get()) { setupGenerateComposablePreviewPaparazziTestsTask(project, extension) } From 8dd9a2206cabd9cc3bcb2e32e85aa1822813f9ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Sastre=20Fl=C3=B3rez?= Date: Sat, 25 Oct 2025 09:10:25 +0200 Subject: [PATCH 3/9] Update GenerateComposablePreviewPaparazziTestsTask --- ...rateComposablePreviewPaparazziTestsTask.kt | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/GenerateComposablePreviewPaparazziTestsTask.kt b/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/GenerateComposablePreviewPaparazziTestsTask.kt index cfe783f4..3cc0d316 100644 --- a/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/GenerateComposablePreviewPaparazziTestsTask.kt +++ b/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/GenerateComposablePreviewPaparazziTestsTask.kt @@ -144,6 +144,65 @@ abstract class GenerateComposablePreviewPaparazziTestsTask : DefaultTask() { import sergio.sastre.composable.preview.scanner.android.screenshotid.AndroidPreviewScreenshotIdBuilder import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview + private val paparazziTestName = + TestName(packageName = "Paparazzi", className = "Preview", methodName = "Test") + + private class SnapshotVerifierPaparazzi( + maxPercentDifference: Double + ): SnapshotHandler { + private val snapshotHandler = SnapshotVerifier( + maxPercentDifference = maxPercentDifference + ) + override fun newFrameHandler( + snapshot: Snapshot, + frameCount: Int, + fps: Int + ): SnapshotHandler.FrameHandler { + val newSnapshot = Snapshot( + name = snapshot.name, + testName = paparazziTestName, + timestamp = snapshot.timestamp, + tags = snapshot.tags, + file = snapshot.file, + ) + return snapshotHandler.newFrameHandler( + snapshot = newSnapshot, + frameCount = frameCount, + fps = fps + ) + } + + override fun close() { + snapshotHandler.close() + } + } + + private class HtmlReportWriterPaparazzi: SnapshotHandler { + private val snapshotHandler = HtmlReportWriter() + override fun newFrameHandler( + snapshot: Snapshot, + frameCount: Int, + fps: Int + ): SnapshotHandler.FrameHandler { + val newSnapshot = Snapshot( + name = snapshot.name, + testName = paparazziTestName, + timestamp = snapshot.timestamp, + tags = snapshot.tags, + file = snapshot.file, + ) + return snapshotHandler.newFrameHandler( + snapshot = newSnapshot, + frameCount = frameCount, + fps = fps + ) + } + + override fun close() { + snapshotHandler.close() + } + } + class Dimensions( val screenWidthInPx: Int, val screenHeightInPx: Int From 9741cbe8358c5cda788d8e86d41e1644c16480be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Sastre=20Fl=C3=B3rez?= Date: Sat, 25 Oct 2025 11:49:47 +0200 Subject: [PATCH 4/9] optimize sharding + account for numOfShards > num of previews --- .../plugin/GenerateComposablePreviewPaparazziTestsTask.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/GenerateComposablePreviewPaparazziTestsTask.kt b/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/GenerateComposablePreviewPaparazziTestsTask.kt index 3cc0d316..23384141 100644 --- a/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/GenerateComposablePreviewPaparazziTestsTask.kt +++ b/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/GenerateComposablePreviewPaparazziTestsTask.kt @@ -106,7 +106,7 @@ abstract class GenerateComposablePreviewPaparazziTestsTask : DefaultTask() { val valuesExpr = if (shardIndex == null || numShards <= 1) { "cachedPreviews" } else { - "cachedPreviews.filterIndexed { index, _ -> index % $numShards == $shardIndex }" + "shardedCachedPreviews[$shardIndex]?:emptyList()" } val header = """ @@ -405,6 +405,12 @@ abstract class GenerateComposablePreviewPaparazziTestsTask : DefaultTask() { ${if (includePrivatePreviewsExpr) ".includePrivatePreviews()" else ""} .getPreviews() } + + private val shardedCachedPreviews: Map>> by lazy { + cachedPreviews + .mapIndexed { index, preview -> index % $numShards to preview } + .groupBy({ it.first }, { it.second }) + } """.trimIndent() val classSection = """ From cef590fcbfbdbc973de010eceb2b3929c84ba0e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Sastre=20Fl=C3=B3rez?= Date: Sat, 25 Oct 2025 12:06:11 +0200 Subject: [PATCH 5/9] rename SnapshotHandlers in GenerateComposablePerviewPaparazziTestsTask --- ...rateComposablePreviewPaparazziTestsTask.kt | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/GenerateComposablePreviewPaparazziTestsTask.kt b/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/GenerateComposablePreviewPaparazziTestsTask.kt index 23384141..06334bda 100644 --- a/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/GenerateComposablePreviewPaparazziTestsTask.kt +++ b/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/GenerateComposablePreviewPaparazziTestsTask.kt @@ -48,10 +48,10 @@ abstract class GenerateComposablePreviewPaparazziTestsTask : DefaultTask() { } else if (shards == 1) { File(directory, "$className.kt").writeText( generateTestFileContent( - packageName, - className, - packagesExpr, - includePrivatePreviewsExpr, + packageName = packageName, + className = className, + packagesExpr = packagesExpr, + includePrivatePreviewsExpr = includePrivatePreviewsExpr, shardIndex = null, numShards = 1, includeHeader = true @@ -64,10 +64,10 @@ abstract class GenerateComposablePreviewPaparazziTestsTask : DefaultTask() { // First shard with header and shared code append( generateTestFileContent( - packageName, - "${className}Shard1", - packagesExpr, - includePrivatePreviewsExpr, + packageName = packageName, + className = "${className}Shard1", + packagesExpr = packagesExpr, + includePrivatePreviewsExpr = includePrivatePreviewsExpr, shardIndex = 0, numShards = shards, includeHeader = true @@ -78,10 +78,10 @@ abstract class GenerateComposablePreviewPaparazziTestsTask : DefaultTask() { append("\n\n") append( generateTestFileContent( - packageName, - "${className}Shard${index + 1}", - packagesExpr, - includePrivatePreviewsExpr, + packageName = packageName, + className = "${className}Shard${index + 1}", + packagesExpr = packagesExpr, + includePrivatePreviewsExpr = includePrivatePreviewsExpr, shardIndex = index, numShards = shards, includeHeader = false @@ -147,7 +147,7 @@ abstract class GenerateComposablePreviewPaparazziTestsTask : DefaultTask() { private val paparazziTestName = TestName(packageName = "Paparazzi", className = "Preview", methodName = "Test") - private class SnapshotVerifierPaparazzi( + private class PreviewSnapshotVerifier( maxPercentDifference: Double ): SnapshotHandler { private val snapshotHandler = SnapshotVerifier( @@ -177,7 +177,7 @@ abstract class GenerateComposablePreviewPaparazziTestsTask : DefaultTask() { } } - private class HtmlReportWriterPaparazzi: SnapshotHandler { + private class PreviewHtmlReportWriter: SnapshotHandler { private val snapshotHandler = HtmlReportWriter() override fun newFrameHandler( snapshot: Snapshot, From 45531c1cb1b98df46e3dcce3d28bfa3e8b17ccd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Sastre=20Fl=C3=B3rez?= Date: Wed, 1 Jul 2026 16:16:44 +0200 Subject: [PATCH 6/9] Fix not working paparazzi-plugin-tests --- README.md | 2 +- paparazzi-plugin-tests/build.gradle.kts | 2 +- ...rateComposablePreviewPaparazziTestsTask.kt | 65 +------------------ 3 files changed, 5 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index cd17ac0b..8179be80 100644 --- a/README.md +++ b/README.md @@ -455,7 +455,7 @@ object DeviceConfigBuilder { object PaparazziPreviewRule { const val UNDEFINED_API_LEVEL = -1 - const val MAX_API_LEVEL = 36 + const val MAX_API_LEVEL = 34 fun createFor(preview: ComposablePreview): Paparazzi { val previewInfo = preview.previewInfo diff --git a/paparazzi-plugin-tests/build.gradle.kts b/paparazzi-plugin-tests/build.gradle.kts index 8033cca8..4892209c 100644 --- a/paparazzi-plugin-tests/build.gradle.kts +++ b/paparazzi-plugin-tests/build.gradle.kts @@ -46,7 +46,7 @@ composablePreviewPaparazzi { includePrivatePreviews = true testClassName = "GeneratedPaparazziTests" testPackageName = "preview.generated" - //numOfShards = 2 + numOfShards = 2 } dependencies { diff --git a/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/GenerateComposablePreviewPaparazziTestsTask.kt b/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/GenerateComposablePreviewPaparazziTestsTask.kt index 06334bda..b35a73af 100644 --- a/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/GenerateComposablePreviewPaparazziTestsTask.kt +++ b/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/GenerateComposablePreviewPaparazziTestsTask.kt @@ -144,6 +144,8 @@ abstract class GenerateComposablePreviewPaparazziTestsTask : DefaultTask() { import sergio.sastre.composable.preview.scanner.android.screenshotid.AndroidPreviewScreenshotIdBuilder import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview + // In order to have full control over the screenshot file names + // we need to pass our own SnapshotHandler to the Paparazzi TestRule private val paparazziTestName = TestName(packageName = "Paparazzi", className = "Preview", methodName = "Test") @@ -260,71 +262,10 @@ abstract class GenerateComposablePreviewPaparazziTestsTask : DefaultTask() { ) } } - - // In order to have full control over the screenshot file names - // we need to pass our own SnapshotHandler to the Paparazzi TestRule - private val paparazziTestName = - TestName(packageName = "Paparazzi", className = "Preview", methodName = "Test") - - private class PreviewSnapshotVerifier( - maxPercentDifference: Double - ): SnapshotHandler { - private val snapshotHandler = SnapshotVerifier( - maxPercentDifference = maxPercentDifference - ) - override fun newFrameHandler( - snapshot: Snapshot, - frameCount: Int, - fps: Int - ): SnapshotHandler.FrameHandler { - val newSnapshot = Snapshot( - name = snapshot.name, - testName = paparazziTestName, - timestamp = snapshot.timestamp, - tags = snapshot.tags, - file = snapshot.file, - ) - return snapshotHandler.newFrameHandler( - snapshot = newSnapshot, - frameCount = frameCount, - fps = fps - ) - } - - override fun close() { - snapshotHandler.close() - } - } - - private class PreviewHtmlReportWriter: SnapshotHandler { - private val snapshotHandler = HtmlReportWriter() - override fun newFrameHandler( - snapshot: Snapshot, - frameCount: Int, - fps: Int - ): SnapshotHandler.FrameHandler { - val newSnapshot = Snapshot( - name = snapshot.name, - testName = paparazziTestName, - timestamp = snapshot.timestamp, - tags = snapshot.tags, - file = snapshot.file, - ) - return snapshotHandler.newFrameHandler( - snapshot = newSnapshot, - frameCount = frameCount, - fps = fps - ) - } - - override fun close() { - snapshotHandler.close() - } - } object PaparazziPreviewRule { const val UNDEFINED_API_LEVEL = -1 - const val MAX_API_LEVEL = 36 + const val MAX_API_LEVEL = 34 fun createFor(preview: ComposablePreview): Paparazzi { val previewInfo = preview.previewInfo From f77657186e87c4665acb8d50945817483525cca2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Sastre=20Fl=C3=B3rez?= Date: Wed, 1 Jul 2026 16:46:31 +0200 Subject: [PATCH 7/9] Make sharding resemble Roborazzi approach --- paparazzi-plugin-tests/build.gradle.kts | 2 +- paparazzi-plugin/README.md | 28 +++++++++++++++++++ .../ComposablePreviewPaparazziExtension.kt | 4 +-- .../ComposablePreviewPaparazziPlugin.kt | 6 ++-- ...rateComposablePreviewPaparazziTestsTask.kt | 4 +-- .../scanner/paparazzi/plugin/TaskSetup.kt | 2 +- 6 files changed, 37 insertions(+), 9 deletions(-) diff --git a/paparazzi-plugin-tests/build.gradle.kts b/paparazzi-plugin-tests/build.gradle.kts index 4892209c..3b75a100 100644 --- a/paparazzi-plugin-tests/build.gradle.kts +++ b/paparazzi-plugin-tests/build.gradle.kts @@ -46,7 +46,7 @@ composablePreviewPaparazzi { includePrivatePreviews = true testClassName = "GeneratedPaparazziTests" testPackageName = "preview.generated" - numOfShards = 2 + generatedTestClassCount = 2 } dependencies { diff --git a/paparazzi-plugin/README.md b/paparazzi-plugin/README.md index fedcb126..ef86dab6 100644 --- a/paparazzi-plugin/README.md +++ b/paparazzi-plugin/README.md @@ -81,6 +81,34 @@ dependencies { | `includePrivatePreviews` | `Boolean` | `false` | Include private preview functions | | `testClassName` | `String` | `"GeneratedComposablePreviewPaparazziTests"` | Name of the generated test class | | `testPackageName` | `String` | `"generated.paparazzi.tests"` | Package name for generated tests | +| `generatedTestClassCount` | `Int` | `maxParallelForks` | Number of test classes to split the generated parameterized tests into | + +### Parallel execution +Paparazzi (using JUnit 4) runs parameterized tests sequentially within a single class. To speed up execution, this plugin can split your previews into multiple test classes (shards), allowing Gradle to run them in parallel across multiple worker processes. + +1. **`generatedTestClassCount`**: Controls how many test classes are generated. Each class will contain a subset of your previews. +2. **`maxParallelForks`**: A standard Gradle property that defines how many worker processes Gradle can start to run test classes in parallel. + +For effective parallelism, you should set both, for instance: + +```kotlin +// In your module's build.gradle.kts +composablePreviewPaparazzi { + // ... other config + // 1. Generate 4 test classes + generatedTestClassCount = 4 +} + +tasks.withType { + // 2. Allow Gradle to run up to 4 test classes at the same time + maxParallelForks = 4 +} +``` + +> [!IMPORTANT] +> `generatedTestClassCount` defaults to the same value as `maxParallelForks`. Therefore, if you already use `maxParallelForks` during testing, the behavior may change if your tests are non-deterministic—for example, if they depend on the order in which other tests run. +> +> However, this option does not modify `maxParallelForks` itself. Following [Roborazzi's policy](https://github.com/takahirom/roborazzi/releases/tag/1.53.0), this plugin **never modifies your AGP/Gradle settings** automatically in order to keep one single source of configuration. You must always configure `maxParallelForks` explicitly in your build script to enable parallel processing. ### Run the Generated Tests By running any of the following gradle tasks, the tests will be generated AND then executed (both): diff --git a/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/ComposablePreviewPaparazziExtension.kt b/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/ComposablePreviewPaparazziExtension.kt index afd417f0..f7ded421 100644 --- a/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/ComposablePreviewPaparazziExtension.kt +++ b/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/ComposablePreviewPaparazziExtension.kt @@ -33,7 +33,7 @@ open class ComposablePreviewPaparazziExtension @Inject constructor(objects: Obje val testPackageName: Property = objects.property(String::class.java) /** - * Number of shards to split the generated parameterized tests into. + * Number of test classes to split the generated parameterized tests into. */ - val numOfShards: Property = objects.property(Int::class.java) + val generatedTestClassCount: Property = objects.property(Int::class.java) } \ No newline at end of file diff --git a/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/ComposablePreviewPaparazziPlugin.kt b/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/ComposablePreviewPaparazziPlugin.kt index c453338c..9ee33be9 100644 --- a/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/ComposablePreviewPaparazziPlugin.kt +++ b/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/ComposablePreviewPaparazziPlugin.kt @@ -18,15 +18,15 @@ class ComposablePreviewPaparazziPlugin : Plugin { extension.includePrivatePreviews.convention(false) extension.testClassName.convention("GeneratedComposablePreviewPaparazziTests") extension.testPackageName.convention("generated.paparazzi.tests") - // Do not set a convention for numOfShards here; we will derive it from Gradle's Test.maxParallelForks later. + // Do not set a convention for generatedTestClassCount here; we will derive it from Gradle's Test.maxParallelForks later. // Configure the task after project evaluation project.afterEvaluate { - // Default numOfShards to Gradle Test.maxParallelForks (users can still override via extension) + // Default generatedTestClassCount to Gradle Test.maxParallelForks (users can still override via extension) val tests = project.tasks.withType(org.gradle.api.tasks.testing.Test::class.java) val maxForks = tests.findByName("test")?.maxParallelForks ?: tests.maxOfOrNull { it.maxParallelForks } ?: 1 - extension.numOfShards.convention(maxForks) + extension.generatedTestClassCount.convention(maxForks) if (extension.enable.get()) { setupGenerateComposablePreviewPaparazziTestsTask(project, extension) diff --git a/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/GenerateComposablePreviewPaparazziTestsTask.kt b/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/GenerateComposablePreviewPaparazziTestsTask.kt index b35a73af..de976c33 100644 --- a/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/GenerateComposablePreviewPaparazziTestsTask.kt +++ b/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/GenerateComposablePreviewPaparazziTestsTask.kt @@ -27,7 +27,7 @@ abstract class GenerateComposablePreviewPaparazziTestsTask : DefaultTask() { abstract val testPackageName: Property @get:Input - abstract val numOfShards: Property + abstract val generatedTestClassCount: Property @TaskAction fun generateTests() { @@ -38,7 +38,7 @@ abstract class GenerateComposablePreviewPaparazziTestsTask : DefaultTask() { val includePrivatePreviewsExpr = includePrivatePreviews.get() val className = testClassName.get() val packageName = testPackageName.get() - val shards = numOfShards.get() + val shards = generatedTestClassCount.get() val directory = File(testDir, packageName.replace(".", "/")) directory.mkdirs() diff --git a/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/TaskSetup.kt b/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/TaskSetup.kt index 4b8a8feb..7969d221 100644 --- a/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/TaskSetup.kt +++ b/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/TaskSetup.kt @@ -30,7 +30,7 @@ fun setupGenerateComposablePreviewPaparazziTestsTask( task.includePrivatePreviews.set(extension.includePrivatePreviews) task.testClassName.set(extension.testClassName) task.testPackageName.set(extension.testPackageName) - task.numOfShards.set(extension.numOfShards) + task.generatedTestClassCount.set(extension.generatedTestClassCount) } // The tests are now generated directly in src/test/kotlin, so no need to add source directories From d947fa60bec877c603806ea0377233243df6d007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Sastre=20Fl=C3=B3rez?= Date: Wed, 1 Jul 2026 18:46:17 +0200 Subject: [PATCH 8/9] Update paparazzi-plugin-tests/build.gradle.kts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- paparazzi-plugin-tests/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paparazzi-plugin-tests/build.gradle.kts b/paparazzi-plugin-tests/build.gradle.kts index 3b75a100..efcc6cb9 100644 --- a/paparazzi-plugin-tests/build.gradle.kts +++ b/paparazzi-plugin-tests/build.gradle.kts @@ -34,7 +34,7 @@ android { testOptions { unitTests.all { - it.maxParallelForks = Runtime.getRuntime().availableProcessors() / 2 + it.maxParallelForks = maxOf(1, Runtime.getRuntime().availableProcessors() / 2) } } } From d7b22fa3441f4f4cda9309ccb0b9670e68c594bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Sastre=20Fl=C3=B3rez?= Date: Wed, 1 Jul 2026 18:46:44 +0200 Subject: [PATCH 9/9] Update paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/GenerateComposablePreviewPaparazziTestsTask.kt Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../plugin/GenerateComposablePreviewPaparazziTestsTask.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/GenerateComposablePreviewPaparazziTestsTask.kt b/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/GenerateComposablePreviewPaparazziTestsTask.kt index de976c33..bc4316ac 100644 --- a/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/GenerateComposablePreviewPaparazziTestsTask.kt +++ b/paparazzi-plugin/src/main/kotlin/io/github/sergio/sastre/composable/preview/scanner/paparazzi/plugin/GenerateComposablePreviewPaparazziTestsTask.kt @@ -44,7 +44,7 @@ abstract class GenerateComposablePreviewPaparazziTestsTask : DefaultTask() { directory.mkdirs() if (shards < 1) { - logger.info("Number of shards must be at least 1") + throw IllegalArgumentException("generatedTestClassCount must be at least 1, but was $shards") } else if (shards == 1) { File(directory, "$className.kt").writeText( generateTestFileContent(