Last active
July 13, 2024 20:05
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* 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 | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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