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 a12162f8..efcc6cb9 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 = maxOf(1, Runtime.getRuntime().availableProcessors() / 2) + } + } } // Execute ./gradlew :paparazzi-plugin-tests:recordPaparazziDebug @@ -40,6 +46,7 @@ composablePreviewPaparazzi { includePrivatePreviews = true testClassName = "GeneratedPaparazziTests" testPackageName = "preview.generated" + 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 c9c24f63..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 @@ -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 test classes to split the generated parameterized tests into. + */ + 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 3ee9dcd8..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,9 +18,16 @@ class ComposablePreviewPaparazziPlugin : Plugin { extension.includePrivatePreviews.convention(false) extension.testClassName.convention("GeneratedComposablePreviewPaparazziTests") extension.testPackageName.convention("generated.paparazzi.tests") + // 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 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.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 c53b0787..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 @@ -26,6 +26,9 @@ abstract class GenerateComposablePreviewPaparazziTestsTask : DefaultTask() { @get:Input abstract val testPackageName: Property + @get:Input + abstract val generatedTestClassCount: 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 = generatedTestClassCount.get() val directory = File(testDir, packageName.replace(".", "/")) directory.mkdirs() - File(directory, "$className.kt").writeText( - generateTestFileContent( - packageName, - className, - packagesExpr, - includePrivatePreviewsExpr + if (shards < 1) { + throw IllegalArgumentException("generatedTestClassCount must be at least 1, but was $shards") + } else if (shards == 1) { + File(directory, "$className.kt").writeText( + generateTestFileContent( + packageName = packageName, + className = className, + packagesExpr = packagesExpr, + includePrivatePreviewsExpr = 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 = packageName, + className = "${className}Shard1", + packagesExpr = packagesExpr, + includePrivatePreviewsExpr = includePrivatePreviewsExpr, + shardIndex = 0, + numShards = shards, + includeHeader = true + ) + ) + // Remaining shards: only class declarations + for (index in 1 until shards) { + append("\n\n") + append( + generateTestFileContent( + packageName = packageName, + className = "${className}Shard${index + 1}", + packagesExpr = packagesExpr, + includePrivatePreviewsExpr = 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 { + "shardedCachedPreviews[$shardIndex]?:emptyList()" + } + + val header = """ package $packageName import android.content.res.Configuration.UI_MODE_NIGHT_MASK @@ -92,64 +144,6 @@ abstract class GenerateComposablePreviewPaparazziTestsTask : DefaultTask() { import sergio.sastre.composable.preview.scanner.android.screenshotid.AndroidPreviewScreenshotIdBuilder import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview - class Dimensions( - val screenWidthInPx: Int, - val screenHeightInPx: Int - ) - - object ScreenDimensions { - fun dimensions( - parsedDevice: Device, - widthDp: Int, - heightDp: Int - ): Dimensions { - val conversionFactor = parsedDevice.densityDpi / 160f - val previewWidthInPx = ceil(widthDp * conversionFactor).toInt() - val previewHeightInPx = ceil(heightDp * conversionFactor).toInt() - return Dimensions( - screenHeightInPx = when (heightDp > 0) { - true -> previewHeightInPx - false -> parsedDevice.dimensions.height.toInt() - }, - screenWidthInPx = when (widthDp > 0) { - true -> previewWidthInPx - false -> parsedDevice.dimensions.width.toInt() - } - ) - } - } - - object DeviceConfigBuilder { - fun build(preview: AndroidPreviewInfo): DeviceConfig { - val parsedDevice = - DevicePreviewInfoParser.parse(preview.device)?.inPx() ?: return DeviceConfig() - - val dimensions = ScreenDimensions.dimensions( - parsedDevice = parsedDevice, - widthDp = preview.widthDp, - heightDp = preview.heightDp - ) - - return DeviceConfig( - screenHeight = dimensions.screenHeightInPx, - screenWidth = dimensions.screenWidthInPx, - density = Density(parsedDevice.densityDpi), - xdpi = parsedDevice.densityDpi, // not 100% precise - ydpi = parsedDevice.densityDpi, // not 100% precise - size = ScreenSize.valueOf(parsedDevice.screenSize.name), - ratio = ScreenRatio.valueOf(parsedDevice.screenRatio.name), - screenRound = ScreenRound.valueOf(parsedDevice.shape.name), - orientation = ScreenOrientation.valueOf(parsedDevice.orientation.name), - locale = preview.locale.ifBlank { "en" }, - fontScale = preview.fontScale, - nightMode = when (preview.uiMode and UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES) { - true -> NightMode.NIGHT - false -> NightMode.NOTNIGHT - } - ) - } - } - // 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 = @@ -210,10 +204,68 @@ abstract class GenerateComposablePreviewPaparazziTestsTask : DefaultTask() { snapshotHandler.close() } } + + class Dimensions( + val screenWidthInPx: Int, + val screenHeightInPx: Int + ) + + object ScreenDimensions { + fun dimensions( + parsedDevice: Device, + widthDp: Int, + heightDp: Int + ): Dimensions { + val conversionFactor = parsedDevice.densityDpi / 160f + val previewWidthInPx = ceil(widthDp * conversionFactor).toInt() + val previewHeightInPx = ceil(heightDp * conversionFactor).toInt() + return Dimensions( + screenHeightInPx = when (heightDp > 0) { + true -> previewHeightInPx + false -> parsedDevice.dimensions.height.toInt() + }, + screenWidthInPx = when (widthDp > 0) { + true -> previewWidthInPx + false -> parsedDevice.dimensions.width.toInt() + } + ) + } + } + + object DeviceConfigBuilder { + fun build(preview: AndroidPreviewInfo): DeviceConfig { + val parsedDevice = + DevicePreviewInfoParser.parse(preview.device)?.inPx() ?: return DeviceConfig() + + val dimensions = ScreenDimensions.dimensions( + parsedDevice = parsedDevice, + widthDp = preview.widthDp, + heightDp = preview.heightDp + ) + + return DeviceConfig( + screenHeight = dimensions.screenHeightInPx, + screenWidth = dimensions.screenWidthInPx, + density = Density(parsedDevice.densityDpi), + xdpi = parsedDevice.densityDpi, // not 100% precise + ydpi = parsedDevice.densityDpi, // not 100% precise + size = ScreenSize.valueOf(parsedDevice.screenSize.name), + ratio = ScreenRatio.valueOf(parsedDevice.screenRatio.name), + screenRound = ScreenRound.valueOf(parsedDevice.shape.name), + orientation = ScreenOrientation.valueOf(parsedDevice.orientation.name), + locale = preview.locale.ifBlank { "en" }, + fontScale = preview.fontScale, + nightMode = when (preview.uiMode and UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES) { + true -> NightMode.NIGHT + false -> NightMode.NOTNIGHT + } + ) + } + } 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 @@ -286,23 +338,32 @@ 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() + } + private val shardedCachedPreviews: Map>> by lazy { + cachedPreviews + .mapIndexed { index, preview -> index % $numShards to preview } + .groupBy({ it.first }, { it.second }) + } + """.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 +404,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..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,6 +30,7 @@ fun setupGenerateComposablePreviewPaparazziTestsTask( task.includePrivatePreviews.set(extension.includePrivatePreviews) task.testClassName.set(extension.testClassName) task.testPackageName.set(extension.testPackageName) + task.generatedTestClassCount.set(extension.generatedTestClassCount) } // The tests are now generated directly in src/test/kotlin, so no need to add source directories