Skip to content

Instantly share code, notes, and snippets.

@kozaxinan
Last active July 13, 2024 20:05
Show Gist options
  • Save kozaxinan/4b44f817b28ac409f950c041c3627eca to your computer and use it in GitHub Desktop.
Save kozaxinan/4b44f817b28ac409f950c041c3627eca to your computer and use it in GitHub Desktop.
This version of paparazzi plugin changes the cache setup for Verify and Record tasks. Original version of the PaparazziPlugin adds report and snapshot folder as output and cause cache violations. Paparazzi Rule changes the default handler to verification instead of report.
/*
* Copyright (C) 2019 Square, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.gradle.plugins
import app.cash.paparazzi.gradle.PaparazziPlugin
import app.cash.paparazzi.gradle.PrepareResourcesTask
import com.android.build.gradle.BaseExtension
import com.android.build.gradle.LibraryExtension
import com.android.build.gradle.TestedExtension
import com.android.build.gradle.api.BaseVariant
import com.android.build.gradle.internal.api.TestedVariant
import com.android.build.gradle.internal.dsl.BaseAppModuleExtension
import com.android.build.gradle.internal.dsl.DynamicFeatureExtension
import com.android.build.gradle.internal.publishing.AndroidArtifacts.ArtifactType
import com.roadrunner.gradle.extentions.getDep
import org.gradle.api.DomainObjectSet
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.artifacts.ArtifactCollection
import org.gradle.api.artifacts.ArtifactView
import org.gradle.api.artifacts.Configuration
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.api.artifacts.component.ComponentIdentifier
import org.gradle.api.artifacts.component.ProjectComponentIdentifier
import org.gradle.api.artifacts.type.ArtifactTypeDefinition
import org.gradle.api.artifacts.type.ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE
import org.gradle.api.file.Directory
import org.gradle.api.file.FileCollection
import org.gradle.api.internal.artifacts.transform.UnzipTransform
import org.gradle.api.logging.LogLevel.LIFECYCLE
import org.gradle.api.plugins.JavaBasePlugin
import org.gradle.api.provider.Provider
import org.gradle.api.reporting.ReportingExtension
import org.gradle.api.tasks.Delete
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.testing.Test
import org.gradle.internal.os.OperatingSystem
import org.gradle.kotlin.dsl.*
import org.gradle.language.base.plugins.LifecycleBasePlugin.VERIFICATION_GROUP
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.plugin.KotlinAndroidPluginWrapper
import org.jetbrains.kotlin.gradle.plugin.KotlinMultiplatformPluginWrapper
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinAndroidTarget
import java.io.File
import java.util.Locale
import kotlin.collections.List
import kotlin.collections.any
import kotlin.collections.filterKeys
import kotlin.collections.flatMap
import kotlin.collections.forEach
import kotlin.collections.joinToString
import kotlin.collections.listOf
import kotlin.collections.map
import kotlin.collections.set
@Suppress("unused")
public class PaparazziPlugin : Plugin<Project> {
override fun apply(project: Project) {
val supportedPlugins = listOf("com.android.application", "com.android.library", "com.android.dynamic-feature")
project.afterEvaluate {
check(supportedPlugins.any { project.plugins.hasPlugin(it) }) {
"One of ${supportedPlugins.joinToString(", ")} must be applied for Paparazzi to work properly."
}
}
supportedPlugins.forEach { plugin ->
project.plugins.withId(plugin) {
val variants = when (val extension = project.extensions.getByType(TestedExtension::class.java)) {
is LibraryExtension -> extension.libraryVariants
is BaseAppModuleExtension -> extension.applicationVariants
is DynamicFeatureExtension -> extension.applicationVariants
// exhaustive to avoid potential breaking changes in future AGP releases
else -> error("${extension.javaClass.name} from $plugin is not supported in Paparazzi")
}
setupPaparazzi(project, variants)
}
}
}
private fun <T> setupPaparazzi(
project: Project,
variants: DomainObjectSet<T>
) where T : BaseVariant, T : TestedVariant {
project.addTestDependency()
val nativePlatformFileCollection = project.setupNativePlatformDependency()
val snapshotOutputDir = project.layout.projectDirectory.dir("src/test/snapshots")
// Create anchor tasks for all variants.
val verifyVariants = project.tasks.register("verifyPaparazzi") {
group = VERIFICATION_GROUP
description = "Run screenshot tests for all variants"
}
val recordVariants = project.tasks.register("recordPaparazzi") {
group = VERIFICATION_GROUP
description = "Record golden images for all variants"
}
val cleanRecordVariants = project.tasks.register("cleanRecordPaparazzi") {
group = VERIFICATION_GROUP
description = "Clean and record golden images for all variants"
}
val deleteSnapshots = project.tasks.register("deletePaparazziSnapshots", Delete::class.java) {
group = VERIFICATION_GROUP
description = "Delete all golden images"
val files = project.fileTree(snapshotOutputDir) {
include("**/*.png")
include("**/*.mov")
}
delete(files)
}
variants.configureEach {
val variant = this
val variantSlug = variant.name.capitalize(Locale.US)
val testVariant = variant.unitTestVariant ?: return@configureEach
val projectDirectory = project.layout.projectDirectory
val buildDirectory = project.layout.buildDirectory
val gradleUserHomeDir = project.gradle.gradleUserHomeDir
val reportOutputDir =
project.extensions.getByType(ReportingExtension::class.java).baseDirectory.dir("paparazzi/${variant.name}")
val localResourceDirs = project
.files(variant.sourceSets.flatMap { it.resDirectories })
// https://android.googlesource.com/platform/tools/base/+/96015063acd3455a76cdf1cc71b23b0828c0907f/build-system/gradle-core/src/main/java/com/android/build/gradle/tasks/MergeResources.kt#875
val moduleResourceDirs = variant.runtimeConfiguration
.artifactsFor(ArtifactType.ANDROID_RES.type) { it is ProjectComponentIdentifier }
.artifactFiles
val aarExplodedDirs = variant.runtimeConfiguration
.artifactsFor(ArtifactType.ANDROID_RES.type) { it !is ProjectComponentIdentifier }
.artifactFiles
val localAssetDirs = project
.files(variant.sourceSets.flatMap { it.assetsDirectories })
// https://android.googlesource.com/platform/tools/base/+/96015063acd3455a76cdf1cc71b23b0828c0907f/build-system/gradle-core/src/main/java/com/android/build/gradle/tasks/MergeResources.kt#875
val moduleAssetDirs = variant.runtimeConfiguration
.artifactsFor(ArtifactType.ASSETS.type) { it is ProjectComponentIdentifier }
.artifactFiles
val aarAssetDirs = variant.runtimeConfiguration
.artifactsFor(ArtifactType.ASSETS.type) { it !is ProjectComponentIdentifier }
.artifactFiles
val packageAwareArtifactFiles = variant.runtimeConfiguration
.artifactsFor(ArtifactType.SYMBOL_LIST_WITH_PACKAGE_NAME.type)
.artifactFiles
val writeResourcesTask = project.tasks.register(
"preparePaparazzi${variantSlug}Resources",
PrepareResourcesTask::class.java
) {
val task = this
val android = project.extensions.getByType(BaseExtension::class.java)
val nonTransitiveRClassEnabled =
(project.findProperty("android.nonTransitiveRClass") as? String)?.toBoolean() ?: true
val gradleHomeDir = projectDirectory.dir(project.gradle.gradleUserHomeDir.path)
task.packageName.set(android.packageName())
task.artifactFiles.from(packageAwareArtifactFiles)
task.nonTransitiveRClassEnabled.set(nonTransitiveRClassEnabled)
task.targetSdkVersion.set(android.targetSdkVersion())
task.compileSdkVersion.set(android.compileSdkVersion())
task.projectResourceDirs.set(
run {
val resourcesComputer = variant.mergeResourcesProvider
val extraGeneratedResDirs =
resourcesComputer?.map { it.resourcesComputer.extraGeneratedResFolders }
?: project.provider { project.files() }
val generatedResOutputDirs =
resourcesComputer?.map { it.resourcesComputer.generatedResOutputDir }
?: project.provider { project.files() }
extraGeneratedResDirs
.zip(project.provider { localResourceDirs }, FileCollection::plus)
.zip(generatedResOutputDirs, FileCollection::plus)
.flatMap { it.relativize(projectDirectory) }
}
)
task.moduleResourceDirs.set(moduleResourceDirs.relativize(projectDirectory))
task.aarExplodedDirs.set(aarExplodedDirs.relativize(gradleHomeDir))
task.projectAssetDirs.set(localAssetDirs.plus(moduleAssetDirs).relativize(projectDirectory))
task.aarAssetDirs.set(aarAssetDirs.relativize(gradleHomeDir))
task.paparazziResources.set(buildDirectory.file("intermediates/paparazzi/${variant.name}/resources.json"))
}
val testVariantSlug = testVariant.name.capitalize(Locale.US)
project.plugins.withType(JavaBasePlugin::class.java) {
project.tasks.named("compile${testVariantSlug}JavaWithJavac")
.configure { dependsOn(writeResourcesTask) }
}
project.plugins.withType(KotlinMultiplatformPluginWrapper::class.java) {
val multiplatformExtension =
project.extensions.getByType(KotlinMultiplatformExtension::class.java)
check(multiplatformExtension.targets.any { target -> target is KotlinAndroidTarget }) {
"There must be an Android target configured when using Paparazzi with the Kotlin Multiplatform Plugin"
}
project.tasks.named("compile${testVariantSlug}KotlinAndroid")
.configure { dependsOn(writeResourcesTask) }
}
project.plugins.withType(KotlinAndroidPluginWrapper::class.java) {
project.tasks.named("compile${testVariantSlug}Kotlin")
.configure { dependsOn(writeResourcesTask) }
}
val recordTaskProvider =
project.tasks.register("recordPaparazzi$variantSlug", PaparazziPlugin.PaparazziTask::class.java) {
group = VERIFICATION_GROUP
description = "Record golden images for variant '${name}'"
mustRunAfter(deleteSnapshots)
}
recordVariants.configure { dependsOn(recordTaskProvider) }
val cleanRecordTaskProvider = project.tasks.register("cleanRecordPaparazzi$variantSlug") {
group = VERIFICATION_GROUP
description = "Clean and record golden images for variant '${name}'"
dependsOn(deleteSnapshots, recordTaskProvider)
}
cleanRecordVariants.configure { dependsOn(cleanRecordTaskProvider) }
val verifyTaskProvider =
project.tasks.register("verifyPaparazzi$variantSlug", PaparazziPlugin.PaparazziTask::class.java) {
group = VERIFICATION_GROUP
description = "Run screenshot tests for variant '${name}'"
doFirst {
throw IllegalStateException("Paparazzi verify task is not supported. Use testDebugUnitTest to verify. Use recordPaparazzi to record images. Use reportPaparazzi to create report.")
}
}
verifyVariants.configure { dependsOn(verifyTaskProvider) }
val isRecordRun = project.objects.property(Boolean::class.java)
val isVerifyRun = project.objects.property(Boolean::class.java)
project.gradle.taskGraph.whenReady {
isRecordRun.set(recordTaskProvider.map { hasTask(it) })
isVerifyRun.set(verifyTaskProvider.map { hasTask(it) })
}
val testTaskProvider = project.tasks.named("test$testVariantSlug", Test::class.java) {
val test = this
test.systemProperties["paparazzi.test.resources"] =
writeResourcesTask.flatMap { it.paparazziResources.asFile }.get().path
test.systemProperties["paparazzi.project.dir"] = projectDirectory.toString()
test.systemProperties["paparazzi.build.dir"] = buildDirectory.get().toString()
test.systemProperties["paparazzi.report.dir"] = reportOutputDir.get().toString()
test.systemProperties["paparazzi.snapshot.dir"] = snapshotOutputDir.toString()
test.systemProperties["paparazzi.artifacts.cache.dir"] = gradleUserHomeDir.path
test.systemProperties.putAll(project.properties.filterKeys { it.startsWith("app.cash.paparazzi") })
test.inputs.property("paparazzi.test.record", isRecordRun)
test.inputs.property("paparazzi.test.verify", isVerifyRun)
test.inputs.files(localResourceDirs)
.withPropertyName("paparazzi.localResourceDirs")
.withPathSensitivity(PathSensitivity.RELATIVE)
test.inputs.files(moduleResourceDirs)
.withPropertyName("paparazzi.moduleResourceDirs")
.withPathSensitivity(PathSensitivity.RELATIVE)
test.inputs.files(localAssetDirs)
.withPropertyName("paparazzi.localAssetDirs")
.withPathSensitivity(PathSensitivity.RELATIVE)
test.inputs.files(moduleAssetDirs)
.withPropertyName("paparazzi.moduleAssetDirs")
.withPathSensitivity(PathSensitivity.RELATIVE)
test.inputs.files(nativePlatformFileCollection)
.withPropertyName("paparazzi.nativePlatform")
.withPathSensitivity(PathSensitivity.NONE)
// Report folder in aggregating with every run. Reports are not reproducible.
// test.outputs.dir(reportOutputDir)
// Snapshots are input of verify task at best.
// test.outputs.dir(snapshotOutputDir)
test.inputs.files(snapshotOutputDir.asFileTree) // this is input instead of output because it's used in the test
test.outputs.upToDateWhen { !isRecordRun.get() }
test.outputs.cacheIf { !isRecordRun.get() }
// -------- End of changes --------
test.doFirst {
// Note: these are lazy properties that are not resolvable in the Gradle configuration phase.
// They need special handling, so they're added as inputs.property above, and systemProperty here.
test.systemProperties["paparazzi.platform.data.root"] =
nativePlatformFileCollection.singleFile.absolutePath
test.systemProperties["paparazzi.test.record"] = isRecordRun.get()
test.systemProperties["paparazzi.test.verify"] = isVerifyRun.get()
}
test.doLast {
val uri = reportOutputDir.get().asFile.toPath().resolve("index.html").toUri()
test.logger.log(LIFECYCLE, "See the Paparazzi report at: $uri")
}
}
recordTaskProvider.configure { dependsOn(testTaskProvider) }
verifyTaskProvider.configure { dependsOn(testTaskProvider) }
}
}
private fun Project.setupNativePlatformDependency(): FileCollection {
val operatingSystem = OperatingSystem.current()
val nativeLibraryArtifactId = when {
operatingSystem.isMacOsX -> {
val osArch = System.getProperty("os.arch").lowercase(Locale.US)
if (osArch.startsWith("x86")) "macosx" else "macarm"
}
operatingSystem.isWindows -> "win"
else -> "linux"
}
val nativePlatformConfiguration = configurations.create("nativePlatform")
nativePlatformConfiguration.dependencies.add(
dependencies.create("app.cash.paparazzi:layoutlib-native-$nativeLibraryArtifactId:$NATIVE_LIB_VERSION")
)
dependencies.registerTransform(UnzipTransform::class.java) {
from.attribute(ARTIFACT_TYPE_ATTRIBUTE, ArtifactTypeDefinition.JAR_TYPE)
to.attribute(ARTIFACT_TYPE_ATTRIBUTE, ArtifactTypeDefinition.DIRECTORY_TYPE)
}
return nativePlatformConfiguration
.artifactViewFor(ArtifactTypeDefinition.DIRECTORY_TYPE)
.files
}
/**
* This part modified as we don't have rest of the paparazzi source
**/
private fun Project.addTestDependency() {
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
dependencies {
add("testImplementation", libs.getDep("paparazzi"))
}
}
private fun BaseExtension.packageName(): String = namespace ?: ""
private fun BaseExtension.compileSdkVersion(): String {
return compileSdkVersion!!.substringAfter("android-", DEFAULT_COMPILE_SDK_VERSION.toString())
}
private fun BaseExtension.targetSdkVersion(): String {
return defaultConfig.targetSdkVersion?.apiLevel?.toString()
?: DEFAULT_COMPILE_SDK_VERSION.toString()
}
}
private const val DEFAULT_COMPILE_SDK_VERSION = 34
private const val NATIVE_LIB_VERSION = "2023.2.1-6c7316c"
internal fun Configuration.artifactsFor(
attrValue: String,
componentFilter: (ComponentIdentifier) -> Boolean = { true }
): ArtifactCollection =
artifactViewFor(attrValue, componentFilter).artifacts
internal fun Configuration.artifactViewFor(
attrValue: String,
componentFilter: (ComponentIdentifier) -> Boolean = { true }
): ArtifactView =
incoming.artifactView {
attributes.attribute(ARTIFACT_TYPE_ATTRIBUTE, attrValue)
componentFilter(componentFilter)
}
internal fun FileCollection.relativize(directory: Directory): Provider<List<String>> =
elements.map { files -> files.map { file -> directory.relativize(file.asFile) } }
internal fun Directory.relativize(child: File): String {
return asFile.toPath().relativize(child.toPath()).toFile().invariantSeparatorsPath
}
package com.test.screenshot.paparazzi
import app.cash.paparazzi.HtmlReportWriter
import app.cash.paparazzi.Paparazzi
import app.cash.paparazzi.SnapshotHandler
import app.cash.paparazzi.SnapshotVerifier
import com.android.ide.common.rendering.api.SessionParams.RenderingMode
private const val MAX_PERCENT_DIFFERENCE = 0.1
fun paparazziRule() = Paparazzi(
renderingMode = RenderingMode.SHRINK,
snapshotHandler = determineHandler(MAX_PERCENT_DIFFERENCE),
)
private val isRecording: Boolean =
System.getProperty("paparazzi.test.record")?.toBoolean() == true
private fun determineHandler(maxPercentDifference: Double): SnapshotHandler =
if (isRecording) {
HtmlReportWriter()
} else {
SnapshotVerifier(maxPercentDifference)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment