Skip to content
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<AndroidPreviewInfo>): Paparazzi {
val previewInfo = preview.previewInfo
Expand Down
7 changes: 7 additions & 0 deletions paparazzi-plugin-tests/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ android {
kotlinOptions {
jvmTarget = "11"
}

testOptions {
unitTests.all {
it.maxParallelForks = maxOf(1, Runtime.getRuntime().availableProcessors() / 2)
}
}
Comment thread
sergio-sastre marked this conversation as resolved.
}

// Execute ./gradlew :paparazzi-plugin-tests:recordPaparazziDebug
Expand All @@ -40,6 +46,7 @@ composablePreviewPaparazzi {
includePrivatePreviews = true
testClassName = "GeneratedPaparazziTests"
testPackageName = "preview.generated"
generatedTestClassCount = 2
}

dependencies {
Expand Down
28 changes: 28 additions & 0 deletions paparazzi-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Test> {
// 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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,9 @@ open class ComposablePreviewPaparazziExtension @Inject constructor(objects: Obje
* The package name for the generated test class.
*/
val testPackageName: Property<String> = objects.property(String::class.java)

/**
* Number of test classes to split the generated parameterized tests into.
*/
val generatedTestClassCount: Property<Int> = objects.property(Int::class.java)
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,16 @@ class ComposablePreviewPaparazziPlugin : Plugin<Project> {
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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ abstract class GenerateComposablePreviewPaparazziTestsTask : DefaultTask() {
@get:Input
abstract val testPackageName: Property<String>

@get:Input
abstract val generatedTestClassCount: Property<Int>

@TaskAction
fun generateTests() {
val testDir = outputDir.get().asFile
Expand All @@ -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
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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<AndroidPreviewInfo>): Paparazzi {
val previewInfo = preview.previewInfo
Expand Down Expand Up @@ -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<ComposablePreview<AndroidPreviewInfo>> by lazy {
AndroidComposablePreviewScanner()
.scanPackageTrees($packagesExpr)
${if (includePrivatePreviewsExpr) ".includePrivatePreviews()" else ""}
.getPreviews()
}

private val shardedCachedPreviews: Map<Int, List<ComposablePreview<AndroidPreviewInfo>>> 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<AndroidPreviewInfo>,
) {

companion object {
private val cachedPreviews: List<ComposablePreview<AndroidPreviewInfo>> by lazy {
AndroidComposablePreviewScanner()
.scanPackageTrees($packagesExpr)
${if (includePrivatePreviewsExpr) ".includePrivatePreviews()" else ""}
.getPreviews()
}

@JvmStatic
@Parameterized.Parameters
fun values(): List<ComposablePreview<AndroidPreviewInfo>> = cachedPreviews
fun values(): List<ComposablePreview<AndroidPreviewInfo>> = $valuesExpr
}

@get:Rule
Expand Down Expand Up @@ -343,6 +404,8 @@ abstract class GenerateComposablePreviewPaparazziTestsTask : DefaultTask() {
}
}
}
""".trimIndent()
""".trimIndent()

return if (includeHeader) "$header\n\n$classSection" else classSection
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading