|
| 1 | +package io.sentry.android.gradle.snapshot |
| 2 | + |
| 3 | +import java.io.File |
| 4 | +import org.gradle.api.DefaultTask |
| 5 | +import org.gradle.api.Project |
| 6 | +import org.gradle.api.file.DirectoryProperty |
| 7 | +import org.gradle.api.provider.ListProperty |
| 8 | +import org.gradle.api.provider.Property |
| 9 | +import org.gradle.api.tasks.CacheableTask |
| 10 | +import org.gradle.api.tasks.Input |
| 11 | +import org.gradle.api.tasks.OutputDirectory |
| 12 | +import org.gradle.api.tasks.TaskAction |
| 13 | +import org.gradle.api.tasks.TaskProvider |
| 14 | + |
| 15 | +@CacheableTask |
| 16 | +abstract class GenerateSnapshotTestsTask : DefaultTask() { |
| 17 | + |
| 18 | + init { |
| 19 | + description = |
| 20 | + "Generates a parameterized Paparazzi test that snapshots all Compose @Preview composables" |
| 21 | + } |
| 22 | + |
| 23 | + @get:Input abstract val includePrivatePreviews: Property<Boolean> |
| 24 | + |
| 25 | + @get:Input abstract val packageTrees: ListProperty<String> |
| 26 | + |
| 27 | + @get:OutputDirectory abstract val outputDir: DirectoryProperty |
| 28 | + |
| 29 | + @TaskAction |
| 30 | + fun generate() { |
| 31 | + val outDir = outputDir.get().asFile |
| 32 | + if (outDir.exists()) { |
| 33 | + outDir.deleteRecursively() |
| 34 | + } |
| 35 | + |
| 36 | + val packageDir = File(outDir, PACKAGE_NAME.replace('.', '/')) |
| 37 | + packageDir.mkdirs() |
| 38 | + |
| 39 | + val content = |
| 40 | + generateTestFileContent( |
| 41 | + includePrivatePreviews = includePrivatePreviews.get(), |
| 42 | + packageTrees = packageTrees.get(), |
| 43 | + ) |
| 44 | + File(packageDir, "$CLASS_NAME.kt").writeText(content) |
| 45 | + logger.lifecycle("Generated snapshot test: ${packageDir.absolutePath}/$CLASS_NAME.kt") |
| 46 | + } |
| 47 | + |
| 48 | + companion object { |
| 49 | + private const val PACKAGE_NAME = "io.sentry.snapshot" |
| 50 | + private const val CLASS_NAME = "ComposablePreviewSnapshotTest" |
| 51 | + |
| 52 | + fun register( |
| 53 | + project: Project, |
| 54 | + extension: SentrySnapshotExtension, |
| 55 | + android: com.android.build.gradle.BaseExtension, |
| 56 | + ): TaskProvider<GenerateSnapshotTestsTask> { |
| 57 | + return project.tasks.register( |
| 58 | + "generateSnapshotTests", |
| 59 | + GenerateSnapshotTestsTask::class.java, |
| 60 | + ) { task -> |
| 61 | + task.includePrivatePreviews.set(extension.includePrivatePreviews) |
| 62 | + // Fall back to the Android namespace when the user doesn't configure packageTrees |
| 63 | + task.packageTrees.set( |
| 64 | + extension.packageTrees.map { packages -> |
| 65 | + packages.ifEmpty { listOf(android.namespace!!) } |
| 66 | + } |
| 67 | + ) |
| 68 | + task.outputDir.set(project.layout.buildDirectory.dir("generated/sentry/snapshotTests")) |
| 69 | + } |
| 70 | + } |
| 71 | + |
| 72 | + @Suppress("LongMethod") |
| 73 | + private fun generateTestFileContent( |
| 74 | + includePrivatePreviews: Boolean, |
| 75 | + packageTrees: List<String>, |
| 76 | + ): String { |
| 77 | + val includePrivateExpr = |
| 78 | + if (includePrivatePreviews) "\n .includePrivatePreviews()" else "" |
| 79 | + |
| 80 | + val packages = packageTrees.joinToString(", ") { "\"$it\"" } |
| 81 | + val scanExpr = ".scanPackageTrees($packages)" |
| 82 | + |
| 83 | + return """ |
| 84 | +package $PACKAGE_NAME |
| 85 | +
|
| 86 | +import android.content.res.Configuration.UI_MODE_NIGHT_MASK |
| 87 | +import android.content.res.Configuration.UI_MODE_NIGHT_YES |
| 88 | +import androidx.compose.foundation.background |
| 89 | +import androidx.compose.foundation.layout.Box |
| 90 | +import androidx.compose.foundation.layout.size |
| 91 | +import androidx.compose.runtime.Composable |
| 92 | +import androidx.compose.ui.Modifier |
| 93 | +import androidx.compose.ui.graphics.Color |
| 94 | +import androidx.compose.ui.unit.dp |
| 95 | +import app.cash.paparazzi.DeviceConfig |
| 96 | +import app.cash.paparazzi.HtmlReportWriter |
| 97 | +import app.cash.paparazzi.Paparazzi |
| 98 | +import app.cash.paparazzi.Snapshot |
| 99 | +import app.cash.paparazzi.SnapshotHandler |
| 100 | +import app.cash.paparazzi.SnapshotVerifier |
| 101 | +import app.cash.paparazzi.TestName |
| 102 | +import app.cash.paparazzi.detectEnvironment |
| 103 | +import com.android.ide.common.rendering.api.SessionParams |
| 104 | +import com.android.resources.* |
| 105 | +import kotlin.math.ceil |
| 106 | +import org.junit.Rule |
| 107 | +import org.junit.Test |
| 108 | +import org.junit.runner.RunWith |
| 109 | +import org.junit.runners.Parameterized |
| 110 | +import sergio.sastre.composable.preview.scanner.android.AndroidComposablePreviewScanner |
| 111 | +import sergio.sastre.composable.preview.scanner.android.AndroidPreviewInfo |
| 112 | +import sergio.sastre.composable.preview.scanner.android.device.DevicePreviewInfoParser |
| 113 | +import sergio.sastre.composable.preview.scanner.android.device.domain.Device |
| 114 | +import sergio.sastre.composable.preview.scanner.android.device.types.DEFAULT |
| 115 | +import sergio.sastre.composable.preview.scanner.android.screenshotid.AndroidPreviewScreenshotIdBuilder |
| 116 | +import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview |
| 117 | +
|
| 118 | +private class Dimensions( |
| 119 | + val screenWidthInPx: Int, |
| 120 | + val screenHeightInPx: Int, |
| 121 | +) |
| 122 | +
|
| 123 | +private object ScreenDimensions { |
| 124 | + fun dimensions( |
| 125 | + parsedDevice: Device, |
| 126 | + widthDp: Int, |
| 127 | + heightDp: Int, |
| 128 | + ): Dimensions { |
| 129 | + val conversionFactor = parsedDevice.densityDpi / 160f |
| 130 | + val previewWidthInPx = ceil(widthDp * conversionFactor).toInt() |
| 131 | + val previewHeightInPx = ceil(heightDp * conversionFactor).toInt() |
| 132 | + return Dimensions( |
| 133 | + screenWidthInPx = when (widthDp > 0) { |
| 134 | + true -> previewWidthInPx |
| 135 | + false -> parsedDevice.dimensions.width.toInt() |
| 136 | + }, |
| 137 | + screenHeightInPx = when (heightDp > 0) { |
| 138 | + true -> previewHeightInPx |
| 139 | + false -> parsedDevice.dimensions.height.toInt() |
| 140 | + }, |
| 141 | + ) |
| 142 | + } |
| 143 | +} |
| 144 | +
|
| 145 | +private object DeviceConfigBuilder { |
| 146 | + fun build(preview: AndroidPreviewInfo): DeviceConfig { |
| 147 | + val parsedDevice = |
| 148 | + DevicePreviewInfoParser.parse(preview.device)?.inPx() ?: return DeviceConfig() |
| 149 | +
|
| 150 | + val dimensions = ScreenDimensions.dimensions( |
| 151 | + parsedDevice = parsedDevice, |
| 152 | + widthDp = preview.widthDp, |
| 153 | + heightDp = preview.heightDp, |
| 154 | + ) |
| 155 | +
|
| 156 | + return DeviceConfig( |
| 157 | + screenHeight = dimensions.screenHeightInPx, |
| 158 | + screenWidth = dimensions.screenWidthInPx, |
| 159 | + density = Density(parsedDevice.densityDpi), |
| 160 | + xdpi = parsedDevice.densityDpi, |
| 161 | + ydpi = parsedDevice.densityDpi, |
| 162 | + size = ScreenSize.valueOf(parsedDevice.screenSize.name), |
| 163 | + ratio = ScreenRatio.valueOf(parsedDevice.screenRatio.name), |
| 164 | + screenRound = ScreenRound.valueOf(parsedDevice.shape.name), |
| 165 | + orientation = ScreenOrientation.valueOf(parsedDevice.orientation.name), |
| 166 | + locale = preview.locale.ifBlank { "en" }, |
| 167 | + fontScale = preview.fontScale, |
| 168 | + nightMode = when (preview.uiMode and UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES) { |
| 169 | + true -> NightMode.NIGHT |
| 170 | + false -> NightMode.NOTNIGHT |
| 171 | + }, |
| 172 | + ) |
| 173 | + } |
| 174 | +} |
| 175 | +
|
| 176 | +private val paparazziTestName = |
| 177 | + TestName(packageName = "Paparazzi", className = "Preview", methodName = "Test") |
| 178 | +
|
| 179 | +private class TestNameOverrideHandler( |
| 180 | + private val delegate: SnapshotHandler, |
| 181 | +) : SnapshotHandler { |
| 182 | + override fun newFrameHandler( |
| 183 | + snapshot: Snapshot, |
| 184 | + frameCount: Int, |
| 185 | + fps: Int, |
| 186 | + ): SnapshotHandler.FrameHandler { |
| 187 | + val newSnapshot = Snapshot( |
| 188 | + name = snapshot.name, |
| 189 | + testName = paparazziTestName, |
| 190 | + timestamp = snapshot.timestamp, |
| 191 | + tags = snapshot.tags, |
| 192 | + file = snapshot.file, |
| 193 | + ) |
| 194 | + return delegate.newFrameHandler(newSnapshot, frameCount, fps) |
| 195 | + } |
| 196 | +
|
| 197 | + override fun close() { |
| 198 | + delegate.close() |
| 199 | + } |
| 200 | +} |
| 201 | +
|
| 202 | +private object PaparazziPreviewRule { |
| 203 | + const val UNDEFINED_API_LEVEL = -1 |
| 204 | + const val MAX_API_LEVEL = 36 |
| 205 | +
|
| 206 | + fun createFor(preview: ComposablePreview<AndroidPreviewInfo>): Paparazzi { |
| 207 | + val previewInfo = preview.previewInfo |
| 208 | + val previewApiLevel = when (previewInfo.apiLevel == UNDEFINED_API_LEVEL) { |
| 209 | + true -> MAX_API_LEVEL |
| 210 | + false -> previewInfo.apiLevel |
| 211 | + } |
| 212 | + val tolerance = 0.0 |
| 213 | + return Paparazzi( |
| 214 | + environment = detectEnvironment().copy(compileSdkVersion = previewApiLevel), |
| 215 | + deviceConfig = DeviceConfigBuilder.build(preview.previewInfo), |
| 216 | + supportsRtl = true, |
| 217 | + showSystemUi = previewInfo.showSystemUi, |
| 218 | + renderingMode = when { |
| 219 | + previewInfo.showSystemUi -> SessionParams.RenderingMode.NORMAL |
| 220 | + previewInfo.widthDp > 0 && previewInfo.heightDp > 0 -> SessionParams.RenderingMode.FULL_EXPAND |
| 221 | + else -> SessionParams.RenderingMode.SHRINK |
| 222 | + }, |
| 223 | + snapshotHandler = TestNameOverrideHandler( |
| 224 | + when (System.getProperty("paparazzi.test.verify")?.toBoolean() == true) { |
| 225 | + true -> SnapshotVerifier(maxPercentDifference = tolerance) |
| 226 | + false -> HtmlReportWriter(maxPercentDifference = tolerance) |
| 227 | + } |
| 228 | + ), |
| 229 | + maxPercentDifference = tolerance, |
| 230 | + ) |
| 231 | + } |
| 232 | +} |
| 233 | +
|
| 234 | +@Composable |
| 235 | +private fun SystemUiSize( |
| 236 | + widthInDp: Int, |
| 237 | + heightInDp: Int, |
| 238 | + content: @Composable () -> Unit, |
| 239 | +) { |
| 240 | + Box( |
| 241 | + Modifier |
| 242 | + .size(width = widthInDp.dp, height = heightInDp.dp) |
| 243 | + .background(Color.White) |
| 244 | + ) { |
| 245 | + content() |
| 246 | + } |
| 247 | +} |
| 248 | +
|
| 249 | +@Composable |
| 250 | +private fun PreviewBackground( |
| 251 | + showBackground: Boolean, |
| 252 | + backgroundColor: Long, |
| 253 | + content: @Composable () -> Unit, |
| 254 | +) { |
| 255 | + when (showBackground) { |
| 256 | + false -> content() |
| 257 | + true -> { |
| 258 | + val color = when (backgroundColor != 0L) { |
| 259 | + true -> Color(backgroundColor) |
| 260 | + false -> Color.White |
| 261 | + } |
| 262 | + Box(Modifier.background(color)) { |
| 263 | + content() |
| 264 | + } |
| 265 | + } |
| 266 | + } |
| 267 | +} |
| 268 | +
|
| 269 | +/** |
| 270 | + * Auto-generated by Sentry Snapshot Plugin. |
| 271 | + */ |
| 272 | +@RunWith(Parameterized::class) |
| 273 | +class $CLASS_NAME( |
| 274 | + private val preview: ComposablePreview<AndroidPreviewInfo>, |
| 275 | +) { |
| 276 | +
|
| 277 | + companion object { |
| 278 | + private val cachedPreviews: List<ComposablePreview<AndroidPreviewInfo>> by lazy { |
| 279 | + AndroidComposablePreviewScanner() |
| 280 | + $scanExpr$includePrivateExpr |
| 281 | + .getPreviews() |
| 282 | + } |
| 283 | +
|
| 284 | + @JvmStatic |
| 285 | + @Parameterized.Parameters |
| 286 | + fun values(): List<ComposablePreview<AndroidPreviewInfo>> = cachedPreviews |
| 287 | + } |
| 288 | +
|
| 289 | + @get:Rule |
| 290 | + val paparazzi: Paparazzi = PaparazziPreviewRule.createFor(preview) |
| 291 | +
|
| 292 | + @Test |
| 293 | + fun snapshot() { |
| 294 | + val screenshotId = AndroidPreviewScreenshotIdBuilder(preview) |
| 295 | + .doNotIgnoreMethodParametersType() |
| 296 | + .encodeUnsafeCharacters() |
| 297 | + .build() |
| 298 | +
|
| 299 | + paparazzi.snapshot(name = screenshotId) { |
| 300 | + val previewInfo = preview.previewInfo |
| 301 | + when (previewInfo.showSystemUi) { |
| 302 | + false -> PreviewBackground( |
| 303 | + showBackground = previewInfo.showBackground, |
| 304 | + backgroundColor = previewInfo.backgroundColor, |
| 305 | + ) { |
| 306 | + preview() |
| 307 | + } |
| 308 | +
|
| 309 | + true -> { |
| 310 | + val parsedDevice = (DevicePreviewInfoParser.parse(previewInfo.device) ?: DEFAULT).inDp() |
| 311 | + SystemUiSize( |
| 312 | + widthInDp = parsedDevice.dimensions.width.toInt(), |
| 313 | + heightInDp = parsedDevice.dimensions.height.toInt(), |
| 314 | + ) { |
| 315 | + PreviewBackground( |
| 316 | + showBackground = true, |
| 317 | + backgroundColor = previewInfo.backgroundColor, |
| 318 | + ) { |
| 319 | + preview() |
| 320 | + } |
| 321 | + } |
| 322 | + } |
| 323 | + } |
| 324 | + } |
| 325 | + } |
| 326 | +} |
| 327 | +""" |
| 328 | + .trimStart() |
| 329 | + } |
| 330 | + } |
| 331 | +} |
0 commit comments