From eba5eab1f6902a684f4872a0c92f9b18b65be57d Mon Sep 17 00:00:00 2001 From: Ryan Harter Date: Tue, 12 Nov 2024 09:18:19 -0600 Subject: [PATCH 01/21] Adds config model. --- build.gradle.kts | 1 + dropshots-gradle-plugin/build.gradle.kts | 3 +- gradle/libs.versions.toml | 2 ++ model/build.gradle.kts | 15 +++++++++ .../dropbox/dropshots/model/TestRunConfig.kt | 32 +++++++++++++++++++ settings.gradle.kts | 3 ++ 6 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 model/build.gradle.kts create mode 100644 model/src/main/kotlin/com/dropbox/dropshots/model/TestRunConfig.kt diff --git a/build.gradle.kts b/build.gradle.kts index bc89002..0259155 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,6 +5,7 @@ import com.vanniktech.maven.publish.SonatypeHost plugins { alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlinx.serialization) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.ktlint) alias(libs.plugins.mavenPublish) diff --git a/dropshots-gradle-plugin/build.gradle.kts b/dropshots-gradle-plugin/build.gradle.kts index 7ce2c42..58de322 100644 --- a/dropshots-gradle-plugin/build.gradle.kts +++ b/dropshots-gradle-plugin/build.gradle.kts @@ -94,8 +94,9 @@ val releaseMode = hasProperty("dropshots.releaseMode") dependencies { compileOnly(gradleApi()) implementation(platform(libs.kotlin.bom)) - // Don't impose our version of KGP on consumers + implementation(projects.model) + // Don't impose our version of KGP on consumers if (releaseMode) { compileOnly(libs.android) compileOnly(libs.kotlin.plugin) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 003edcb..2c96b2e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,7 @@ truth = "com.google.truth:truth:1.4.4" kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" } kotlin-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version = "1.7.3" } [plugins] android-library = { id = "com.android.library", version.ref = "agp" } @@ -26,6 +27,7 @@ android-application = { id = "com.android.application", version.ref = "agp" } dokka = { id = "org.jetbrains.dokka", version = "1.9.20" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version = "12.1.1" } mavenPublish = { id = "com.vanniktech.maven.publish.base", version = "0.30.0" } binaryCompatibilityValidator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version = "0.16.3" } diff --git a/model/build.gradle.kts b/model/build.gradle.kts new file mode 100644 index 0000000..d44cb73 --- /dev/null +++ b/model/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlinx.serialization) + alias(libs.plugins.dokka) + alias(libs.plugins.mavenPublish) + alias(libs.plugins.binaryCompatibilityValidator) +} + +kotlin { + explicitApi() +} + +dependencies { + implementation(libs.kotlinx.serialization) +} diff --git a/model/src/main/kotlin/com/dropbox/dropshots/model/TestRunConfig.kt b/model/src/main/kotlin/com/dropbox/dropshots/model/TestRunConfig.kt new file mode 100644 index 0000000..e107fff --- /dev/null +++ b/model/src/main/kotlin/com/dropbox/dropshots/model/TestRunConfig.kt @@ -0,0 +1,32 @@ +package com.dropbox.dropshots.model + +import java.io.InputStream +import java.io.OutputStream +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.cbor.Cbor +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray + +/** + * Test run configuration passed from the Dropshots Gradle plugin to the + * runtime via a serialized file written to the test storage location. + */ +@Serializable +public data class TestRunConfig( + val isRecording: Boolean, + val deviceName: String, +) { + public companion object { + @OptIn(ExperimentalSerializationApi::class) + public fun read(inputStream: InputStream): TestRunConfig { + return Cbor.decodeFromByteArray(inputStream.readAllBytes()) + } + } + + @OptIn(ExperimentalSerializationApi::class) + public fun write(outputStream: OutputStream) { + outputStream.write(Cbor.encodeToByteArray(this)) + } +} + diff --git a/settings.gradle.kts b/settings.gradle.kts index 4cab8bb..c416ecb 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -6,7 +6,10 @@ pluginManagement { } } +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + rootProject.name = "dropshots-root" include(":dropshots-gradle-plugin") include(":dropshots") +include(":model") From ff0f24855178a49475371be58a6153a345938bb7 Mon Sep 17 00:00:00 2001 From: Ryan Harter Date: Tue, 12 Nov 2024 11:01:38 -0600 Subject: [PATCH 02/21] Updates Jvm target version config. --- build.gradle.kts | 27 +++++++++++--- dropshots-gradle-plugin/build.gradle.kts | 47 ++---------------------- dropshots/build.gradle.kts | 7 ---- gradle/libs.versions.toml | 1 + model/api/model.api | 31 ++++++++++++++++ settings.gradle.kts | 24 +++++++++++- 6 files changed, 81 insertions(+), 56 deletions(-) create mode 100644 model/api/model.api diff --git a/build.gradle.kts b/build.gradle.kts index 0259155..683b6cf 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,9 @@ import com.vanniktech.maven.publish.MavenPublishBaseExtension import com.vanniktech.maven.publish.SonatypeHost +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion +import org.jetbrains.kotlin.gradle.plugin.KotlinBasePlugin +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { @@ -15,11 +19,6 @@ allprojects { group = project.property("GROUP") as String version = project.property("VERSION_NAME") as String - repositories { - google() - mavenCentral() - } - plugins.withId("com.vanniktech.maven.publish.base") { configure { publishToMavenCentral(SonatypeHost.S01) @@ -37,6 +36,24 @@ allprojects { } } } + + plugins.withType().configureEach { + tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + apiVersion.set(KotlinVersion.KOTLIN_1_9) + languageVersion.set(KotlinVersion.KOTLIN_1_9) + } + } + } + + plugins.withType(JavaBasePlugin::class.java).configureEach { + extensions.configure(JavaPluginExtension::class.java) { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } + } + } } tasks.register("printVersionName") { diff --git a/dropshots-gradle-plugin/build.gradle.kts b/dropshots-gradle-plugin/build.gradle.kts index 58de322..640ca56 100644 --- a/dropshots-gradle-plugin/build.gradle.kts +++ b/dropshots-gradle-plugin/build.gradle.kts @@ -15,32 +15,13 @@ plugins { alias(libs.plugins.binaryCompatibilityValidator) } -buildscript { - repositories { - mavenCentral() - gradlePluginPortal() - } -} - -repositories { - mavenCentral() - gradlePluginPortal() -} - -sourceSets { - main.configure { - java.srcDir("src/generated/kotlin") - } -} - mavenPublishing { configure(GradlePlugin(Dokka("dokkaJavadoc"))) } val generateVersionTask = tasks.register("generateVersion") { inputs.property("version", project.property("VERSION_NAME") as String) - outputs.dir(project.layout.projectDirectory.dir("src/generated/kotlin")) - + outputs.dir(project.layout.buildDirectory.dir("generated/version/kotlin")) doLast { val output = File(outputs.files.first(), "com/dropbox/dropshots/Version.kt") output.parentFile.mkdirs() @@ -52,28 +33,8 @@ val generateVersionTask = tasks.register("generateVersion") { } } -tasks.withType().configureEach { - dependsOn(generateVersionTask) -} - -tasks.named("dokkaJavadoc").configure { - dependsOn(generateVersionTask) -} - -tasks.named("compileKotlin").configure { - dependsOn(generateVersionTask) -} - -tasks.withType().configureEach { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_11) - apiVersion.set(KotlinVersion.KOTLIN_1_8) - languageVersion.set(KotlinVersion.KOTLIN_1_8) - } -} - -tasks.withType().configureEach { - options.release.set(11) +sourceSets.main { + java.srcDir(generateVersionTask) } kotlin { @@ -94,7 +55,7 @@ val releaseMode = hasProperty("dropshots.releaseMode") dependencies { compileOnly(gradleApi()) implementation(platform(libs.kotlin.bom)) - implementation(projects.model) + implementation(project(":model")) // Don't impose our version of KGP on consumers if (releaseMode) { diff --git a/dropshots/build.gradle.kts b/dropshots/build.gradle.kts index 0e59338..2cc5a6c 100644 --- a/dropshots/build.gradle.kts +++ b/dropshots/build.gradle.kts @@ -21,13 +21,6 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = "1.8" - } } kotlin { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2c96b2e..f08472a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] kotlin = "2.0.21" agp = "8.7.2" +jvmTarget = "11" [libraries] android = { module = "com.android.tools.build:gradle", version.ref = "agp" } diff --git a/model/api/model.api b/model/api/model.api new file mode 100644 index 0000000..cf26350 --- /dev/null +++ b/model/api/model.api @@ -0,0 +1,31 @@ +public final class com/dropbox/dropshots/model/TestRunConfig { + public static final field Companion Lcom/dropbox/dropshots/model/TestRunConfig$Companion; + public fun (ZLjava/lang/String;)V + public final fun component1 ()Z + public final fun component2 ()Ljava/lang/String; + public final fun copy (ZLjava/lang/String;)Lcom/dropbox/dropshots/model/TestRunConfig; + public static synthetic fun copy$default (Lcom/dropbox/dropshots/model/TestRunConfig;ZLjava/lang/String;ILjava/lang/Object;)Lcom/dropbox/dropshots/model/TestRunConfig; + public fun equals (Ljava/lang/Object;)Z + public final fun getDeviceName ()Ljava/lang/String; + public fun hashCode ()I + public final fun isRecording ()Z + public fun toString ()Ljava/lang/String; + public final fun write (Ljava/io/OutputStream;)V +} + +public final class com/dropbox/dropshots/model/TestRunConfig$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lcom/dropbox/dropshots/model/TestRunConfig$$serializer; + public fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/dropbox/dropshots/model/TestRunConfig; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/dropbox/dropshots/model/TestRunConfig;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class com/dropbox/dropshots/model/TestRunConfig$Companion { + public final fun read (Ljava/io/InputStream;)Lcom/dropbox/dropshots/model/TestRunConfig; + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + diff --git a/settings.gradle.kts b/settings.gradle.kts index c416ecb..7f70160 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,8 +1,30 @@ pluginManagement { repositories { + google { + @Suppress("UnstableApiUsage") + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() gradlePluginPortal() + } +} + +dependencyResolutionManagement { + @Suppress("UnstableApiUsage") + repositories { + google { + @Suppress("UnstableApiUsage") + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } mavenCentral() - google() } } From ca56683c122f0d9f0fe9a4559aa77f609ec9c2a7 Mon Sep 17 00:00:00 2001 From: Ryan Harter Date: Thu, 14 Nov 2024 09:08:20 -0600 Subject: [PATCH 03/21] Adds support for writing a test run config file to the target device. --- dropshots-gradle-plugin/build.gradle.kts | 5 +- .../com/dropbox/dropshots/DropshotsPlugin.kt | 161 ++++++++++++++++-- .../com/dropbox/dropshots/StringUtils.kt | 6 + .../dropshots/UpdateScreenshotsTask.kt | 45 +++++ .../dropbox/dropshots/WriteConfigFileTask.kt | 72 ++++++++ dropshots/build.gradle.kts | 1 + .../dropshots/CustomImageComparatorTest.kt | 1 - .../com/dropbox/dropshots/DropshotsTest.kt | 38 +++-- .../com/dropbox/dropshots/FileTestStorage.kt | 74 ++++++++ .../java/com/dropbox/dropshots/Dropshots.kt | 115 ++++++------- gradle/libs.versions.toml | 8 +- .../kotlin/com/dropbox/dropshots/Constants.kt | 3 + .../dropbox/dropshots/model/TestRunConfig.kt | 17 +- 13 files changed, 443 insertions(+), 103 deletions(-) create mode 100644 dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/StringUtils.kt create mode 100644 dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/UpdateScreenshotsTask.kt create mode 100644 dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/WriteConfigFileTask.kt create mode 100644 dropshots/src/androidTest/kotlin/com/dropbox/dropshots/FileTestStorage.kt create mode 100644 model/src/main/kotlin/com/dropbox/dropshots/Constants.kt diff --git a/dropshots-gradle-plugin/build.gradle.kts b/dropshots-gradle-plugin/build.gradle.kts index 640ca56..610dd95 100644 --- a/dropshots-gradle-plugin/build.gradle.kts +++ b/dropshots-gradle-plugin/build.gradle.kts @@ -55,7 +55,10 @@ val releaseMode = hasProperty("dropshots.releaseMode") dependencies { compileOnly(gradleApi()) implementation(platform(libs.kotlin.bom)) - implementation(project(":model")) + implementation(libs.android.builder.test) + implementation(libs.android.common) + implementation(libs.android.ddmlib) + implementation(projects.model) // Don't impose our version of KGP on consumers if (releaseMode) { diff --git a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/DropshotsPlugin.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/DropshotsPlugin.kt index c7037b4..8756959 100644 --- a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/DropshotsPlugin.kt +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/DropshotsPlugin.kt @@ -1,32 +1,169 @@ package com.dropbox.dropshots -import com.android.build.gradle.AppExtension -import com.android.build.gradle.LibraryExtension +import com.android.build.api.variant.AndroidComponentsExtension +import com.android.build.api.variant.DeviceTestBuilder +import com.android.build.api.variant.HasDeviceTests +import com.android.build.api.variant.Variant import com.android.build.gradle.TestedExtension +import com.android.build.gradle.api.AndroidBasePlugin import com.android.build.gradle.api.ApkVariant import com.android.build.gradle.internal.tasks.AndroidTestTask +import com.android.build.gradle.internal.tasks.DeviceProviderInstrumentTestTask +import com.android.build.gradle.internal.tasks.ManagedDeviceInstrumentationTestTask +import com.android.build.gradle.internal.tasks.ManagedDeviceTestTask import com.android.build.gradle.internal.tasks.factory.dependsOn +import com.android.build.gradle.options.BooleanOption +import com.android.build.gradle.options.ProjectOptionService +import com.android.builder.core.ComponentType +import com.dropbox.dropshots.model.TestRunConfig +import java.io.File import java.util.Locale import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.api.file.RegularFile +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.plugins.JavaBasePlugin +import org.gradle.api.provider.Provider private const val recordScreenshotsArg = "dropshots.record" public class DropshotsPlugin : Plugin { - override fun apply(project: Project) { - project.pluginManager.withPlugin("com.android.application") { - val extension = project.extensions.findByType(AppExtension::class.java) - ?: throw Exception("Failed to find Android Application extension") - project.configureDropshots(extension) - } + override fun apply(project: Project): Unit = with(project) { + plugins.withType(AndroidBasePlugin::class.java) { plugin -> + + // Add dropshots dependency + afterEvaluate { + it.dependencies.add( + "androidTestImplementation", + "com.dropbox.dropshots:dropshots:$VERSION" + ) + } + + val updateAllTask = tasks.register("updateDropshotsScreenshots") { + it.description = "Updates screenshots for all variants." + it.group = JavaBasePlugin.VERIFICATION_GROUP + } + + val componentsExtension = extensions.getByType(AndroidComponentsExtension::class.java) + val testedExtension = extensions.getByType(TestedExtension::class.java) + + //check this to have resource based on flavours + val androidTestSourceSet = testedExtension.sourceSets.findByName("androidTest") + ?: throw Exception("Failed to find androidTest source set") + + // TODO configure this via extension + val referenceScreenshotDirectory = layout.projectDirectory.dir("src/androidTest/screenshots") + + androidTestSourceSet.assets { + srcDirs(referenceScreenshotDirectory) + } + + @Suppress("UnstableApiUsage") + componentsExtension.onVariants { variant -> + if (!variant.debuggable || variant !is HasDeviceTests) { + return@onVariants + } + + logger.warn("Found testable variant: ${variant.name}") + + val variantName = variant.name + val deviceTestComponent = variant.deviceTests[DeviceTestBuilder.ANDROID_TEST_TYPE] ?: return@onVariants + val adbProvider = provider { testedExtension.adbExecutable } + + // Create a test connected check task + addTasksForDeviceProvider(variant, "connected", adbProvider) + + testedExtension.deviceProviders.forEach { deviceProvider -> + addTasksForDeviceProvider(variant, deviceProvider.name, adbProvider) + } - project.pluginManager.withPlugin("com.android.library") { - val extension = project.extensions.findByType(LibraryExtension::class.java) - ?: throw Exception("Failed to find Android Library extension") - project.configureDropshots(extension) + val optionService = ProjectOptionService.RegistrationAction(this).execute().get() + optionService.projectOptions.get(BooleanOption.ENABLE_ADDITIONAL_ANDROID_TEST_OUTPUT) + } } } + private fun Project.addTasksForDeviceProvider( + variant: Variant, + deviceProviderName: String, + adbProvider: Provider, + ) { + + tasks + .named { + it == "${deviceProviderName}${variant.name.capitalize()}${ComponentType.ANDROID_TEST_SUFFIX}" + } + .all { testTask -> + val testSlug = testTask.name.capitalize() + + val testDataProvider = when (testTask) { + is DeviceProviderInstrumentTestTask -> testTask.testData + is ManagedDeviceInstrumentationTestTask -> testTask.testData + is ManagedDeviceTestTask -> testTask.testData + else -> return@all + } + val defaultTestOutputDirectory = layout.buildDirectory + .dir("outputs/androidTest-results/dropshots/${variant.name}/$deviceProviderName/") + val additionalTestOutputEnabled = when (testTask) { + is DeviceProviderInstrumentTestTask -> testTask.additionalTestOutputEnabled + is ManagedDeviceInstrumentationTestTask -> testTask.getAdditionalTestOutputEnabled() + is ManagedDeviceTestTask -> testTask.getAdditionalTestOutputEnabled() + else -> provider { false } + } + val additionalTestOutputDir = additionalTestOutputEnabled + .flatMap { enabled -> + if (!enabled) { + defaultTestOutputDirectory + } else { + when (testTask) { + is DeviceProviderInstrumentTestTask -> testTask.additionalTestOutputDir + is ManagedDeviceInstrumentationTestTask -> testTask.getAdditionalTestOutputDir() + is ManagedDeviceTestTask -> testTask.getAdditionalTestOutputDir() + else -> defaultTestOutputDirectory + } + } + } + val referenceImageResultDir = additionalTestOutputDir.map { it.dir("dropshots/reference") } + val referenceImageSourceDir = layout.projectDirectory.dir("src/${variant.name}/androidTest/screenshots/$deviceProviderName") + + val updateTaskProvider = tasks.register( + "update${testTask.name.capitalize()}Screenshots", + UpdateScreenshotsTask::class.java, + ) { + if ("connected" == deviceProviderName) { + it.description = "Updates screenshots for ${variant.name} on connected devices." + } else { + it.description = "Updates screenshots for ${variant.name} using provider: ${deviceProviderName.capitalize()}" + } + it.group = JavaBasePlugin.VERIFICATION_GROUP + it.referenceImageDir.set(referenceImageResultDir) + it.outputDir.set(referenceImageSourceDir) + } + updateTaskProvider.dependsOn(testTask.name) + + val isRecordingScreenshots = project.objects.property(Boolean::class.java) + project.gradle.taskGraph.whenReady { graph -> + isRecordingScreenshots.set(updateTaskProvider.map { graph.hasTask(it) }) + } + + val writeConfigTaskProvider = tasks.register( + "write${testTask.name.capitalize()}ScreenshotConfigFile", + WriteConfigFileTask::class.java, + ) { task -> + task.group = JavaBasePlugin.VERIFICATION_GROUP + task.getIsRecording.set(isRecordingScreenshots) + task.adbExecutable.set(adbProvider) + task.remoteDir.set( + testDataProvider.map { testData -> + @Suppress("SdCardPath") + "/sdcard/Android/media/${testData.instrumentationTargetPackageId.get()}/additional_test_output/dropbox" + }, + ) + } + testTask.dependsOn(writeConfigTaskProvider) + } + } + private fun Project.configureDropshots(extension: TestedExtension) { project.afterEvaluate { it.dependencies.add( diff --git a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/StringUtils.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/StringUtils.kt new file mode 100644 index 0000000..dd2da59 --- /dev/null +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/StringUtils.kt @@ -0,0 +1,6 @@ +package com.dropbox.dropshots + +import java.util.Locale + +internal fun String.capitalize(): String = + replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } diff --git a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/UpdateScreenshotsTask.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/UpdateScreenshotsTask.kt new file mode 100644 index 0000000..6d0ae63 --- /dev/null +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/UpdateScreenshotsTask.kt @@ -0,0 +1,45 @@ +package com.dropbox.dropshots + +import java.nio.file.Files +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.copyToRecursively +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.logging.Logging +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault + +@DisableCachingByDefault(because = "Not worth caching") +public abstract class UpdateScreenshotsTask : DefaultTask() { + @get:InputDirectory + public abstract val referenceImageDir: DirectoryProperty + + @get:Optional + @get:Input + public abstract val outputBasePath: Property + + @get:OutputDirectory + public abstract val outputDir: DirectoryProperty + + @OptIn(ExperimentalPathApi::class) + @TaskAction + public fun performAction() { + val from = referenceImageDir.asFile.get().toPath() + val to = outputDir.asFile.get().toPath().let { output -> + if (outputBasePath.isPresent) { + output.resolve(outputBasePath.get()) + } else { + output + } + } + val logger = Logging.getLogger(UpdateScreenshotsTask::class.java) + logger.lifecycle("Copying reference images to $to") + Files.createDirectories(to) + from.copyToRecursively(to, followLinks = true) + } +} diff --git a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/WriteConfigFileTask.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/WriteConfigFileTask.kt new file mode 100644 index 0000000..2fe3588 --- /dev/null +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/WriteConfigFileTask.kt @@ -0,0 +1,72 @@ +package com.dropbox.dropshots + +import com.android.build.gradle.internal.LoggerWrapper +import com.android.build.gradle.internal.testing.ConnectedDeviceProvider +import com.android.ddmlib.DdmPreferences +import com.android.ddmlib.MultiLineReceiver +import com.dropbox.dropshots.model.TestRunConfig +import java.io.File +import java.util.concurrent.TimeUnit +import org.gradle.api.DefaultTask +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault + +@DisableCachingByDefault(because = "Not worth caching") +public abstract class WriteConfigFileTask : DefaultTask() { + + @get:Input + public abstract val getIsRecording: Property + + @get:Input + public abstract val adbExecutable: Property + + @get:Input + public abstract val remoteDir: Property + + init { + description = "Writes Dropshots config file to emulator" + outputs.upToDateWhen { false } + } + + @TaskAction + public fun performAction() { + val iLogger = LoggerWrapper(logger) + val deviceProvider = ConnectedDeviceProvider( + adbExecutable.get(), + DdmPreferences.getTimeOut(), + iLogger, + System.getenv("ANDROID_SERIAL"), + ) + + deviceProvider.use { + @Suppress("UnstableApiUsage") + deviceProvider.devices.forEach { device -> + fun executeShellCommand(command: String, receiver: MultiLineReceiver) { + device.executeShellCommand(command, receiver, DdmPreferences.getTimeOut().toLong(), TimeUnit.MILLISECONDS,) + } + + val deviceName = device.name + val config = TestRunConfig(getIsRecording.get(), deviceName) + val remotePath = remoteDir.get() + val loggingReceiver = object : MultiLineReceiver() { + override fun isCancelled(): Boolean = false + override fun processNewLines(lines: Array?) { + lines?.forEach(logger::info) + } + } + + logger.info("DeviceConnector '$deviceName': creating parent directories $remotePath") + executeShellCommand("mkdir -p $remotePath", loggingReceiver) + + logger.info("DeviceConnector '$deviceName': writing config file to $remotePath") + val configFile = "$remotePath/$configFileName" + executeShellCommand( + "echo '${config.write()}' > $configFile", + loggingReceiver, + ) + } + } + } +} diff --git a/dropshots/build.gradle.kts b/dropshots/build.gradle.kts index 2cc5a6c..0db0188 100644 --- a/dropshots/build.gradle.kts +++ b/dropshots/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(libs.androidx.annotation) implementation(libs.androidx.test.runner) implementation(libs.androidx.test.rules) + implementation(projects.model) debugImplementation(libs.androidx.fragment) diff --git a/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/CustomImageComparatorTest.kt b/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/CustomImageComparatorTest.kt index 905aca7..833390d 100644 --- a/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/CustomImageComparatorTest.kt +++ b/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/CustomImageComparatorTest.kt @@ -25,7 +25,6 @@ class CustomImageComparatorTest { @get:Rule val dropshots = Dropshots( - rootScreenshotDirectory = imageDirectory, filenameFunc = defaultFilenameFunc, recordScreenshots = false, imageComparator = comparator, diff --git a/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/DropshotsTest.kt b/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/DropshotsTest.kt index c1de654..e4c9a73 100644 --- a/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/DropshotsTest.kt +++ b/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/DropshotsTest.kt @@ -3,9 +3,13 @@ package com.dropbox.dropshots import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color +import android.net.Uri import android.os.Environment import android.view.View +import androidx.core.net.toFile import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.platform.io.PlatformTestStorage +import androidx.test.platform.io.PlatformTestStorageRegistry import com.dropbox.differ.SimpleImageComparator import java.io.File import org.junit.After @@ -26,7 +30,7 @@ class DropshotsTest { private val fakeValidator = FakeResultValidator() private var filenameFunc: (String) -> String = { it } private val isRecordingScreenshots = isRecordingScreenshots(defaultRootScreenshotDirectory()) - private lateinit var imageDirectory: File + private lateinit var testStorage: FileTestStorage @get:Rule val testName = TestName() @get:Rule @@ -43,19 +47,19 @@ class DropshotsTest { @Before fun setup() { - imageDirectory = - File( - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), - "screenshots/test-${testName.methodName}", - ) + val imageDirUri = PlatformTestStorageRegistry.getInstance() + .getOutputFileUri("dropshots-tests") + testStorage = FileTestStorage( + imageDirUri.buildUpon().appendPath("input").build().toFile(), + imageDirUri.buildUpon().appendPath("output").build().toFile() + ) fakeValidator.validator = CountValidator(0) } @After fun after() { - if (imageDirectory.exists()) { - imageDirectory.deleteRecursively() - } + testStorage.outputDir.takeIf { it.exists() }?.deleteRecursively() + testStorage.inputDir.takeIf { it.exists() }?.deleteRecursively() } @Test @@ -85,7 +89,7 @@ class DropshotsTest { @Test fun testWritesReferenceImageForMissingImages() { val dropshots = Dropshots( - rootScreenshotDirectory = imageDirectory, + testStorage = testStorage, filenameFunc = filenameFunc, recordScreenshots = true, resultValidator = { false }, @@ -96,7 +100,7 @@ class DropshotsTest { dropshots.assertSnapshot(it, "MatchesViewScreenshotBad") } - with(File(imageDirectory, "reference")) { + with(File(testStorage.outputDir, "reference")) { assertTrue(exists()) assertArrayEquals(arrayOf(File(this, "MatchesViewScreenshotBad.png")), listFiles()) } @@ -105,7 +109,7 @@ class DropshotsTest { @Test fun testWritesDiffImageOnFailureWhenRecording() { val dropshots = Dropshots( - rootScreenshotDirectory = imageDirectory, + testStorage = testStorage, filenameFunc = filenameFunc, recordScreenshots = false, resultValidator = { false }, @@ -125,7 +129,7 @@ class DropshotsTest { } } - with(File(imageDirectory, "diff")) { + with(File(testStorage.inputDir, "diff")) { assertTrue(exists()) assertArrayEquals(arrayOf(File(this, "MatchesViewScreenshot.png")), listFiles()) } @@ -135,7 +139,7 @@ class DropshotsTest { fun testFailsForDifferences() { val dropshots = Dropshots( resultValidator = CountValidator(0), - rootScreenshotDirectory = imageDirectory, + testStorage = testStorage, filenameFunc = filenameFunc, recordScreenshots = false, imageComparator = SimpleImageComparator(), @@ -161,7 +165,7 @@ class DropshotsTest { fun testPassesWhenValidatorPasses() { val dropshots = Dropshots( resultValidator = FakeResultValidator { true }, - rootScreenshotDirectory = imageDirectory, + testStorage = testStorage, filenameFunc = filenameFunc, recordScreenshots = false, imageComparator = SimpleImageComparator(), @@ -185,7 +189,7 @@ class DropshotsTest { fun testFailsWhenValidatorFails() { val dropshots = Dropshots( resultValidator = FakeResultValidator { false }, - rootScreenshotDirectory = imageDirectory, + testStorage = testStorage, filenameFunc = filenameFunc, recordScreenshots = false, imageComparator = SimpleImageComparator(), @@ -216,7 +220,7 @@ class DropshotsTest { fun fastFailsForMismatchedSize() { val dropshots = Dropshots( resultValidator = CountValidator(0), - rootScreenshotDirectory = imageDirectory, + testStorage = testStorage, filenameFunc = filenameFunc, recordScreenshots = false, imageComparator = SimpleImageComparator(), diff --git a/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/FileTestStorage.kt b/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/FileTestStorage.kt new file mode 100644 index 0000000..086c46b --- /dev/null +++ b/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/FileTestStorage.kt @@ -0,0 +1,74 @@ +package com.dropbox.dropshots + +import android.net.Uri +import android.util.Log +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.platform.io.PlatformTestStorage +import java.io.File +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.io.InputStream +import java.io.OutputStream +import java.io.Serializable + +class FileTestStorage( + val inputDir: File, + val outputDir: File, +) : PlatformTestStorage { + + override fun openInputFile(pathname: String?): InputStream { + requireNotNull(pathname) + return inputDir.resolve(pathname).inputStream() + } + + override fun openOutputFile(pathname: String?): OutputStream { + return openOutputFile(pathname, false) + } + + override fun openOutputFile(pathname: String?, append: Boolean): OutputStream { + requireNotNull(pathname) + val outputFile = File(outputDir, pathname) + val parentFile = outputFile.parentFile + if (parentFile != null && !parentFile.exists()) { + if (!parentFile.mkdirs()) { + throw FileNotFoundException("Failed to create output dir ${parentFile.absolutePath}") + } + } + return FileOutputStream(outputFile, append) + } + + override fun getInputArg(argName: String?): String? { + requireNotNull(argName) + return InstrumentationRegistry.getArguments().getString(argName) + } + + override fun getInputArgs(): Map { + return buildMap { + val args = InstrumentationRegistry.getArguments() + for (k in args.keySet()) { + args.getString(k)?.let { put(k, it) } + } + } + } + + override fun addOutputProperties(properties: MutableMap?) { + Log.w("FileTestStorage", "Output properties is not supported.") + } + + override fun getOutputProperties(): Map { + Log.w("FileTestStorage", "Output properties is not supported.") + return emptyMap() + } + + override fun getInputFileUri(pathname: String): Uri { + return Uri.fromFile(inputDir.resolve(pathname)) + } + + override fun getOutputFileUri(pathname: String): Uri { + return Uri.fromFile(outputDir.resolve(pathname)) + } + + override fun isTestStorageFilePath(pathname: String): Boolean { + return pathname.startsWith(outputDir.absolutePath) + } +} diff --git a/dropshots/src/main/java/com/dropbox/dropshots/Dropshots.kt b/dropshots/src/main/java/com/dropbox/dropshots/Dropshots.kt index abdfa05..abdc27b 100644 --- a/dropshots/src/main/java/com/dropbox/dropshots/Dropshots.kt +++ b/dropshots/src/main/java/com/dropbox/dropshots/Dropshots.kt @@ -7,26 +7,44 @@ import android.graphics.Canvas import android.graphics.Paint import android.os.Build import android.os.Environment +import android.os.ParcelFileDescriptor import android.util.Base64 import android.view.View import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.platform.io.PlatformTestStorage +import androidx.test.platform.io.PlatformTestStorageRegistry import androidx.test.rule.GrantPermissionRule import androidx.test.runner.screenshot.Screenshot import com.dropbox.differ.ImageComparator import com.dropbox.differ.Mask import com.dropbox.differ.SimpleImageComparator +import com.dropbox.dropshots.model.TestRunConfig import java.io.File import java.io.FileNotFoundException +import java.io.IOException +import java.util.Properties import org.junit.rules.TestRule import org.junit.runner.Description import org.junit.runners.model.Statement -public class Dropshots internal constructor( - private val rootScreenshotDirectory: File, - private val filenameFunc: (String) -> String, - private val recordScreenshots: Boolean, - private val imageComparator: ImageComparator, - private val resultValidator: ResultValidator, +public class Dropshots @JvmOverloads constructor( + private val testStorage: PlatformTestStorage = PlatformTestStorageRegistry.getInstance(), + + private val testRunConfig: TestRunConfig = loadConfig(testStorage), + + /** + * Function to create a filename from a snapshot name (i.e. the name provided when taking + * the snapshot). + */ + private val filenameFunc: (String) -> String = defaultFilenameFunc, + /** + * The `ImageComparator` used to compare test and reference screenshots. + */ + private val imageComparator: ImageComparator = SimpleImageComparator(maxDistance = 0.004f), + /** + * The `ResultValidator` used to validate the comparison results. + */ + private val resultValidator: ResultValidator = CountValidator(0), ) : TestRule { private val context = InstrumentationRegistry.getInstrumentation().context private var fqName: String = "" @@ -36,28 +54,6 @@ public class Dropshots internal constructor( private val snapshotName: String get() = testName - @JvmOverloads - public constructor( - /** - * Function to create a filename from a snapshot name (i.e. the name provided when taking - * the snapshot). - */ - filenameFunc: (String) -> String = defaultFilenameFunc, - /** - * Indicates whether new reference screenshots should be recorded. Otherwise Dropshots performs - * validation of test screenshots against reference screenshots. - */ - recordScreenshots: Boolean = isRecordingScreenshots(defaultRootScreenshotDirectory()), - /** - * The `ImageComparator` used to compare test and reference screenshots. - */ - imageComparator: ImageComparator = SimpleImageComparator(maxDistance = 0.004f), - /** - * The `ResultValidator` used to validate the comparison results. - */ - resultValidator: ResultValidator = CountValidator(0), - ): this(defaultRootScreenshotDirectory(), filenameFunc, recordScreenshots, imageComparator, resultValidator) - override fun apply(base: Statement, description: Description): Statement { fqName = description.className packageName = fqName.substringBeforeLast('.', missingDelimiterValue = "") @@ -129,10 +125,10 @@ public class Dropshots internal constructor( } catch (e: FileNotFoundException) { writeReferenceImage(filename, filePath, bitmap) - if (!recordScreenshots) { + if (!testRunConfig.isRecording) { throw IllegalStateException( "Failed to find reference image named /$filename.png at path $filePath . " + - "If this is a new test, you may need to record screenshots by adding `dropshots.record=true` to your gradle.properties file, or gradlew with `-Pdropshots.record`.", + "If this is a new test, you may need to record screenshots by running the `recordDebugAndroidTestScreenshots` gradle task.", e ) } @@ -143,12 +139,11 @@ public class Dropshots internal constructor( if (bitmap.width != reference.width || bitmap.height != reference.height) { writeReferenceImage(filename, filePath, bitmap) - if (!recordScreenshots) { - val outputPath = writeDiffImage(filename, filePath, bitmap, reference, null) + if (!testRunConfig.isRecording) { + writeDiffImage(filename, filePath, bitmap, reference, null) throw AssertionError( "$name: Test image (w=${bitmap.width}, h=${bitmap.height}) differs in size" + - " from reference image (w=${reference.width}, h=${reference.height}).\n" + - "Diff written to: $outputPath", + " from reference image (w=${reference.width}, h=${reference.height})." ) } } @@ -159,12 +154,11 @@ public class Dropshots internal constructor( } catch (e: IllegalArgumentException) { writeReferenceImage(filename, filePath, bitmap) - if (!recordScreenshots) { - val outputPath = writeDiffImage(filename, filePath, bitmap, reference, mask) + if (!testRunConfig.isRecording) { + writeDiffImage(filename, filePath, bitmap, reference, mask) throw AssertionError( "Failed to compare images: reference{width=${reference.width}, height=${reference.height}} " + - "<> bitmap{width=${bitmap.width}, height=${bitmap.height}}\n" + - "Diff written to: $outputPath", + "<> bitmap{width=${bitmap.width}, height=${bitmap.height}}", e, ) } @@ -176,12 +170,11 @@ public class Dropshots internal constructor( if (!resultValidator(result)) { writeReferenceImage(filename, filePath, bitmap) - if (!recordScreenshots) { - val outputPath = writeDiffImage(filename, filePath, bitmap, reference, mask) + if (!testRunConfig.isRecording) { + writeDiffImage(filename, filePath, bitmap, reference, mask) throw AssertionError( "\"$name\" failed to match reference image. ${result.pixelDifferences} pixels differ " + - "(${(result.pixelDifferences / result.pixelCount.toFloat()) * 100} %)\n" + - "Output written to: $outputPath" + "(${(result.pixelDifferences / result.pixelCount.toFloat()) * 100} %)" ) } } @@ -191,37 +184,31 @@ public class Dropshots internal constructor( * Writes the given screenshot to the external reference image directory, returning the * file path of the file that was written. */ - private fun writeReferenceImage(name: String, filePath: String?, screenshot: Bitmap): String { - val screenshotFolder = File(rootScreenshotDirectory, "reference".appendPath(filePath)) - return writeImage(screenshotFolder, name, screenshot) + private fun writeReferenceImage(name: String, filePath: String?, screenshot: Bitmap) { + writeImage("reference".appendPath(filePath).appendPath(name), screenshot) } /** * Writes the given screenshot to the external reference image directory, returning the * file path of the file that was written. */ + @Throws(IOException::class) private fun writeDiffImage( name: String, filePath: String?, screenshot: Bitmap, referenceImage: Bitmap, mask: Mask? - ): String { - val screenshotFolder = File(rootScreenshotDirectory, "diff".appendPath(filePath)) + ) { val diffImage = generateDiffImage(referenceImage, screenshot, mask) - return writeImage(screenshotFolder, name, diffImage) + writeImage("diff".appendPath(filePath).appendPath(name), diffImage) } - private fun writeImage(dir: File, name: String, image: Bitmap): String { - if (!dir.exists() && !dir.mkdirs()) { - throw IllegalStateException("Unable to create screenshot storage directory.") - } - - val file = File(dir, "${name.replace(" ", "_")}.png") - file.outputStream().use { + @Throws(IOException::class) + private fun writeImage(name: String, image: Bitmap) { + testStorage.openOutputFile("dropshots/$name.png").use { image.compress(Bitmap.CompressFormat.PNG, 100, it) } - return file.absolutePath } /** @@ -279,9 +266,19 @@ internal fun defaultRootScreenshotDirectory(): File { * Reads the target application's `is_recording_screenshots` boolean resource to determine if * Dropshots should record screenshots or validate them. */ -internal fun isRecordingScreenshots(rootScreenshotDirectory: File): Boolean { - val markerFile = File(rootScreenshotDirectory, ".isRecordingScreenshots") - return markerFile.exists() +internal fun isRecordingScreenshots(testStorage: PlatformTestStorage): Boolean { + return loadConfig(testStorage).isRecording +} + +internal fun loadConfig(testStorage: PlatformTestStorage): TestRunConfig { + val testDataFileUri = testStorage.getOutputFileUri("dropshots/$configFileName") + return InstrumentationRegistry.getInstrumentation().context + .contentResolver + .openFileDescriptor(testDataFileUri, "r") + .let(ParcelFileDescriptor::AutoCloseInputStream) + .use { inputStream -> + TestRunConfig.read(inputStream) + } } /** diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f08472a..800885e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,10 +1,14 @@ [versions] -kotlin = "2.0.21" agp = "8.7.2" +androidTools = "31.7.2" +kotlin = "2.0.21" jvmTarget = "11" [libraries] android = { module = "com.android.tools.build:gradle", version.ref = "agp" } +android-builder-test = { module = "com.android.tools.build:builder-test-api", version.ref = "agp" } +android-ddmlib = { module = "com.android.tools.ddms:ddmlib", version.ref = "androidTools" } +android-common = { module = "com.android.tools:common", version.ref = "androidTools" } androidx-annotation = { module = "androidx.annotation:annotation", version = "1.9.1" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.0" } androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version = "2.2.0" } @@ -20,7 +24,7 @@ truth = "com.google.truth:truth:1.4.4" kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" } kotlin-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } -kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version = "1.7.3" } +kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.7.3" } [plugins] android-library = { id = "com.android.library", version.ref = "agp" } diff --git a/model/src/main/kotlin/com/dropbox/dropshots/Constants.kt b/model/src/main/kotlin/com/dropbox/dropshots/Constants.kt new file mode 100644 index 0000000..7453af7 --- /dev/null +++ b/model/src/main/kotlin/com/dropbox/dropshots/Constants.kt @@ -0,0 +1,3 @@ +package com.dropbox.dropshots + +public const val configFileName: String = "dropbox-config.json" diff --git a/model/src/main/kotlin/com/dropbox/dropshots/model/TestRunConfig.kt b/model/src/main/kotlin/com/dropbox/dropshots/model/TestRunConfig.kt index e107fff..8f59c3d 100644 --- a/model/src/main/kotlin/com/dropbox/dropshots/model/TestRunConfig.kt +++ b/model/src/main/kotlin/com/dropbox/dropshots/model/TestRunConfig.kt @@ -1,12 +1,11 @@ package com.dropbox.dropshots.model import java.io.InputStream -import java.io.OutputStream import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable -import kotlinx.serialization.cbor.Cbor -import kotlinx.serialization.decodeFromByteArray -import kotlinx.serialization.encodeToByteArray +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream /** * Test run configuration passed from the Dropshots Gradle plugin to the @@ -19,14 +18,10 @@ public data class TestRunConfig( ) { public companion object { @OptIn(ExperimentalSerializationApi::class) - public fun read(inputStream: InputStream): TestRunConfig { - return Cbor.decodeFromByteArray(inputStream.readAllBytes()) - } + public fun read(data: InputStream): TestRunConfig = Json.decodeFromStream(data) + public fun read(data: String): TestRunConfig = Json.decodeFromString(data) } - @OptIn(ExperimentalSerializationApi::class) - public fun write(outputStream: OutputStream) { - outputStream.write(Cbor.encodeToByteArray(this)) - } + public fun write(): String = Json.encodeToString(this) } From ab8669a32a3ba6d8e4c49b41e8459dfd680af0b0 Mon Sep 17 00:00:00 2001 From: Ryan Harter Date: Fri, 15 Nov 2024 13:22:21 -0600 Subject: [PATCH 04/21] Updates plugin to use input/output directories for connected checks. --- .../com/dropbox/dropshots/DropshotsPlugin.kt | 170 +++++------------- .../GenerateReferenceScreenshotsTask.kt | 31 ++++ .../dropbox/dropshots/PullScreenshotsTask.kt | 57 +++--- .../com/dropbox/dropshots/PushFileTask.kt | 68 ------- .../dropshots/UpdateScreenshotsTask.kt | 28 +-- .../dropbox/dropshots/WriteConfigFileTask.kt | 11 +- dropshots/build.gradle.kts | 2 +- .../java/com/dropbox/dropshots/Dropshots.kt | 41 +++-- .../kotlin/com/dropbox/dropshots/Constants.kt | 2 +- .../screenshots/basicActivityView.png | Bin 30840 -> 0 bytes .../connected/basicActivityView.png | Bin 0 -> 27612 bytes .../connected/views/colors/purple.png | Bin 0 -> 2165 bytes .../connected/views/colors/red.png | Bin 0 -> 2161 bytes .../screenshots/views/colors/purple.png | Bin 2854 -> 0 bytes .../screenshots/views/colors/red.png | Bin 2850 -> 0 bytes sample/settings.gradle.kts | 1 + 16 files changed, 148 insertions(+), 263 deletions(-) create mode 100644 dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/GenerateReferenceScreenshotsTask.kt delete mode 100644 dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/PushFileTask.kt delete mode 100644 sample/app/src/androidTest/screenshots/basicActivityView.png create mode 100644 sample/app/src/androidTest/screenshots/connected/basicActivityView.png create mode 100644 sample/app/src/androidTest/screenshots/connected/views/colors/purple.png create mode 100644 sample/app/src/androidTest/screenshots/connected/views/colors/red.png delete mode 100644 sample/app/src/androidTest/screenshots/views/colors/purple.png delete mode 100644 sample/app/src/androidTest/screenshots/views/colors/red.png diff --git a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/DropshotsPlugin.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/DropshotsPlugin.kt index 8756959..74577b9 100644 --- a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/DropshotsPlugin.kt +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/DropshotsPlugin.kt @@ -1,13 +1,12 @@ package com.dropbox.dropshots import com.android.build.api.variant.AndroidComponentsExtension +import com.android.build.api.variant.DeviceTest import com.android.build.api.variant.DeviceTestBuilder import com.android.build.api.variant.HasDeviceTests import com.android.build.api.variant.Variant import com.android.build.gradle.TestedExtension import com.android.build.gradle.api.AndroidBasePlugin -import com.android.build.gradle.api.ApkVariant -import com.android.build.gradle.internal.tasks.AndroidTestTask import com.android.build.gradle.internal.tasks.DeviceProviderInstrumentTestTask import com.android.build.gradle.internal.tasks.ManagedDeviceInstrumentationTestTask import com.android.build.gradle.internal.tasks.ManagedDeviceTestTask @@ -15,15 +14,12 @@ import com.android.build.gradle.internal.tasks.factory.dependsOn import com.android.build.gradle.options.BooleanOption import com.android.build.gradle.options.ProjectOptionService import com.android.builder.core.ComponentType -import com.dropbox.dropshots.model.TestRunConfig import java.io.File -import java.util.Locale import org.gradle.api.Plugin import org.gradle.api.Project -import org.gradle.api.file.RegularFile -import org.gradle.api.file.RegularFileProperty import org.gradle.api.plugins.JavaBasePlugin import org.gradle.api.provider.Provider +import org.gradle.internal.configuration.problems.taskPathFrom private const val recordScreenshotsArg = "dropshots.record" @@ -47,34 +43,20 @@ public class DropshotsPlugin : Plugin { val componentsExtension = extensions.getByType(AndroidComponentsExtension::class.java) val testedExtension = extensions.getByType(TestedExtension::class.java) - //check this to have resource based on flavours - val androidTestSourceSet = testedExtension.sourceSets.findByName("androidTest") - ?: throw Exception("Failed to find androidTest source set") - - // TODO configure this via extension - val referenceScreenshotDirectory = layout.projectDirectory.dir("src/androidTest/screenshots") - - androidTestSourceSet.assets { - srcDirs(referenceScreenshotDirectory) - } - @Suppress("UnstableApiUsage") componentsExtension.onVariants { variant -> if (!variant.debuggable || variant !is HasDeviceTests) { return@onVariants } - logger.warn("Found testable variant: ${variant.name}") - - val variantName = variant.name val deviceTestComponent = variant.deviceTests[DeviceTestBuilder.ANDROID_TEST_TYPE] ?: return@onVariants val adbProvider = provider { testedExtension.adbExecutable } // Create a test connected check task - addTasksForDeviceProvider(variant, "connected", adbProvider) + addTasksForDeviceProvider(variant, deviceTestComponent, "connected", adbProvider) testedExtension.deviceProviders.forEach { deviceProvider -> - addTasksForDeviceProvider(variant, deviceProvider.name, adbProvider) + addTasksForDeviceProvider(variant, deviceTestComponent, deviceProvider.name, adbProvider) } val optionService = ProjectOptionService.RegistrationAction(this).execute().get() @@ -85,17 +67,15 @@ public class DropshotsPlugin : Plugin { private fun Project.addTasksForDeviceProvider( variant: Variant, + deviceTest: DeviceTest, deviceProviderName: String, adbProvider: Provider, ) { - tasks .named { it == "${deviceProviderName}${variant.name.capitalize()}${ComponentType.ANDROID_TEST_SUFFIX}" } .all { testTask -> - val testSlug = testTask.name.capitalize() - val testDataProvider = when (testTask) { is DeviceProviderInstrumentTestTask -> testTask.testData is ManagedDeviceInstrumentationTestTask -> testTask.testData @@ -103,7 +83,7 @@ public class DropshotsPlugin : Plugin { else -> return@all } val defaultTestOutputDirectory = layout.buildDirectory - .dir("outputs/androidTest-results/dropshots/${variant.name}/$deviceProviderName/") + .dir("outputs/androidTest-results/$deviceProviderName/${variant.name}") val additionalTestOutputEnabled = when (testTask) { is DeviceProviderInstrumentTestTask -> testTask.additionalTestOutputEnabled is ManagedDeviceInstrumentationTestTask -> testTask.getAdditionalTestOutputEnabled() @@ -123,8 +103,16 @@ public class DropshotsPlugin : Plugin { } } } - val referenceImageResultDir = additionalTestOutputDir.map { it.dir("dropshots/reference") } - val referenceImageSourceDir = layout.projectDirectory.dir("src/${variant.name}/androidTest/screenshots/$deviceProviderName") + + val addReferenceAssetsTask = tasks.register( + "generate${testTask.name.capitalize()}ReferenceScreenshots", + GenerateReferenceScreenshotsTask::class.java + ) { task -> + task.referenceImageDir.set(layout.projectDirectory.dir("src/androidTest/screenshots")) + task.outputDir.set(layout.buildDirectory.dir("generated/dropshots/reference")) + } + logger.warn("Adding generated assets to: ${variant.sources.assets}") + deviceTest.sources.assets?.addGeneratedSourceDirectory(addReferenceAssetsTask) { task -> task.outputDir } val updateTaskProvider = tasks.register( "update${testTask.name.capitalize()}Screenshots", @@ -136,124 +124,48 @@ public class DropshotsPlugin : Plugin { it.description = "Updates screenshots for ${variant.name} using provider: ${deviceProviderName.capitalize()}" } it.group = JavaBasePlugin.VERIFICATION_GROUP - it.referenceImageDir.set(referenceImageResultDir) - it.outputDir.set(referenceImageSourceDir) + it.referenceImageDir.set(additionalTestOutputDir) + it.deviceProviderName.set(deviceProviderName) + it.outputDir.set(layout.projectDirectory.dir("src/androidTest/screenshots/")) } - updateTaskProvider.dependsOn(testTask.name) + tasks.named("updateDropshotsScreenshots").dependsOn(updateTaskProvider) val isRecordingScreenshots = project.objects.property(Boolean::class.java) project.gradle.taskGraph.whenReady { graph -> isRecordingScreenshots.set(updateTaskProvider.map { graph.hasTask(it) }) } + // additional test output will be overwritten by the test task, so we need to use our own + // input directory to send information to the device. + val remoteDirProvider = testDataProvider.map { testData -> + @Suppress("SdCardPath") + "/sdcard/Android/media/${testData.instrumentationTargetPackageId.get()}/dropshots" + } + val writeConfigTaskProvider = tasks.register( "write${testTask.name.capitalize()}ScreenshotConfigFile", WriteConfigFileTask::class.java, ) { task -> task.group = JavaBasePlugin.VERIFICATION_GROUP - task.getIsRecording.set(isRecordingScreenshots) + task.recordingScreenshots.set(isRecordingScreenshots) + task.deviceProviderName.set(deviceProviderName) task.adbExecutable.set(adbProvider) - task.remoteDir.set( - testDataProvider.map { testData -> - @Suppress("SdCardPath") - "/sdcard/Android/media/${testData.instrumentationTargetPackageId.get()}/additional_test_output/dropbox" - }, - ) + task.remoteDir.set(remoteDirProvider) } testTask.dependsOn(writeConfigTaskProvider) - } - } - - private fun Project.configureDropshots(extension: TestedExtension) { - project.afterEvaluate { - it.dependencies.add( - "androidTestImplementation", - "com.dropbox.dropshots:dropshots:$VERSION" - ) - } - - //check this to have resource based on flavours - val androidTestSourceSet = extension.sourceSets.findByName("androidTest") - ?: throw Exception("Failed to find androidTest source set") - - // TODO configure this via extension - val referenceScreenshotDirectory = layout.projectDirectory.dir("src/androidTest/screenshots") - - androidTestSourceSet.assets { - srcDirs(referenceScreenshotDirectory) - } - val adbExecutablePath = provider { extension.adbExecutable.path } - extension.testVariants.all { variant -> - val testTaskProvider = variant.connectedInstrumentTestProvider - val variantSlug = variant.name.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } - - val screenshotDir = provider { - val appId = if (variant.testedVariant is ApkVariant) { - variant.testedVariant.applicationId - } else { - variant.packageApplicationProvider.get().applicationId - variant.applicationId + val pullScreenshotsTask = tasks.register( + "pull${testTask.name.capitalize()}Screenshots", + PullScreenshotsTask::class.java, + ) { task -> + task.onlyIf { !additionalTestOutputEnabled.get() } + task.group = JavaBasePlugin.VERIFICATION_GROUP + task.adbExecutable.set(adbProvider) + task.remoteDir.set(remoteDirProvider) + task.outputDirectory.set(additionalTestOutputDir) } - "/storage/emulated/0/Download/screenshots/$appId" - } - - val clearScreenshotsTask = tasks.register( - "clear${variantSlug}Screenshots", - ClearScreenshotsTask::class.java, - ) { - it.adbExecutable.set(adbExecutablePath) - it.screenshotDir.set(screenshotDir) - } - - val pullScreenshotsTask = tasks.register( - "pull${variantSlug}Screenshots", - PullScreenshotsTask::class.java, - ) { - it.adbExecutable.set(adbExecutablePath) - it.screenshotDir.set(screenshotDir.map { base -> "$base/diff" }) - it.outputDirectory.set(testTaskProvider.flatMap { (it as AndroidTestTask).resultsDir }) - it.finalizedBy(clearScreenshotsTask) + testTask.finalizedBy(pullScreenshotsTask) + updateTaskProvider.dependsOn(pullScreenshotsTask) } - - val recordScreenshotsTask = tasks.register( - "record${variantSlug}Screenshots", - PullScreenshotsTask::class.java, - ) { - it.description = "Updates the local reference screenshots" - - it.adbExecutable.set(adbExecutablePath) - it.screenshotDir.set(screenshotDir.map { base -> "$base/reference" }) - it.outputDirectory.set(referenceScreenshotDirectory) - it.dependsOn(testTaskProvider) - it.finalizedBy(clearScreenshotsTask) - } - - val isRecordingScreenshots = project.objects.property(Boolean::class.java) - if (hasProperty(recordScreenshotsArg)) { - project.logger.warn("The 'dropshots.record' property has been deprecated and will " + - "be removed in a future version.") - isRecordingScreenshots.set(true) - } - project.gradle.taskGraph.whenReady { graph -> - isRecordingScreenshots.set(recordScreenshotsTask.map { graph.hasTask(it) }) - } - - val writeMarkerFileTask = tasks.register( - "push${variantSlug}ScreenshotMarkerFile", - PushFileTask::class.java, - ) { - it.onlyIf { isRecordingScreenshots.get() } - it.adbExecutable.set(adbExecutablePath) - it.fileContents.set("\n") - it.remotePath.set(screenshotDir.map { dir -> "$dir/.isRecordingScreenshots" }) - it.finalizedBy(clearScreenshotsTask) - } - testTaskProvider.dependsOn(writeMarkerFileTask) - - testTaskProvider.configure { - it.finalizedBy(pullScreenshotsTask) - } - } } } diff --git a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/GenerateReferenceScreenshotsTask.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/GenerateReferenceScreenshotsTask.kt new file mode 100644 index 0000000..b2c4439 --- /dev/null +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/GenerateReferenceScreenshotsTask.kt @@ -0,0 +1,31 @@ +package com.dropbox.dropshots + +import java.nio.file.Files +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.copyToRecursively +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.logging.Logging +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction + +public abstract class GenerateReferenceScreenshotsTask : DefaultTask() { + @get:InputDirectory + public abstract val referenceImageDir: DirectoryProperty + + @get:OutputDirectory + public abstract val outputDir: DirectoryProperty + + @OptIn(ExperimentalPathApi::class) + @TaskAction + public fun performAction() { + val from = referenceImageDir.asFile.get().toPath() + val to = outputDir.asFile.get().toPath().resolve("dropshots") + val logger = Logging.getLogger(GenerateReferenceScreenshotsTask::class.java) + + logger.lifecycle("Copying reference images to build directory: $to") + Files.createDirectories(to) + from.copyToRecursively(to, followLinks = true, overwrite = true) + } +} diff --git a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/PullScreenshotsTask.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/PullScreenshotsTask.kt index 7e6f16b..7f8c297 100644 --- a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/PullScreenshotsTask.kt +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/PullScreenshotsTask.kt @@ -1,29 +1,28 @@ package com.dropbox.dropshots -import javax.inject.Inject -import java.io.ByteArrayOutputStream +import com.android.build.gradle.internal.LoggerWrapper +import com.android.build.gradle.internal.testing.ConnectedDeviceProvider +import com.android.ddmlib.DdmPreferences +import java.io.File +import kotlin.io.path.absolutePathString import org.gradle.api.DefaultTask import org.gradle.api.file.DirectoryProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.Input import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.TaskAction -import org.gradle.process.ExecOperations public abstract class PullScreenshotsTask : DefaultTask() { @get:Input - public abstract val adbExecutable: Property + public abstract val adbExecutable: Property @get:Input - public abstract val screenshotDir: Property + public abstract val remoteDir: Property @get:OutputDirectory public abstract val outputDirectory: DirectoryProperty - @get:Inject - protected abstract val execOperations: ExecOperations - init { description = "Pull screenshots from the test device." group = "verification" @@ -32,32 +31,22 @@ public abstract class PullScreenshotsTask : DefaultTask() { @TaskAction public fun pullScreenshots() { - val outputDir = outputDirectory.get().asFile - outputDir.mkdirs() - - val adb = adbExecutable.get() - val dir = screenshotDir.get() - val checkResult = execOperations.exec { - it.executable = adb - it.args = listOf("shell", "test", "-d", dir) - it.isIgnoreExitValue = true - } - - if (checkResult.exitValue == 0) { - val output = ByteArrayOutputStream() - execOperations.exec { - it.executable = adb - it.args = listOf("pull", "$dir/.", outputDir.path) - it.standardOutput = output - } - - val fileCount = """^$dir/?\./: ([0-9]*) files pulled,.*$""".toRegex() - val matchResult = fileCount.find(output.toString(Charsets.UTF_8)) - if (matchResult != null && matchResult.groups.size > 1) { - println("${matchResult.groupValues[1]} screenshots saved at ${outputDir.path}") - } else { - println("Unknown result executing adb: $adb pull $dir/. ${outputDir.path}") - print(output.toString(Charsets.UTF_8)) + val iLogger = LoggerWrapper(logger) + val deviceProvider = ConnectedDeviceProvider( + adbExecutable.get(), + DdmPreferences.getTimeOut(), + iLogger, + System.getenv("ANDROID_SERIAL"), + ) + + deviceProvider.use { + @Suppress("UnstableApiUsage") + deviceProvider.devices.forEach { device -> + val remotePath = remoteDir.get() + val localPath = outputDirectory.dir("${device.name}/dropshots").get().asFile.toPath() + + // TODO Does this really only do a single file? If so we'll have to `ls` to get the files + device.pullFile("$remotePath/.", localPath.absolutePathString()) } } } diff --git a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/PushFileTask.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/PushFileTask.kt deleted file mode 100644 index 9751bb5..0000000 --- a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/PushFileTask.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.dropbox.dropshots - -import java.io.ByteArrayOutputStream -import java.nio.charset.StandardCharsets -import java.util.regex.Pattern -import javax.inject.Inject -import org.gradle.api.DefaultTask -import org.gradle.api.internal.file.temp.TemporaryFileProvider -import org.gradle.api.provider.Property -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.TaskAction -import org.gradle.process.ExecOperations - -public abstract class PushFileTask : DefaultTask() { - - @get:Input - public abstract val adbExecutable: Property - - /** - * The file to push to the emulator. - */ - @get:Input - public abstract val fileContents: Property - - /** - * The path to the file or directory on the emulator. - */ - @get:Input - public abstract val remotePath: Property - - @get:Inject - protected abstract val execOperations: ExecOperations - - @get:Inject - protected abstract val tempFileProvider: TemporaryFileProvider - - init { - description = "Push files to an emulator or device using adb." - outputs.upToDateWhen { false } - } - - @TaskAction - public fun push() { - val tempFile = tempFileProvider.createTemporaryFile("adb-file", "", "dropshots") - tempFile.writeText(fileContents.get()) - - val adb = adbExecutable.get() - val remote = remotePath.get() - val output = ByteArrayOutputStream() - val checkResult = execOperations.exec { - it.executable = adb - it.args = listOf("devices") - it.isIgnoreExitValue = true - it.standardOutput = output - } - - val whitespacePattern = Pattern.compile("\\s") - val hasConnectedDevice = output.toString(StandardCharsets.UTF_8).lines() - .any { it.trim().split(whitespacePattern).last() == "device" } - - if (checkResult.exitValue == 0 && hasConnectedDevice) { - execOperations.exec { - it.executable = adb - it.args = listOf("push", tempFile.absolutePath, remote) - } - } - } -} diff --git a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/UpdateScreenshotsTask.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/UpdateScreenshotsTask.kt index 6d0ae63..97fcd52 100644 --- a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/UpdateScreenshotsTask.kt +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/UpdateScreenshotsTask.kt @@ -19,6 +19,9 @@ public abstract class UpdateScreenshotsTask : DefaultTask() { @get:InputDirectory public abstract val referenceImageDir: DirectoryProperty + @get:Input + public abstract val deviceProviderName: Property + @get:Optional @get:Input public abstract val outputBasePath: Property @@ -30,16 +33,21 @@ public abstract class UpdateScreenshotsTask : DefaultTask() { @TaskAction public fun performAction() { val from = referenceImageDir.asFile.get().toPath() - val to = outputDir.asFile.get().toPath().let { output -> - if (outputBasePath.isPresent) { - output.resolve(outputBasePath.get()) - } else { - output - } - } + val to = outputDir.asFile.get().toPath() val logger = Logging.getLogger(UpdateScreenshotsTask::class.java) - logger.lifecycle("Copying reference images to $to") - Files.createDirectories(to) - from.copyToRecursively(to, followLinks = true) + + Files.list(from).forEach { devicePath -> + val deviceName = deviceProviderName.get() + logger.lifecycle("Copying reference images for $deviceName") + + val referenceImagePath = devicePath.resolve("dropshots/reference") + val outputPath = to.resolve(deviceName) + Files.createDirectories(outputPath) + referenceImagePath.copyToRecursively( + outputPath, + followLinks = true, + overwrite = true, + ) + } } } diff --git a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/WriteConfigFileTask.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/WriteConfigFileTask.kt index 2fe3588..8a84267 100644 --- a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/WriteConfigFileTask.kt +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/WriteConfigFileTask.kt @@ -17,7 +17,10 @@ import org.gradle.work.DisableCachingByDefault public abstract class WriteConfigFileTask : DefaultTask() { @get:Input - public abstract val getIsRecording: Property + public abstract val recordingScreenshots: Property + + @get:Input + public abstract val deviceProviderName: Property @get:Input public abstract val adbExecutable: Property @@ -48,7 +51,7 @@ public abstract class WriteConfigFileTask : DefaultTask() { } val deviceName = device.name - val config = TestRunConfig(getIsRecording.get(), deviceName) + val config = TestRunConfig(recordingScreenshots.get(), deviceProviderName.get()) val remotePath = remoteDir.get() val loggingReceiver = object : MultiLineReceiver() { override fun isCancelled(): Boolean = false @@ -57,11 +60,11 @@ public abstract class WriteConfigFileTask : DefaultTask() { } } - logger.info("DeviceConnector '$deviceName': creating parent directories $remotePath") + logger.info("DeviceConnector '$deviceName': creating directories $remotePath") executeShellCommand("mkdir -p $remotePath", loggingReceiver) - logger.info("DeviceConnector '$deviceName': writing config file to $remotePath") val configFile = "$remotePath/$configFileName" + logger.warn("DeviceConnector '$deviceName': writing config file to $configFile") executeShellCommand( "echo '${config.write()}' > $configFile", loggingReceiver, diff --git a/dropshots/build.gradle.kts b/dropshots/build.gradle.kts index 0db0188..4596145 100644 --- a/dropshots/build.gradle.kts +++ b/dropshots/build.gradle.kts @@ -29,11 +29,11 @@ kotlin { dependencies { api(libs.differ) + api(projects.model) implementation(libs.androidx.annotation) implementation(libs.androidx.test.runner) implementation(libs.androidx.test.rules) - implementation(projects.model) debugImplementation(libs.androidx.fragment) diff --git a/dropshots/src/main/java/com/dropbox/dropshots/Dropshots.kt b/dropshots/src/main/java/com/dropbox/dropshots/Dropshots.kt index abdc27b..7fc4b39 100644 --- a/dropshots/src/main/java/com/dropbox/dropshots/Dropshots.kt +++ b/dropshots/src/main/java/com/dropbox/dropshots/Dropshots.kt @@ -1,19 +1,19 @@ package com.dropbox.dropshots +import android.annotation.SuppressLint import android.app.Activity import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Canvas import android.graphics.Paint -import android.os.Build +import android.net.Uri import android.os.Environment -import android.os.ParcelFileDescriptor import android.util.Base64 +import android.util.Log import android.view.View import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.io.PlatformTestStorage import androidx.test.platform.io.PlatformTestStorageRegistry -import androidx.test.rule.GrantPermissionRule import androidx.test.runner.screenshot.Screenshot import com.dropbox.differ.ImageComparator import com.dropbox.differ.Mask @@ -22,7 +22,6 @@ import com.dropbox.dropshots.model.TestRunConfig import java.io.File import java.io.FileNotFoundException import java.io.IOException -import java.util.Properties import org.junit.rules.TestRule import org.junit.runner.Description import org.junit.runners.model.Statement @@ -60,13 +59,14 @@ public class Dropshots @JvmOverloads constructor( className = fqName.substringAfterLast('.', missingDelimiterValue = "") testName = description.methodName - return if (Build.VERSION.SDK_INT <= 29) { - GrantPermissionRule - .grant(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) - .apply(base, description) - } else { - base - } + return base +// return if (Build.VERSION.SDK_INT <= 29) { +// GrantPermissionRule +// .grant(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) +// .apply(base, description) +// } else { +// base +// } } /** @@ -117,9 +117,10 @@ public class Dropshots @JvmOverloads constructor( filePath: String? = null, ) { val filename = filenameFunc(name) + val referencePath = path("dropshots", testRunConfig.deviceName, filePath, "$filename.png") val reference = try { - context.assets.open("$filename.png".prependPath(filePath)).use { + context.assets.open(referencePath).use { BitmapFactory.decodeStream(it) } } catch (e: FileNotFoundException) { @@ -127,7 +128,7 @@ public class Dropshots @JvmOverloads constructor( if (!testRunConfig.isRecording) { throw IllegalStateException( - "Failed to find reference image named /$filename.png at path $filePath . " + + "Failed to find reference image file at $referencePath. " + "If this is a new test, you may need to record screenshots by running the `recordDebugAndroidTestScreenshots` gradle task.", e ) @@ -206,6 +207,7 @@ public class Dropshots @JvmOverloads constructor( @Throws(IOException::class) private fun writeImage(name: String, image: Bitmap) { + Log.d("RYAN", "Writing reference image to: ${testStorage.getOutputFileUri("dropshots/$name.png")}") testStorage.openOutputFile("dropshots/$name.png").use { image.compress(Bitmap.CompressFormat.PNG, 100, it) } @@ -271,12 +273,16 @@ internal fun isRecordingScreenshots(testStorage: PlatformTestStorage): Boolean { } internal fun loadConfig(testStorage: PlatformTestStorage): TestRunConfig { - val testDataFileUri = testStorage.getOutputFileUri("dropshots/$configFileName") + val targetApplicationId = InstrumentationRegistry.getInstrumentation().targetContext.packageName + @SuppressLint("SdCardPath") + val testDataFileUri = Uri.fromFile(File("/sdcard/Android/media/${targetApplicationId}/dropshots/$configFileName")) return InstrumentationRegistry.getInstrumentation().context .contentResolver - .openFileDescriptor(testDataFileUri, "r") - .let(ParcelFileDescriptor::AutoCloseInputStream) + .openInputStream(testDataFileUri) +// .openFile(testDataFileUri, "r", null) +// .let(ParcelFileDescriptor::AutoCloseInputStream) .use { inputStream -> + requireNotNull(inputStream) TestRunConfig.read(inputStream) } } @@ -302,6 +308,9 @@ internal val defaultFilenameFunc = { testName: String -> } } +private fun path(vararg parts: String?): String = + parts.filterNotNull().joinToString("/") + private fun String.prependPath(path: String?): String = if (path == null) { this diff --git a/model/src/main/kotlin/com/dropbox/dropshots/Constants.kt b/model/src/main/kotlin/com/dropbox/dropshots/Constants.kt index 7453af7..f714605 100644 --- a/model/src/main/kotlin/com/dropbox/dropshots/Constants.kt +++ b/model/src/main/kotlin/com/dropbox/dropshots/Constants.kt @@ -1,3 +1,3 @@ package com.dropbox.dropshots -public const val configFileName: String = "dropbox-config.json" +public const val configFileName: String = "dropshots-config.json" diff --git a/sample/app/src/androidTest/screenshots/basicActivityView.png b/sample/app/src/androidTest/screenshots/basicActivityView.png deleted file mode 100644 index 99237dec002ebe6808f516c8aa9454a9bb450a70..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30840 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 diff --git a/sample/app/src/androidTest/screenshots/connected/basicActivityView.png b/sample/app/src/androidTest/screenshots/connected/basicActivityView.png new file mode 100644 index 0000000000000000000000000000000000000000..87b3b45b47c60d95f76d36148eee09b016130232 GIT binary patch literal 27612 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

&y-1#c&yb02lgf!NXc(0%{v`Pk6SdL4a3iQe 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 literal 0 HcmV?d00001 diff --git a/sample/app/src/androidTest/screenshots/views/colors/purple.png b/sample/app/src/androidTest/screenshots/views/colors/purple.png deleted file mode 100644 index b89b1fd7170fceba1b4a6a609829a2f177dd1b04..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2854 zcmeAS@N?(olHy`uVBq!ia0y~yV1B^Bz$C`O1Qdz*SuhJoF%}28J29*~C-V}>VJ>#^ z4B_D5xc$%{sF3TTr;B4q#hka-3>gn7FdW!m_;JP|DRyY-1_lnp7YqynNf#Iy6pk!lW^m|mWM^m)Xy#*JP-K&3U~uBGVqj>I7*#kLJfn$X xG%Jjj6r<(iXss|>X^u7(MjJ@6o;x0N7?_@O1TaS?83{1OV`pI^qBT diff --git a/sample/app/src/androidTest/screenshots/views/colors/red.png b/sample/app/src/androidTest/screenshots/views/colors/red.png deleted file mode 100644 index f813245ff96a3db34acd3c5fa7b831f6340a380c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2850 zcmeAS@N?(olHy`uVBq!ia0y~yV1B^Bz$D7S1QfZK%VYwi7>k44ofy`glX(f`Fc&*{ zhH!9j+mdKI;Vst06-=*G5`Po diff --git a/sample/settings.gradle.kts b/sample/settings.gradle.kts index a1025d2..990bce1 100644 --- a/sample/settings.gradle.kts +++ b/sample/settings.gradle.kts @@ -13,6 +13,7 @@ include(":app") includeBuild("..") { dependencySubstitution { substitute(module("com.dropbox.dropshots:dropshots")).using(project(":dropshots")) + substitute(module("com.dropbox.dropshots:model")).using(project(":model")) } } From 692a66ab27fc4102711f4e331275f8e89753e5f0 Mon Sep 17 00:00:00 2001 From: Ryan Harter Date: Wed, 20 Nov 2024 13:17:41 -0600 Subject: [PATCH 05/21] Adds plugin tests. --- build.gradle.kts | 3 +- dropshots-gradle-plugin/build.gradle.kts | 4 - .../com/dropbox/dropshots/DropshotsPlugin.kt | 6 +- .../dropshots/device/DeviceProviderFactory.kt | 28 +++++ .../{ => tasks}/ClearScreenshotsTask.kt | 2 +- .../GenerateReferenceScreenshotsTask.kt | 2 +- .../{ => tasks}/PullScreenshotsTask.kt | 2 +- .../{ => tasks}/UpdateScreenshotsTask.kt | 2 +- .../{ => tasks}/WriteConfigFileTask.kt | 17 +-- .../dropbox/dropshots/ConnectedDeviceTest.kt | 56 +++++++++ .../dropbox/dropshots/DropshotsPluginTest.kt | 114 ++--------------- .../dropshots/rules/TestProjectRule.kt | 116 ++++++++++++++++++ .../test/projects/common-settings.gradle.kts | 22 ++++ .../connected-device/build.gradle.kts | 12 ++ .../com/dropbox/dropshots/test/MainTest.kt | 48 ++++++++ .../dropshots/test/TestRunConfigTest.kt | 66 ++++++++++ .../connected/basicActivityView.png | Bin 0 -> 27612 bytes .../connected/views/colors/purple.png | Bin 0 -> 2165 bytes .../connected/views/colors/red.png | Bin 0 -> 2161 bytes .../src/main/AndroidManifest.xml | 16 +++ .../dropbox/dropshots/test/MainActivity.kt | 11 ++ .../src/main/res/drawable/icon.png | Bin 0 -> 2574 bytes .../src/main/res/layout/acivity_main.xml | 37 ++++++ .../src/main/res/values/strings.xml | 3 + .../com/dropbox/dropshots/DropshotsTest.kt | 20 ++- .../java/com/dropbox/dropshots/Dropshots.kt | 10 +- 26 files changed, 454 insertions(+), 143 deletions(-) create mode 100644 dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/device/DeviceProviderFactory.kt rename dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/{ => tasks}/ClearScreenshotsTask.kt (96%) rename dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/{ => tasks}/GenerateReferenceScreenshotsTask.kt (96%) rename dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/{ => tasks}/PullScreenshotsTask.kt (97%) rename dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/{ => tasks}/UpdateScreenshotsTask.kt (97%) rename dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/{ => tasks}/WriteConfigFileTask.kt (86%) create mode 100644 dropshots-gradle-plugin/src/test/kotlin/com/dropbox/dropshots/ConnectedDeviceTest.kt create mode 100644 dropshots-gradle-plugin/src/test/kotlin/com/dropbox/dropshots/rules/TestProjectRule.kt create mode 100644 dropshots-gradle-plugin/src/test/projects/common-settings.gradle.kts create mode 100644 dropshots-gradle-plugin/src/test/projects/connected-device/build.gradle.kts create mode 100644 dropshots-gradle-plugin/src/test/projects/connected-device/src/androidTest/kotlin/com/dropbox/dropshots/test/MainTest.kt create mode 100644 dropshots-gradle-plugin/src/test/projects/connected-device/src/androidTest/kotlin/com/dropbox/dropshots/test/TestRunConfigTest.kt create mode 100644 dropshots-gradle-plugin/src/test/projects/connected-device/src/androidTest/screenshots/connected/basicActivityView.png create mode 100644 dropshots-gradle-plugin/src/test/projects/connected-device/src/androidTest/screenshots/connected/views/colors/purple.png create mode 100644 dropshots-gradle-plugin/src/test/projects/connected-device/src/androidTest/screenshots/connected/views/colors/red.png create mode 100644 dropshots-gradle-plugin/src/test/projects/connected-device/src/main/AndroidManifest.xml create mode 100644 dropshots-gradle-plugin/src/test/projects/connected-device/src/main/java/com/dropbox/dropshots/test/MainActivity.kt create mode 100644 dropshots-gradle-plugin/src/test/projects/connected-device/src/main/res/drawable/icon.png create mode 100644 dropshots-gradle-plugin/src/test/projects/connected-device/src/main/res/layout/acivity_main.xml create mode 100644 dropshots-gradle-plugin/src/test/projects/connected-device/src/main/res/values/strings.xml diff --git a/build.gradle.kts b/build.gradle.kts index 683b6cf..1a6e7c7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -40,7 +40,6 @@ allprojects { plugins.withType().configureEach { tasks.withType().configureEach { compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) apiVersion.set(KotlinVersion.KOTLIN_1_9) languageVersion.set(KotlinVersion.KOTLIN_1_9) } @@ -50,7 +49,7 @@ allprojects { plugins.withType(JavaBasePlugin::class.java).configureEach { extensions.configure(JavaPluginExtension::class.java) { toolchain { - languageVersion.set(JavaLanguageVersion.of(17)) + languageVersion.set(JavaLanguageVersion.of(21)) } } } diff --git a/dropshots-gradle-plugin/build.gradle.kts b/dropshots-gradle-plugin/build.gradle.kts index 610dd95..3676099 100644 --- a/dropshots-gradle-plugin/build.gradle.kts +++ b/dropshots-gradle-plugin/build.gradle.kts @@ -80,7 +80,3 @@ tasks.register("printVersionName") { println(project.property("VERSION_NAME")) } } - -tasks.withType().configureEach { - dependsOn(":dropshots:publishMavenPublicationToProjectLocalMavenRepository") -} diff --git a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/DropshotsPlugin.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/DropshotsPlugin.kt index 74577b9..17f005b 100644 --- a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/DropshotsPlugin.kt +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/DropshotsPlugin.kt @@ -14,12 +14,15 @@ import com.android.build.gradle.internal.tasks.factory.dependsOn import com.android.build.gradle.options.BooleanOption import com.android.build.gradle.options.ProjectOptionService import com.android.builder.core.ComponentType +import com.dropbox.dropshots.tasks.GenerateReferenceScreenshotsTask +import com.dropbox.dropshots.tasks.PullScreenshotsTask +import com.dropbox.dropshots.tasks.UpdateScreenshotsTask +import com.dropbox.dropshots.tasks.WriteConfigFileTask import java.io.File import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.plugins.JavaBasePlugin import org.gradle.api.provider.Provider -import org.gradle.internal.configuration.problems.taskPathFrom private const val recordScreenshotsArg = "dropshots.record" @@ -111,7 +114,6 @@ public class DropshotsPlugin : Plugin { task.referenceImageDir.set(layout.projectDirectory.dir("src/androidTest/screenshots")) task.outputDir.set(layout.buildDirectory.dir("generated/dropshots/reference")) } - logger.warn("Adding generated assets to: ${variant.sources.assets}") deviceTest.sources.assets?.addGeneratedSourceDirectory(addReferenceAssetsTask) { task -> task.outputDir } val updateTaskProvider = tasks.register( diff --git a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/device/DeviceProviderFactory.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/device/DeviceProviderFactory.kt new file mode 100644 index 0000000..1c2dedc --- /dev/null +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/device/DeviceProviderFactory.kt @@ -0,0 +1,28 @@ +package com.dropbox.dropshots.device + +import com.android.build.gradle.internal.LoggerWrapper +import com.android.build.gradle.internal.testing.ConnectedDeviceProvider +import com.android.builder.testing.api.DeviceProvider +import com.android.ddmlib.DdmPreferences +import com.android.utils.ILogger +import java.io.File +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.Internal + +public abstract class DeviceProviderFactory { + @get:Internal + internal var deviceProvider: DeviceProvider? = null + + public fun getDeviceProvider( + adbExecutableProvider: Provider, + logger: ILogger = LoggerWrapper.getLogger(DeviceProviderFactory::class.java), + environmentSerials: String? = System.getenv("ANDROID_SERIAL"), + ): DeviceProvider { + return deviceProvider ?: ConnectedDeviceProvider( + adbExecutableProvider.get(), + DdmPreferences.getTimeOut(), + logger, + environmentSerials, + ) + } +} diff --git a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/ClearScreenshotsTask.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/ClearScreenshotsTask.kt similarity index 96% rename from dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/ClearScreenshotsTask.kt rename to dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/ClearScreenshotsTask.kt index 88f2e4e..89d79f0 100644 --- a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/ClearScreenshotsTask.kt +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/ClearScreenshotsTask.kt @@ -1,4 +1,4 @@ -package com.dropbox.dropshots +package com.dropbox.dropshots.tasks import javax.inject.Inject import org.gradle.api.DefaultTask diff --git a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/GenerateReferenceScreenshotsTask.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/GenerateReferenceScreenshotsTask.kt similarity index 96% rename from dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/GenerateReferenceScreenshotsTask.kt rename to dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/GenerateReferenceScreenshotsTask.kt index b2c4439..416a757 100644 --- a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/GenerateReferenceScreenshotsTask.kt +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/GenerateReferenceScreenshotsTask.kt @@ -1,4 +1,4 @@ -package com.dropbox.dropshots +package com.dropbox.dropshots.tasks import java.nio.file.Files import kotlin.io.path.ExperimentalPathApi diff --git a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/PullScreenshotsTask.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/PullScreenshotsTask.kt similarity index 97% rename from dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/PullScreenshotsTask.kt rename to dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/PullScreenshotsTask.kt index 7f8c297..3f7873a 100644 --- a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/PullScreenshotsTask.kt +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/PullScreenshotsTask.kt @@ -1,4 +1,4 @@ -package com.dropbox.dropshots +package com.dropbox.dropshots.tasks import com.android.build.gradle.internal.LoggerWrapper import com.android.build.gradle.internal.testing.ConnectedDeviceProvider diff --git a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/UpdateScreenshotsTask.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/UpdateScreenshotsTask.kt similarity index 97% rename from dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/UpdateScreenshotsTask.kt rename to dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/UpdateScreenshotsTask.kt index 97fcd52..a902d14 100644 --- a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/UpdateScreenshotsTask.kt +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/UpdateScreenshotsTask.kt @@ -1,4 +1,4 @@ -package com.dropbox.dropshots +package com.dropbox.dropshots.tasks import java.nio.file.Files import kotlin.io.path.ExperimentalPathApi diff --git a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/WriteConfigFileTask.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/WriteConfigFileTask.kt similarity index 86% rename from dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/WriteConfigFileTask.kt rename to dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/WriteConfigFileTask.kt index 8a84267..20fa51b 100644 --- a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/WriteConfigFileTask.kt +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/WriteConfigFileTask.kt @@ -1,15 +1,17 @@ -package com.dropbox.dropshots +package com.dropbox.dropshots.tasks import com.android.build.gradle.internal.LoggerWrapper -import com.android.build.gradle.internal.testing.ConnectedDeviceProvider import com.android.ddmlib.DdmPreferences import com.android.ddmlib.MultiLineReceiver +import com.dropbox.dropshots.configFileName +import com.dropbox.dropshots.device.DeviceProviderFactory import com.dropbox.dropshots.model.TestRunConfig import java.io.File import java.util.concurrent.TimeUnit import org.gradle.api.DefaultTask import org.gradle.api.provider.Property import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Nested import org.gradle.api.tasks.TaskAction import org.gradle.work.DisableCachingByDefault @@ -28,6 +30,9 @@ public abstract class WriteConfigFileTask : DefaultTask() { @get:Input public abstract val remoteDir: Property + @get:Nested + public abstract val deviceProviderFactory: DeviceProviderFactory + init { description = "Writes Dropshots config file to emulator" outputs.upToDateWhen { false } @@ -36,12 +41,8 @@ public abstract class WriteConfigFileTask : DefaultTask() { @TaskAction public fun performAction() { val iLogger = LoggerWrapper(logger) - val deviceProvider = ConnectedDeviceProvider( - adbExecutable.get(), - DdmPreferences.getTimeOut(), - iLogger, - System.getenv("ANDROID_SERIAL"), - ) + val deviceProvider = deviceProviderFactory + .getDeviceProvider(adbExecutable, iLogger) deviceProvider.use { @Suppress("UnstableApiUsage") diff --git a/dropshots-gradle-plugin/src/test/kotlin/com/dropbox/dropshots/ConnectedDeviceTest.kt b/dropshots-gradle-plugin/src/test/kotlin/com/dropbox/dropshots/ConnectedDeviceTest.kt new file mode 100644 index 0000000..4aeb971 --- /dev/null +++ b/dropshots-gradle-plugin/src/test/kotlin/com/dropbox/dropshots/ConnectedDeviceTest.kt @@ -0,0 +1,56 @@ +package com.dropbox.dropshots + +import com.dropbox.dropshots.rules.TestProjectRule +import com.google.common.truth.Truth.assertThat +import org.gradle.testkit.runner.TaskOutcome +import org.junit.Rule +import org.junit.Test + +class ConnectedDeviceTest { + + @get:Rule val testProject = TestProjectRule("connected-device") + + @Test + fun `executes marker file push when record task is run`() { + val result = testProject.execute("updateConnectedDebugAndroidTestScreenshots") + with(result.task(":module:writeConnectedDebugAndroidTestScreenshotConfigFile")) { + assertThat(this).isNotNull() + assertThat(this!!.outcome).isEqualTo(TaskOutcome.SUCCESS) + } + } + + @Test + fun `executes marker file push when test task is run`() { + val result = testProject.execute("connectedDebugAndroidTest") + with(result.task(":module:writeConnectedDebugAndroidTestScreenshotConfigFile")) { + assert(this != null) { + "Expected :module:writeConnectedDebugAndroidTestScreenshotConfigFile in task list.\n${result.output}" + } + assertThat(this).isNotNull() + assertThat(this!!.outcome).isEqualTo(TaskOutcome.SUCCESS) + } + } + + @Test + fun `writes correct config while testing`() { + val result = testProject.execute( + "connectedDebugAndroidTest", + "-Pandroid.testInstrumentationRunnerArguments.dropshots.test.testConfig=true", + "-Pandroid.testInstrumentationRunnerArguments.class=com.dropbox.dropshots.test.TestRunConfig", + ) + assertThat(result.task(":module:connectedDebugAndroidTest")?.outcome) + .isEqualTo(TaskOutcome.SUCCESS) + } + + @Test + fun `writes correct config while recording`() { + val result = testProject.execute( + "updateConnectedDebugAndroidTestScreenshots", + "-Pandroid.testInstrumentationRunnerArguments.dropshots.test.testConfig=true", + "-Pandroid.testInstrumentationRunnerArguments.dropshots.test.expectRecording=true", + "-Pandroid.testInstrumentationRunnerArguments.class=com.dropbox.dropshots.test.TestRunConfig", + ) + assertThat(result.task(":module:connectedDebugAndroidTest")?.outcome) + .isEqualTo(TaskOutcome.SUCCESS) + } +} diff --git a/dropshots-gradle-plugin/src/test/kotlin/com/dropbox/dropshots/DropshotsPluginTest.kt b/dropshots-gradle-plugin/src/test/kotlin/com/dropbox/dropshots/DropshotsPluginTest.kt index 6630627..377ab9f 100644 --- a/dropshots-gradle-plugin/src/test/kotlin/com/dropbox/dropshots/DropshotsPluginTest.kt +++ b/dropshots-gradle-plugin/src/test/kotlin/com/dropbox/dropshots/DropshotsPluginTest.kt @@ -1,97 +1,27 @@ package com.dropbox.dropshots +import com.dropbox.dropshots.rules.TestProjectRule import com.google.common.truth.Truth.assertThat -import java.io.File -import org.gradle.testkit.runner.GradleRunner -import org.gradle.testkit.runner.TaskOutcome -import org.junit.Before import org.junit.Rule import org.junit.Test -import org.junit.rules.TemporaryFolder class DropshotsPluginTest { - @get:Rule val tmpFolder = TemporaryFolder() - - private lateinit var buildFile: File - private lateinit var gradleRunner: GradleRunner - - @Before - fun setup() { - val localMavenRepo = File("../build/localMaven").absolutePath - val versionsFilePath = File("../gradle/libs.versions.toml").absolutePath - - // Setup project directory - val projectDir = tmpFolder.newFolder().apply { mkdir() } - projectDir.resolve("gradle.properties").writeText("android.useAndroidX=true") - - projectDir.resolve("settings.gradle").writeText( - // language=groovy - """ - pluginManagement { - repositories { - gradlePluginPortal() - mavenCentral() - google() - } - } - - dependencyResolutionManagement { - versionCatalogs { - libs { - from(files("$versionsFilePath")) - } - } - - repositories { - maven { - url("$localMavenRepo") - } - mavenCentral() - google() - } - } - """.trimIndent() - ) - - buildFile = projectDir.resolve("build.gradle") - buildFile.writeText( - // language=groovy - """ - plugins { - alias(libs.plugins.android.library) - id("com.dropbox.dropshots") - } - - android { - namespace = "com.dropbox.dropshots.test.library" - compileSdk = 35 - - defaultConfig.minSdk = 24 - } - """.trimIndent() - ) - - gradleRunner = GradleRunner.create() - .withPluginClasspath() - .withProjectDir(projectDir) - } + @get:Rule val testProject = TestProjectRule("connected-device") @Test fun configurationCache() { // first build - gradleRunner.withArguments("tasks", "--configuration-cache").build() + testProject.execute("tasks", "--configuration-cache") // Cached build - val result = gradleRunner - .withArguments("tasks", "--configuration-cache", "--stacktrace") - .build() + val result = testProject.execute("tasks", "--configuration-cache", "--stacktrace") assertThat(result.output).contains("Reusing configuration cache.") } @Test fun `applies to library plugins applied after plugin`() { - val result = gradleRunner + val result = testProject .withBuildScript( // language=groovy """ @@ -112,36 +42,8 @@ class DropshotsPluginTest { } """.trimIndent() ) - .withArguments("tasks") - .build() - assertThat(result.output).contains("recordDebugAndroidTestScreenshots") - assertThat(result.output).contains("pullDebugAndroidTestScreenshots") - } - - @Test - fun `executes marker file push only when record task is run`() { - val result = gradleRunner - .withArguments("recordDebugAndroidTestScreenshots") - .build() - with(result.task(":pushDebugAndroidTestScreenshotMarkerFile")) { - assertThat(this).isNotNull() - assertThat(this!!.outcome).isEqualTo(TaskOutcome.SUCCESS) - } - } - - @Test - fun `skips marker file push only when record task is run`() { - val result = gradleRunner - .withArguments("connectedDebugAndroidTest") - .build() - with(result.task(":pushDebugAndroidTestScreenshotMarkerFile")) { - assertThat(this).isNotNull() - assertThat(this!!.outcome).isEqualTo(TaskOutcome.SKIPPED) - } - } - - private fun GradleRunner.withBuildScript(buildScript: String): GradleRunner { - buildFile.writeText(buildScript) - return this + .execute("tasks") + assertThat(result.output).contains("updateConnectedDebugAndroidTestScreenshots") + assertThat(result.output).contains("pullConnectedDebugAndroidTestScreenshots") } } diff --git a/dropshots-gradle-plugin/src/test/kotlin/com/dropbox/dropshots/rules/TestProjectRule.kt b/dropshots-gradle-plugin/src/test/kotlin/com/dropbox/dropshots/rules/TestProjectRule.kt new file mode 100644 index 0000000..58a558e --- /dev/null +++ b/dropshots-gradle-plugin/src/test/kotlin/com/dropbox/dropshots/rules/TestProjectRule.kt @@ -0,0 +1,116 @@ +package com.dropbox.dropshots.rules + +import java.io.File +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.copyToRecursively +import org.gradle.testkit.runner.BuildResult +import org.gradle.testkit.runner.GradleRunner +import org.junit.rules.ExternalResource +import org.junit.rules.TemporaryFolder + +class TestProjectRule( + val name: String, +) : ExternalResource() { + + private val tempFolder = TemporaryFolder() + lateinit var buildFile: File + private set + lateinit var gradleRunner: GradleRunner + private set + + override fun before() { + tempFolder.create() + createProject() + } + + override fun after() { + tempFolder.delete() + } + + private fun createProject() { + val rootProjectDir = File("..").absolutePath + val versionsFilePath = File("../gradle/libs.versions.toml").absolutePath + + // Setup project directory + val projectDir = tempFolder.newFolder().apply { mkdir() } + projectDir.resolve("gradle.properties").writeText( + """ + org.gradle.jvmargs=-Xmx1g + android.useAndroidX=true + """.trimIndent()) + + projectDir.resolve("settings.gradle").writeText( + // language=groovy + """ + pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + google() + } + includeBuild("$rootProjectDir") + } + + rootProject.name = "test-project" + include(":module") + + includeBuild("$rootProjectDir") { + dependencySubstitution { + substitute(module("com.dropbox.dropshots:dropshots")).using(project(":dropshots")) + substitute(module("com.dropbox.dropshots:model")).using(project(":model")) + } + } + + dependencyResolutionManagement { + versionCatalogs { + libs { + from(files("$versionsFilePath")) + } + } + + repositories { + mavenCentral() + google() + } + } + """.trimIndent() + ) + + projectDir.resolve("build.gradle").writeText( + // language=groovy + """ + plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlinx.serialization) apply false + } + """.trimIndent() + ) + + val moduleDir = projectDir.resolve("module") + moduleDir.mkdirs() + + @OptIn(ExperimentalPathApi::class) + File("src/test/projects/$name").toPath() + .copyToRecursively( + moduleDir.toPath(), + followLinks = true, + overwrite = true, + ) + + buildFile = projectDir.resolve("module/build.gradle") + + gradleRunner = GradleRunner.create() + .withProjectDir(projectDir) + } + + fun execute(vararg tasks: String): BuildResult = + gradleRunner.withArguments(*tasks).build() + + fun withBuildScript(buildScript: String): TestProjectRule { + buildFile.writeText(buildScript) + return this + } +} diff --git a/dropshots-gradle-plugin/src/test/projects/common-settings.gradle.kts b/dropshots-gradle-plugin/src/test/projects/common-settings.gradle.kts new file mode 100644 index 0000000..557acd6 --- /dev/null +++ b/dropshots-gradle-plugin/src/test/projects/common-settings.gradle.kts @@ -0,0 +1,22 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + google() + } +} + +includeBuild("../../../..") { + dependencySubstitution { + substitute(module("com.dropbox.dropshots:dropshots")).using(project(":dropshots")) + substitute(module("com.dropbox.dropshots:model")).using(project(":model")) + } +} + +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("../../../../gradle/libs.versions.toml")) + } + } +} diff --git a/dropshots-gradle-plugin/src/test/projects/connected-device/build.gradle.kts b/dropshots-gradle-plugin/src/test/projects/connected-device/build.gradle.kts new file mode 100644 index 0000000..7cc6bd5 --- /dev/null +++ b/dropshots-gradle-plugin/src/test/projects/connected-device/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + id("com.dropbox.dropshots") + alias(libs.plugins.android.library) +} + +android { + namespace = "com.dropbox.dropshots.test" + compileSdk = 34 + testOptions.targetSdk = 34 + lint.targetSdk = 34 + defaultConfig.minSdk = 21 +} diff --git a/dropshots-gradle-plugin/src/test/projects/connected-device/src/androidTest/kotlin/com/dropbox/dropshots/test/MainTest.kt b/dropshots-gradle-plugin/src/test/projects/connected-device/src/androidTest/kotlin/com/dropbox/dropshots/test/MainTest.kt new file mode 100644 index 0000000..6a724d6 --- /dev/null +++ b/dropshots-gradle-plugin/src/test/projects/connected-device/src/androidTest/kotlin/com/dropbox/dropshots/test/MainTest.kt @@ -0,0 +1,48 @@ +package com.dropbox.dropshots.test + +import android.view.View +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.dropbox.dropshots.Dropshots +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Currently recorded screenshots are done with a pixel_5-31 + */ +@RunWith(AndroidJUnit4::class) +class MainTest { + + @get:Rule + val activityScenarioRule = ActivityScenarioRule(MainActivity::class.java) + + @get:Rule + val dropshots = Dropshots() + + @Test + fun basicActivityView() { + activityScenarioRule.scenario.onActivity { + dropshots.assertSnapshot(it) + } + } + + @Test + fun testColors() { + activityScenarioRule.scenario.onActivity { + val purpleView = it.findViewById(R.id.purpleView) + dropshots.assertSnapshot( + view = purpleView, + name = "purple", + filePath = "views/colors" + ) + + val redView = it.findViewById(R.id.redView) + dropshots.assertSnapshot( + view = redView, + name = "red", + filePath = "views/colors" + ) + } + } +} diff --git a/dropshots-gradle-plugin/src/test/projects/connected-device/src/androidTest/kotlin/com/dropbox/dropshots/test/TestRunConfigTest.kt b/dropshots-gradle-plugin/src/test/projects/connected-device/src/androidTest/kotlin/com/dropbox/dropshots/test/TestRunConfigTest.kt new file mode 100644 index 0000000..ee5b8bc --- /dev/null +++ b/dropshots-gradle-plugin/src/test/projects/connected-device/src/androidTest/kotlin/com/dropbox/dropshots/test/TestRunConfigTest.kt @@ -0,0 +1,66 @@ +package com.dropbox.dropshots.test + +import android.annotation.SuppressLint +import android.net.Uri +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.dropbox.dropshots.Dropshots +import com.dropbox.dropshots.configFileName +import com.dropbox.dropshots.model.TestRunConfig +import java.io.File +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Currently recorded screenshots are done with a pixel_5-31 + */ +@RunWith(AndroidJUnit4::class) +class TestRunConfigTest { + + @get:Rule + val activityScenarioRule = ActivityScenarioRule(MainActivity::class.java) + + @get:Rule + val dropshots = Dropshots() + + private fun shouldExecuteTests() = InstrumentationRegistry.getArguments() + .getBoolean("dropshots.test.testConfig", false) + + private fun readConfig(): TestRunConfig { + val targetApplicationId = InstrumentationRegistry.getInstrumentation().targetContext.packageName + @SuppressLint("SdCardPath") + val testDataFileUri = Uri.fromFile(File("/sdcard/Android/media/${targetApplicationId}/dropshots/$configFileName")) + return InstrumentationRegistry.getInstrumentation().context + .contentResolver + .openInputStream(testDataFileUri) + .use { inputStream -> + requireNotNull(inputStream) + TestRunConfig.read(inputStream) + } + } + + @Test + fun testConfigContainsConnectedDeviceName() { + assumeTrue(shouldExecuteTests()) + + val config = readConfig() + assert(config.deviceName == "connected") { + "Expected config.deviceName to be 'connected', but got '${config.deviceName}'" + } + } + + @Test + fun testConfigContainsIsRecording() { + assumeTrue(shouldExecuteTests()) + + val config = readConfig() + val expectRecording = InstrumentationRegistry.getArguments() + .getBoolean("dropshots.test.expectRecording", false) + assert(config.isRecording == expectRecording) { + "Expected config.isRecording to be '$expectRecording', but got '${config.isRecording}'" + } + } +} diff --git a/dropshots-gradle-plugin/src/test/projects/connected-device/src/androidTest/screenshots/connected/basicActivityView.png b/dropshots-gradle-plugin/src/test/projects/connected-device/src/androidTest/screenshots/connected/basicActivityView.png new file mode 100644 index 0000000000000000000000000000000000000000..87b3b45b47c60d95f76d36148eee09b016130232 GIT binary patch literal 27612 zcmeIbXH=A1(=CeI2q>T=1qD$M8iZDIM#)J)a&A#_lpq<}L{KEyG$0wtl5-9!0s;b( zGf2)EnoOVK{l*#N{`$uE=bm%l_sSn^HvK&7sZ}*=&RMJ3?^KjzE)mfX5fBhul9QEG zB_KG%Pe5?~<%NIXC&{Ou8^E{Ij^c9a7cN{F8dv^9KyZ^lPV%9;Ys}J!izh|T#Ljvl z)4(Gi#_akZZe*e=MUJeR;4P^yomi?{BC>O; z60&0$uj8MqO(VaSY)#de$2YzA{9=~c;$j9~ZN>^nb;(*&!kq~c?hGF0ocx18_OklP zw{((+XW`q+1+u>{|4pM2R8wJ&8GCWEn=dcACY~hPgBKTAPhPA0CH#H)e=!BR2hl|3((a76v+6c#NjXiGDPU)Lekyp@a5Jl`Daq-Qfh<#O z+wHpGyNC&#fYlaZ%a_&}uA#CkkUU07;RzEOl4@VRc!a$}KM~rAb{p$MQ19qCe9?U1 zJ--{b?sM5UK_#s{YwO%)T`g)|tv=6B4Etp-`l_a$Wh%PqNI)j)YrOPZ*`?~@UYg$P zrK=?rI%@9iFgZS#aPvZTrU(8Lx-`;|L-oC6e__Qqo7Xvx^F_^GBGHH$2H(rRGbxPvfrDXqNzY|CkXfFCo zC9%M^O~F2VYB-$+o+FNR?IBa(H<_)tS%a#?6~FuM_6(@g{nJWW3?J_6f0xJ(zVZ5d z(KFtCO2oLcyqs2fx%Us!ngGMV_HVkU2neQ#HO_}wF=vzJ9NSskR<;`_PiNPazpdlJ zYLMA+j^T{X*|X~Ld!r<3&D(0dOGt{%5~2~~3i%IGvEo7j)z2Z!zT-Dr-~xIyz#ZYKLbSK@uEg_%|t)=K(1=oJY` zb;PL&2q>PMb>KYedDg2jc||OX-ow7usTvt5?G(47kt6E3`1mWgcWc5&kw*gu8t)lX z`W1r6$F`LCD;3-+DcDmiIDh|HV~1@xEA{FktHp|;eC!y*N^WfD4aN^O6FoYS)=mGU z2MQacG5QCpVK}`e2j$*T4OIN#8I+@MgSS!K&7j*4L#QS0rYe6gQagS)&B>T!iCoAm z?P*!%Hp_6=^V;*>f7z*94%A8oG1w7RfuL9alZM3GbI2pwpyn`Ra#%O%Nmx+v-g_RE~7+o>f?- z85T8rq@H(rKU;PYw$hBo?w*c=5t)^Xu7)f>9g$imV6xGpX!FIXR1pxf$#t!qv%k zZVr9U?wJ;o2Xe|QtgQrL(TjS)JiX?#4Dmgon&Ssrs(5Xy!A%o~r5mM=K5Y_CpRBDp zf0G@4DB@Ag9-ZCMcJ>T+vMuclGQ|zyk^-dU)t$G~pEt)J} zC8Yr6kkn1?Tz3PJ)A9;dPYd5!F=q{#GQ|#l8vnyCO{4pP8~Y_Cl=M{qVAY{lb9-Uv zQp5gvbb2;}JbnVuFZoseym?PH(O#U|M(-MS zvHdsSCA;jMEvh?biC?&=W$&>cOBuK)Q5sxz7$CHK!IJw{GKM!9 z^Vta5Zr~V-j+-__GVWd***v|Ctes_A)k;|>?ck<2O&=I_>?wO2O3OJfvk;ircYT#w z`s&mX;{g$MZo1c=$g@o&-xqdFbbF(Lm)ay27kqQp@=0BBMS_oyq`bnY4@=N7ReNuK z$=siI9%79cTD}uO7OGpM?rVDYu}&Hv5mR*H;jxYUmc0dq6K+&6*L5p$)gY@X^X{w> z1<#O2xB2Yl{GKXH&xty7KHI~CGv>5gTT2H%rD*@j&GZicJhK;ehjW!jlT(4K%!Ca8 zNEAy%9H`sO7iD_|SPUMwXs3nFcAg^C3SzlbE#QGD&y*+jYHzQui)oMOnwQe{GSK$Q zZ8#D}UyZ){f~8V9kbr>2m`Fn9viNnB-NKf)@5ueCL!K9vu5@}~Ze#b0a%zn1l&h^* z>@$*Tbl^IJO81&qcLk?tljm_CwbtKPtokytxW6G2_a|AsYE`uPbL;j$@>ae_eLI=S zyFXDe&UstX>odHyYrXRA1!#ZMIimQlD$DKk2qJ=0DaDhedrewD!!+yEsyKT-ZT*

$`&slu_Y0yh|{fb_f>*&OWizgxX5fqx5lwltlyuR9zEoon19e{$k7=g5-8p6 zR-YuPQM-3zVg>a$N@Kwpmp)X_)zxZQB-pWT@N6lzn`@{Qzh9p#8TMRd zi3qkw2#dqlJa&guv=o}M$KvM*Zpu(#m{}u8bup7Z#i7wc@o8FZL|ORPB> zBH2f229$X>@9#QWW=K&Wz96)ek*g^peSMqFMlEs3k)b#3rsOWjl!oKbC`(Eyd2@MR zp{tdvw8?HS!gmr^NK7ZY7iT&il+;P9W-U)iZ-ym>Gw-Md)SwnxcDm*66(!_>D@rMOQT4IJy=5M}43baM zOI)S0-yomg545{q`Oav40;gm3JxN{J?$P>t!M#H&2FX?&9aDxmzI?91yvW*6-WH)J z(`S`adU#+bf2B`Jmu^hH$HNk;(8N;CESm!Hd44Z)M@ytY54Z?Xp!gN_w`8QbI!w3C zuwzTg&x-w{&9anNQt#9cR-NX?DXR-tOV#>_ZnQNpM1jqKfXz3g?4~#TVzK z5C3d7pEqRgjSk9EEO+E!Q;@3W?wBLLS$)g7^^u{_{bwrCtcIRlzpa8w?@YEH&F;Ii zlCttf^H+V}+FW8hKzfEz?TZpuwe&i89(*+=Kd4a};1sE=!ueK! zNJ|S?HeoD4Bg##FU!ivsiXVMycw{>9CKGQJSWx`pbj|w5yRTI68o>+5`SulKf~y1s z;uKMzQR4nn{PQ-}@elaCD{r$sqVIlxly@8oAW=IXdN0@D@Q?&wSv>F*`?w~go8C&1 zSg=81WvOAf$V-UDXm+;K5UX_=dUEr*9~bgh7qRfZ!( z)&yo^r$f&XJjAQ!)@4hwY=ABBc-7Tt%fxCWq{Q3ie|O^>0Qw zO7$(7_)=W_#;o9cS2Sm_)2q+&$aElcH8)~zzMBS`KT{kKP(IIZvK$v*wGk-Uw4jI1 zy3f9F*)A;b9dUN%7KuWTzI-!)-8Ed+R@Gs7+g5zrIu0Rq!T$F5iUhS7V>hcnX>R)P zXJf{YxfS2Z)m4pe-NHBnr_Q0>0XC_=(OD{u;-brU+?{}!wuv7;5^ zpT>Wa!$T3r5v5ws2}^c#cD|2ws0sM_&CX<22}vQHM0{|!jf&F0`k7G4;5UMj#k6cI zW`0AiKMVI4O$I0&sY7Equk$irS6}bQ&=fC{SieN{YVNVc2zO7 zsQ=n2HTf5_uH^Ak=X`=K9}yDoOJfNa4SosR4k=DQsw^)tao_N%4j0|5mRPJHI!6-- zI*d4bv1i<7@|y`l&y2C0pIWnUH$gKi`S(EGC59jMUR2SK1idn438mKm7-BPT2qax+ z=U8$#nH@6A#u(`udcWsbzFv;S5)*Z4Vnu?4^0=80z4KZVzc;WsT%3xEocr$=knV^1 zcl0(t0ynr$q^$B$t?W;GpueIZQ_rBRU49hi$19V_t#^2RtA*|Ih^b@ul+^ttm(<`K zfT_1+M(w6^2KA%)On`Ckjm=-%(z}iL=kdoZc-hBKPt;teyN1TEs=JwKac(Kt*kt-T zlZ)#MK8ZA3mCNk#rN*1wjT`7XUHjybpUa0!9v-%yV)JvPNpoI5?C2wU_!g+7<~Q9I~8XHLaVUa&-iI@&n*&spT<;Pl+>1efN`oC$W^gk z?Z1(G4$@zGV+L$=MRn)*5di@=3c8y7vao8xC8IuUGIxXR6vPY)LSB+h zY^)Y2=cYx|Jx*@_XLTcRIC?aMN3&2iYbf%}bMEvY>xNUm`B9yJcn~!^ZgFzu9 zGp>qEoPxNE}ZfOssWms7*BG=M*7f7sUk&MCndFHe}Mv+wnL>bIMU zD+0lYB0V||Q#Wg$=oATACQN!>E)Hc_{e4qX%7I${f>G1T@;3f9{V;mg({x0d;VoHd zyF6+p*LqDcbp>z3uFG0izR7+^@3)1aLoqKM=A9rU9q;5tn}%z2hRW66&F<6w)44Zh zXfMC_jvY?;UZSB_d32mTzhn1^sjfKifY$QG8jD)XV?db;pF7_7KD%@d6^MCs59lq; z_V&f>B&6YEy&|k;&BCCXcvDJph^ITo<#FpFdHlb>Pqt*8??K31)uM(xH~AjpLr8pgqcMbTJPQa(&9%_(ncnJ#>euF zc<~D%4<~&8qM`Z%N~qN@JgrQ-h%X{N&!%U<)}<}Y)|Jnqu2Rm#Y7v%{HP%|_D%JQc zYyL@lWkmLnnUI&m@i0^-q@s)P~e&7ZI#3%xuS>D4hgMlSB$IKWMZS^&6v=9+z&;|q!L}FY#=LLf_Ihs zr?tN5#F=VQmSn5AR6<4{Z#j2P$>*bLXy{+`udBaz4*ei?!zL_!lS~4?$mLvUw|6cX zi%(ipRTV!rX`^|=H+znNfPv_d3ZaMQESp}}=4e<9A*;@8dJ)X`Zc1JUPJ7PL9vmt2?xi&szXFrc9+1_iny|PpHNpkNgO7vA!@3ZUH zjrW}kS2vGRhD=iPKm7>xC^_HpWgb7^FyofIFg=_b`K)gCCxU?B0s~R&@7%Rs5}at6 zH>=rxc9XI-COb8-d8J(Nx)ld~A@=M>2LiFKB&1JBYO{EU$tEhQJ3aSv8yg)Z?V`bl z?|p9mz0WSfO_`23<7Y#>SgnVgENx4jJZcp42i|&$9e^@@`!jFOxpC(M&yWH#^zGFl z@Hor7V$$t2M@GnXiX+^neE>is1fbu-SDZs*Mhj}@W_bB-WoLof@~&9#L^HnO%9R%G92&YTCHhD)&9sQs959v%i1m_6vP6u`=QJewXpSmo&`IFfB9bOK7e%>6!FBVt?9 z9mWZtm(M(V*L+P-8*DG$?vV2jrF?7_#W{+j*)^P^ZaumzqZLVUj*{WWwwk8go2y4W z(qbQL>`aqJ<3wLwbDz=-(B@?sM<^q14Mku?_QXl*;}DF`tWFX9r1lvf>o9bznyy<3 z_KcSZASY%(bs>s!3~Oi#^y_C+1lIQFuWQq>X1(Hgz^k#}5Sev-0CN{Q7-!))xm*2r znkgxN4ZR^&Y~gY^g_VLb#r&}D)vs8VAq&Y+BikLWz~tfzN{edeTVvrVn(wYxVc(K% zOlK%)^$F<3Tp}Pa&iIPIJ?IhVQtsaAkqBnm3JUY=;q)Qhe4tHIuDKw8Xk=lrkQ)VY zDDUc+jN_BRUyet~9u^8gS({|>`s^D6+ZXXo^nxi0?~85l+7(gRX%QDNJz|YtGHf>U zN2V$pv1m`)U{9MKTpsJGlfnYG;OImw4j7iP^Q^RRl754 z_d3oKyc~X<_eMVc>?pDZv259I{dDM8@?Gnf%fEa?!iA`lZyW2@75a~ylp`g^)2u3)vrr}|pW75kkO4OTS?t?KMaZ%>dy)w2Rum3010{Y;q(KMH`5 zC*@u}uC{A>o`&wCIr{ONQna0P&03)Oo|8{kt_T5vG6UGLf6SZ$_uW&EcJ#&CHJ@FK zO>?`1|27+%Db--NeJOh}q0$YAWBqfTVB`>vL%s%IT9kRBt3N%e!Q7uaW{5g9=GrnV z&D{9zMm(EW^X7zW9j(}2cvkrM&sU!Z`Mfu5eyh2Y_G70KJe;=9Td1>*J>WAe!a&N`&Sboy3Pur%8_SA5UZ-l@3T zefvM@(WeO3oK&|nP>;^J^In^4xMRuJrcLb}&>qNR89Moc^x2h0TU^){!-=QSlm8QZ z&c;&JY^uk}Z&UR}st<7`WKS1AB

&y-1#c&yb02lgf!NXc(0%{v`Pk6SdL4a3iQe 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 literal 0 HcmV?d00001 diff --git a/dropshots-gradle-plugin/src/test/projects/connected-device/src/main/AndroidManifest.xml b/dropshots-gradle-plugin/src/test/projects/connected-device/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a88331a --- /dev/null +++ b/dropshots-gradle-plugin/src/test/projects/connected-device/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/dropshots-gradle-plugin/src/test/projects/connected-device/src/main/java/com/dropbox/dropshots/test/MainActivity.kt b/dropshots-gradle-plugin/src/test/projects/connected-device/src/main/java/com/dropbox/dropshots/test/MainActivity.kt new file mode 100644 index 0000000..2f87df1 --- /dev/null +++ b/dropshots-gradle-plugin/src/test/projects/connected-device/src/main/java/com/dropbox/dropshots/test/MainActivity.kt @@ -0,0 +1,11 @@ +package com.dropbox.dropshots.test + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity + +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.acivity_main) + } +} diff --git a/dropshots-gradle-plugin/src/test/projects/connected-device/src/main/res/drawable/icon.png b/dropshots-gradle-plugin/src/test/projects/connected-device/src/main/res/drawable/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a07c69fa5a0f4da5d5efe96eea12a543154dbab6 GIT binary patch literal 2574 zcmV+p3i0)cP)Q`Og{P|8RRXpj5bgrSmEzSMfBn+{{vpNxw?;5UX;iv9sYxy_`IQHs$i<61a_iv^L>h8s-`D(`e@|IgS*Fj zNGM876Gf;3D8*1UX9a%v>yJKD*QkCwW2AirU(L{qNA)JghmGItc;(H<$!ABY&gBy1vJIEUj-b8%el*o|VkG)LqNx#TG>Jvj^jIte!!+RY z)T4j$7+PoF1AkRBf}R#^T=-q|PaK1$c<4UH)Hpq3$4WA|xtr!ZQLC=*vNE>O6E9kp+5X0eKB$6>C(lPwI@3#oY zhS_%x7e|j!$yG?ECXmh~EH~^OeuK}+sWoJse3Z3?ha3n`MM9KvA?uqpEnBg4Q46)7 zM$p%a$@l;+O}vfvx%XjH`}a{(-HHth9!JaUwV0*VqGR48^gWNYN<&~7x)y$e!X>e` zZ5!6KZoxbKuV9XUDI%#M1~IVh?pNSdeb~6@$y`v|yk=XK+fHxnDqnUK4&=QRNyIVf zYbDM*cI>~qIy*a7=z7uqkw@agd(<=y-Q7L!ty_23SGdXmahO<;N=wB+j;lNm%=OHC zy zU|>La6h%92y4IPufI$9>Xu!@y`TaNgtg&41@PwMwBdmSm7)xAWDLoqjZ==P2#*k7! z3o1)cVSI3KP_!?d8G^Lg0FtLXC~JYdxi|c%h~lXEixY=%VSFF@!*3&&9>(Rb|iK54Cx5;s~PY5iaV1het%w`dgQFBAJ;aFK zImQC}(|QaCFYUm1JVfzSc)ebv=)ObI)0jwJb``}Zj9J0n0Xgn*Zc(rFM9$xh_makZbm-at_v5^SW zM1y1SW@%+FuIy*WR)i3A2N_q;(YO`O!A|Ts^%z}9ZepCj3ytlw#x%N_fNrKKtPh`< z|1{UqF`4LxHaCQ79+E=uUXCOZ35jAMRz%R%0(P!0FMv=sk>Nr8%+OzY^c-M9@+fz=G`qa@v4sF5u-2289-#$**LWnyNNDwDf1( zkUiMnw|y$tn>pQP=Vn!#|17L^5AGrjtBkN$D@v)Z7LXc5EFhLB4<;7Wehh)CMqX|W zqsiZaO^benJ_hwa&V0ub$-_HUk**?g6fm9|!@kguU6*zhK)$qn-<3*kFrYPIaqR=V zUaUvk>@F_89b@tHs8R!*QKY;INJ<2_U+K6Ca3e9Gsl2{qY0%a7J?uICWgHuLfj+MB z=GkAN1&ifT#2u}B+2S#~$5jA(Qn^;H%CCmIae4AE-Dsng|Hl*Ov!z72k3ZnJs{pp| z+pW`DDueC#mEWOf=ucJ!dTL}hzOeiS-i?m2E;`EKz4<&Lu~NnW?peqVU^@<+T3KKu z{yrI%Qy-Z%HEvLUz}n^~m?7x`xuCtNR#L2En!T>dQtIKdS#V-Hzt3RtwTeYtmQ&dR z6qXZvac*oc@BUYEH%@Ylv_1&tSjkbzzU6*h1(3^C`;1z;g_SmOtclS?KWk2VYE zM*oS<=C483XckW?GN|1jfh3Ro(h + + + + + + + + + + diff --git a/dropshots-gradle-plugin/src/test/projects/connected-device/src/main/res/values/strings.xml b/dropshots-gradle-plugin/src/test/projects/connected-device/src/main/res/values/strings.xml new file mode 100644 index 0000000..ff321f8 --- /dev/null +++ b/dropshots-gradle-plugin/src/test/projects/connected-device/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + _Test-Basic + diff --git a/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/DropshotsTest.kt b/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/DropshotsTest.kt index e4c9a73..1079ad3 100644 --- a/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/DropshotsTest.kt +++ b/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/DropshotsTest.kt @@ -3,14 +3,12 @@ package com.dropbox.dropshots import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color -import android.net.Uri -import android.os.Environment import android.view.View import androidx.core.net.toFile import androidx.test.ext.junit.rules.ActivityScenarioRule -import androidx.test.platform.io.PlatformTestStorage import androidx.test.platform.io.PlatformTestStorageRegistry import com.dropbox.differ.SimpleImageComparator +import com.dropbox.dropshots.model.TestRunConfig import java.io.File import org.junit.After import org.junit.Assert.assertArrayEquals @@ -29,14 +27,14 @@ class DropshotsTest { private val fakeValidator = FakeResultValidator() private var filenameFunc: (String) -> String = { it } - private val isRecordingScreenshots = isRecordingScreenshots(defaultRootScreenshotDirectory()) + private val isRecordingScreenshots = isRecordingScreenshots() private lateinit var testStorage: FileTestStorage @get:Rule val testName = TestName() @get:Rule val dropshots = Dropshots( filenameFunc = filenameFunc, - recordScreenshots = isRecordingScreenshots, + testRunConfig = TestRunConfig(isRecording = isRecordingScreenshots, deviceName = "test"), resultValidator = fakeValidator, imageComparator = SimpleImageComparator( maxDistance = 0.004f, @@ -91,7 +89,7 @@ class DropshotsTest { val dropshots = Dropshots( testStorage = testStorage, filenameFunc = filenameFunc, - recordScreenshots = true, + testRunConfig = TestRunConfig(isRecording =true, deviceName = "test"), resultValidator = { false }, imageComparator = SimpleImageComparator(), ) @@ -111,7 +109,7 @@ class DropshotsTest { val dropshots = Dropshots( testStorage = testStorage, filenameFunc = filenameFunc, - recordScreenshots = false, + testRunConfig = TestRunConfig(isRecording =false, deviceName = "test"), resultValidator = { false }, imageComparator = SimpleImageComparator(), ) @@ -141,7 +139,7 @@ class DropshotsTest { resultValidator = CountValidator(0), testStorage = testStorage, filenameFunc = filenameFunc, - recordScreenshots = false, + testRunConfig = TestRunConfig(isRecording =false, deviceName = "test"), imageComparator = SimpleImageComparator(), ) @@ -167,7 +165,7 @@ class DropshotsTest { resultValidator = FakeResultValidator { true }, testStorage = testStorage, filenameFunc = filenameFunc, - recordScreenshots = false, + testRunConfig = TestRunConfig(isRecording =false, deviceName = "test"), imageComparator = SimpleImageComparator(), ) @@ -191,7 +189,7 @@ class DropshotsTest { resultValidator = FakeResultValidator { false }, testStorage = testStorage, filenameFunc = filenameFunc, - recordScreenshots = false, + testRunConfig = TestRunConfig(isRecording =false, deviceName = "test"), imageComparator = SimpleImageComparator(), ) @@ -222,7 +220,7 @@ class DropshotsTest { resultValidator = CountValidator(0), testStorage = testStorage, filenameFunc = filenameFunc, - recordScreenshots = false, + testRunConfig = TestRunConfig(isRecording =false, deviceName = "test"), imageComparator = SimpleImageComparator(), ) diff --git a/dropshots/src/main/java/com/dropbox/dropshots/Dropshots.kt b/dropshots/src/main/java/com/dropbox/dropshots/Dropshots.kt index 7fc4b39..eb4f085 100644 --- a/dropshots/src/main/java/com/dropbox/dropshots/Dropshots.kt +++ b/dropshots/src/main/java/com/dropbox/dropshots/Dropshots.kt @@ -29,7 +29,7 @@ import org.junit.runners.model.Statement public class Dropshots @JvmOverloads constructor( private val testStorage: PlatformTestStorage = PlatformTestStorageRegistry.getInstance(), - private val testRunConfig: TestRunConfig = loadConfig(testStorage), + private val testRunConfig: TestRunConfig = loadConfig(), /** * Function to create a filename from a snapshot name (i.e. the name provided when taking @@ -268,19 +268,17 @@ internal fun defaultRootScreenshotDirectory(): File { * Reads the target application's `is_recording_screenshots` boolean resource to determine if * Dropshots should record screenshots or validate them. */ -internal fun isRecordingScreenshots(testStorage: PlatformTestStorage): Boolean { - return loadConfig(testStorage).isRecording +internal fun isRecordingScreenshots(): Boolean { + return loadConfig().isRecording } -internal fun loadConfig(testStorage: PlatformTestStorage): TestRunConfig { +internal fun loadConfig(): TestRunConfig { val targetApplicationId = InstrumentationRegistry.getInstrumentation().targetContext.packageName @SuppressLint("SdCardPath") val testDataFileUri = Uri.fromFile(File("/sdcard/Android/media/${targetApplicationId}/dropshots/$configFileName")) return InstrumentationRegistry.getInstrumentation().context .contentResolver .openInputStream(testDataFileUri) -// .openFile(testDataFileUri, "r", null) -// .let(ParcelFileDescriptor::AutoCloseInputStream) .use { inputStream -> requireNotNull(inputStream) TestRunConfig.read(inputStream) From b3415f6cb32685e9236569862234d20a5f7be20f Mon Sep 17 00:00:00 2001 From: Ryan Harter Date: Thu, 5 Dec 2024 14:19:22 -0600 Subject: [PATCH 06/21] Updates dropshots runtime to use gradle plugin. --- dropshots-gradle-plugin/build.gradle.kts | 15 +- .../dropbox/dropshots/DropshotsExtension.kt | 18 +++ .../com/dropbox/dropshots/DropshotsPlugin.kt | 12 +- .../dropshots/device/DeviceProviderFactory.kt | 2 + dropshots/build.gradle.kts | 130 +----------------- .../test}/MatchesViewScreenshot.png | Bin .../{ => dropshots/test}/static/50x50.png | Bin .../test}/static/MatchesViewScreenshotBad.png | Bin .../static/MatchesViewScreenshotBadSize.png | Bin .../dropshots/CustomImageComparatorTest.kt | 2 +- .../com/dropbox/dropshots/DropshotsTest.kt | 77 +++++++++-- .../connected}/MatchesActivityScreenshot.png | Bin .../connected}/MatchesFullScreenshot.png | Bin .../connected/MatchesViewScreenshot.png | Bin 0 -> 13094 bytes .../java/com/dropbox/dropshots/Dropshots.kt | 12 +- gradle/build-logic/build.gradle.kts | 18 +++ gradle/build-logic/settings.gradle.kts | 42 ++++++ gradle/libs.versions.toml | 6 +- model/build.gradle.kts | 5 +- settings.gradle.kts | 2 + 20 files changed, 180 insertions(+), 161 deletions(-) create mode 100644 dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/DropshotsExtension.kt rename dropshots/src/androidTest/assets/{ => dropshots/test}/MatchesViewScreenshot.png (100%) rename dropshots/src/androidTest/assets/{ => dropshots/test}/static/50x50.png (100%) rename dropshots/src/androidTest/assets/{ => dropshots/test}/static/MatchesViewScreenshotBad.png (100%) rename dropshots/src/androidTest/assets/{ => dropshots/test}/static/MatchesViewScreenshotBadSize.png (100%) rename dropshots/src/androidTest/{assets => screenshots/connected}/MatchesActivityScreenshot.png (100%) rename dropshots/src/androidTest/{assets => screenshots/connected}/MatchesFullScreenshot.png (100%) create mode 100644 dropshots/src/androidTest/screenshots/connected/MatchesViewScreenshot.png create mode 100644 gradle/build-logic/build.gradle.kts create mode 100644 gradle/build-logic/settings.gradle.kts diff --git a/dropshots-gradle-plugin/build.gradle.kts b/dropshots-gradle-plugin/build.gradle.kts index 3676099..faf4fd5 100644 --- a/dropshots-gradle-plugin/build.gradle.kts +++ b/dropshots-gradle-plugin/build.gradle.kts @@ -1,22 +1,19 @@ -import com.android.build.gradle.tasks.SourceJarTask import com.vanniktech.maven.publish.GradlePlugin import com.vanniktech.maven.publish.JavadocJar.Dokka -import org.gradle.jvm.tasks.Jar -import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.dsl.KotlinVersion -import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import com.vanniktech.maven.publish.MavenPublishBaseExtension plugins { `java-gradle-plugin` alias(libs.plugins.kotlin.jvm) alias(libs.plugins.dokka) - alias(libs.plugins.mavenPublish) alias(libs.plugins.binaryCompatibilityValidator) } -mavenPublishing { - configure(GradlePlugin(Dokka("dokkaJavadoc"))) +if (rootProject.name == "dropshots-root") { + apply(plugin = libs.plugins.mavenPublish.get().pluginId) + extensions.configure { + configure(GradlePlugin(Dokka("dokkaJavadoc"))) + } } val generateVersionTask = tasks.register("generateVersion") { diff --git a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/DropshotsExtension.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/DropshotsExtension.kt new file mode 100644 index 0000000..5d8581a --- /dev/null +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/DropshotsExtension.kt @@ -0,0 +1,18 @@ +package com.dropbox.dropshots + +import javax.inject.Inject +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.Property + +public abstract class DropshotsExtension @Inject constructor(objects: ObjectFactory) { + /** + * Whether the Dropshots plugin should automatically apply the + * Dropshots runtime dependency. + * + * You can use this to disable automatic addition of the runtime dependency + * if you have your own fork, but in most cases this should use the default + * value of `true`. + */ + public val applyDependency: Property = objects.property(Boolean::class.java) + .convention(true) +} diff --git a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/DropshotsPlugin.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/DropshotsPlugin.kt index 17f005b..2671953 100644 --- a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/DropshotsPlugin.kt +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/DropshotsPlugin.kt @@ -28,14 +28,18 @@ private const val recordScreenshotsArg = "dropshots.record" public class DropshotsPlugin : Plugin { override fun apply(project: Project): Unit = with(project) { + val dropshotsExtension = extensions.create("dropshots", DropshotsExtension::class.java) + plugins.withType(AndroidBasePlugin::class.java) { plugin -> // Add dropshots dependency afterEvaluate { - it.dependencies.add( - "androidTestImplementation", - "com.dropbox.dropshots:dropshots:$VERSION" - ) + if (dropshotsExtension.applyDependency.get()) { + it.dependencies.add( + "androidTestImplementation", + "com.dropbox.dropshots:dropshots:$VERSION" + ) + } } val updateAllTask = tasks.register("updateDropshotsScreenshots") { diff --git a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/device/DeviceProviderFactory.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/device/DeviceProviderFactory.kt index 1c2dedc..e3f16cf 100644 --- a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/device/DeviceProviderFactory.kt +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/device/DeviceProviderFactory.kt @@ -10,9 +10,11 @@ import org.gradle.api.provider.Provider import org.gradle.api.tasks.Internal public abstract class DeviceProviderFactory { + @Suppress("DEPRECATION") @get:Internal internal var deviceProvider: DeviceProvider? = null + @Suppress("DEPRECATION") public fun getDeviceProvider( adbExecutableProvider: Provider, logger: ILogger = LoggerWrapper.getLogger(DeviceProviderFactory::class.java), diff --git a/dropshots/build.gradle.kts b/dropshots/build.gradle.kts index 4596145..04479cb 100644 --- a/dropshots/build.gradle.kts +++ b/dropshots/build.gradle.kts @@ -1,6 +1,4 @@ import com.vanniktech.maven.publish.AndroidSingleVariantLibrary -import java.io.ByteArrayOutputStream -import java.util.Locale plugins { alias(libs.plugins.android.library) @@ -8,6 +6,12 @@ plugins { alias(libs.plugins.dokka) alias(libs.plugins.mavenPublish) alias(libs.plugins.binaryCompatibilityValidator) + id("com.dropbox.dropshots") +} + +dropshots { + // Our dropshots tests will use us, so we don't want a maven dependency added. + applyDependency.set(false) } android { @@ -56,125 +60,3 @@ mavenPublishing { publishJavadocJar = true, )) } - -val adbExecutablePath = provider { android.adbExecutable.path } -android.testVariants.all { - val screenshotDir = "/storage/emulated/0/Download/screenshots/com.dropbox.dropshots.test" - val connectedAndroidTest = connectedInstrumentTestProvider - val variantSlug = name.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } - - val recordScreenshotsTask = tasks.register("record${variantSlug}Screenshots") - val isRecordingScreenshots = project.objects.property(Boolean::class.java) - project.gradle.taskGraph.whenReady { - isRecordingScreenshots.set(recordScreenshotsTask.map { hasTask(it) }) - } - - val pushMarkerFileTask = tasks.register("push${variantSlug}ScreenshotMarkerFile") { - description = "Push screenshot marker file to test device." - group = "verification" - outputs.upToDateWhen { false } - onlyIf { isRecordingScreenshots.get() } - - doLast { - val adb = adbExecutablePath.get() - project.exec { - executable = adb - args = listOf("shell", "mkdir", "-p", screenshotDir) - } - project.exec { - executable = adb - args = listOf("shell", "touch", "$screenshotDir/.isRecordingScreenshots") - } - } - } - - val setupEmulatorTask = tasks.register("setup${variantSlug}ScreenshotEmulator") { - description = "Configures the test device for screenshots." - group = "verification" - doLast { - val adb = adbExecutablePath.get() - fun adbCommand(cmd: String): ExecResult { - return project.exec { - executable = adb - args = cmd.split(" ") - } - } - - adbCommand("root") - adbCommand("wait-for-device") - adbCommand("shell settings put global sysui_demo_allowed 1") - adbCommand("shell am broadcast -a com.android.systemui.demo -e command enter") - .assertNormalExitValue() - adbCommand("shell am broadcast -a com.android.systemui.demo -e command clock -e hhmm 1234") - adbCommand("shell am broadcast -a com.android.systemui.demo -e command battery -e plugged false") - adbCommand("shell am broadcast -a com.android.systemui.demo -e command battery -e level 100") - adbCommand("shell am broadcast -a com.android.systemui.demo -e command network -e wifi show -e level 4") - adbCommand("shell am broadcast -a com.android.systemui.demo -e command network -e mobile show -e datatype none -e level 4") - adbCommand("shell am broadcast -a com.android.systemui.demo -e command notifications -e visible false") - } - } - val restoreEmulatorTask = tasks.register("restore${variantSlug}ScreenshotEmulator") { - description = "Restores the test device from screenshot mode." - group = "verification" - doLast { - project.exec { - executable = adbExecutablePath.get() - args = "shell am broadcast -a com.android.systemui.demo -e command exit".split(" ") - } - } - } - - val pullScreenshotsTask = tasks.register("pull${variantSlug}Screenshots") { - description = "Pull screenshots from the test device." - group = "verification" - outputs.dir(project.layout.buildDirectory.dir("reports/androidTests/dropshots")) - outputs.upToDateWhen { false } - - doLast { - val outputDir = outputs.files.singleFile - outputDir.mkdirs() - - val adb = adbExecutablePath.get() - val checkResult = project.exec { - executable = adb - args = listOf("shell", "test", "-d", screenshotDir) - isIgnoreExitValue = true - } - - if (checkResult.exitValue == 0) { - val output = ByteArrayOutputStream() - val pullResult = project.exec { - executable = adb - args = listOf("pull", "$screenshotDir/.", outputDir.path) - standardOutput = output - isIgnoreExitValue = true - } - - if (pullResult.exitValue == 0) { - val fileCount = """^${screenshotDir.replace(".", "\\.")}/?\./: ([0-9]*) files pulled,.*$""".toRegex() - val matchResult = fileCount.find(output.toString(Charsets.UTF_8)) - if (matchResult != null && matchResult.groups.size > 1) { - println("${matchResult.groupValues[1]} screenshots saved at ${outputDir.path}") - } else { - println("Unknown result executing adb: $adb pull $screenshotDir/. ${outputDir.path}") - print(output.toString(Charsets.UTF_8)) - } - } else { - println("Failed to pull screenshots.") - } - - project.exec { - executable = adb - args = listOf("shell", "rm", "-r", "/storage/emulated/0/Download/screenshots") - } - } - } - } - - connectedAndroidTest.configure { - dependsOn(pushMarkerFileTask) - finalizedBy(pullScreenshotsTask) - dependsOn(setupEmulatorTask) - finalizedBy(restoreEmulatorTask) - } -} diff --git a/dropshots/src/androidTest/assets/MatchesViewScreenshot.png b/dropshots/src/androidTest/assets/dropshots/test/MatchesViewScreenshot.png similarity index 100% rename from dropshots/src/androidTest/assets/MatchesViewScreenshot.png rename to dropshots/src/androidTest/assets/dropshots/test/MatchesViewScreenshot.png diff --git a/dropshots/src/androidTest/assets/static/50x50.png b/dropshots/src/androidTest/assets/dropshots/test/static/50x50.png similarity index 100% rename from dropshots/src/androidTest/assets/static/50x50.png rename to dropshots/src/androidTest/assets/dropshots/test/static/50x50.png diff --git a/dropshots/src/androidTest/assets/static/MatchesViewScreenshotBad.png b/dropshots/src/androidTest/assets/dropshots/test/static/MatchesViewScreenshotBad.png similarity index 100% rename from dropshots/src/androidTest/assets/static/MatchesViewScreenshotBad.png rename to dropshots/src/androidTest/assets/dropshots/test/static/MatchesViewScreenshotBad.png diff --git a/dropshots/src/androidTest/assets/static/MatchesViewScreenshotBadSize.png b/dropshots/src/androidTest/assets/dropshots/test/static/MatchesViewScreenshotBadSize.png similarity index 100% rename from dropshots/src/androidTest/assets/static/MatchesViewScreenshotBadSize.png rename to dropshots/src/androidTest/assets/dropshots/test/static/MatchesViewScreenshotBadSize.png diff --git a/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/CustomImageComparatorTest.kt b/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/CustomImageComparatorTest.kt index 833390d..4c563d3 100644 --- a/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/CustomImageComparatorTest.kt +++ b/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/CustomImageComparatorTest.kt @@ -7,6 +7,7 @@ import com.dropbox.differ.Image import com.dropbox.differ.ImageComparator import com.dropbox.differ.ImageComparator.ComparisonResult import com.dropbox.differ.Mask +import com.dropbox.dropshots.model.TestRunConfig import java.io.File import org.junit.After import org.junit.Before @@ -26,7 +27,6 @@ class CustomImageComparatorTest { @get:Rule val dropshots = Dropshots( filenameFunc = defaultFilenameFunc, - recordScreenshots = false, imageComparator = comparator, resultValidator = CountValidator(0), ) diff --git a/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/DropshotsTest.kt b/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/DropshotsTest.kt index 1079ad3..4858bde 100644 --- a/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/DropshotsTest.kt +++ b/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/DropshotsTest.kt @@ -27,14 +27,12 @@ class DropshotsTest { private val fakeValidator = FakeResultValidator() private var filenameFunc: (String) -> String = { it } - private val isRecordingScreenshots = isRecordingScreenshots() private lateinit var testStorage: FileTestStorage @get:Rule val testName = TestName() @get:Rule val dropshots = Dropshots( filenameFunc = filenameFunc, - testRunConfig = TestRunConfig(isRecording = isRecordingScreenshots, deviceName = "test"), resultValidator = fakeValidator, imageComparator = SimpleImageComparator( maxDistance = 0.004f, @@ -85,22 +83,51 @@ class DropshotsTest { } @Test - fun testWritesReferenceImageForMissingImages() { + fun testWritesReferenceImageForMissingImagesWhenRecording() { val dropshots = Dropshots( testStorage = testStorage, filenameFunc = filenameFunc, - testRunConfig = TestRunConfig(isRecording =true, deviceName = "test"), + testRunConfig = TestRunConfig(isRecording = true, deviceName = "test"), resultValidator = { false }, imageComparator = SimpleImageComparator(), ) activityScenarioRule.scenario.onActivity { - dropshots.assertSnapshot(it, "MatchesViewScreenshotBad") + dropshots.assertSnapshot(it, "not-an-image") } - with(File(testStorage.outputDir, "reference")) { + with(File(testStorage.outputDir, "dropshots/reference")) { assertTrue(exists()) - assertArrayEquals(arrayOf(File(this, "MatchesViewScreenshotBad.png")), listFiles()) + assertArrayEquals(arrayOf("not-an-image.png"), list()) + } + } + + @Test + fun testWritesReferenceImageForMissingImagesWhenNotRecording() { + val dropshots = Dropshots( + testStorage = testStorage, + filenameFunc = filenameFunc, + testRunConfig = TestRunConfig(isRecording = false, deviceName = "test"), + resultValidator = { false }, + imageComparator = SimpleImageComparator(), + ) + + activityScenarioRule.scenario.onActivity { + var failed = false + try { + dropshots.assertSnapshot(it, "not-an-image") + failed = true + } catch (_: IllegalStateException) { + // expected + } + if (failed) { + fail("Expected snapshot assertion to fail but it passed.") + } + } + + with(File(testStorage.outputDir, "dropshots/reference")) { + assertTrue(exists()) + assertArrayEquals(arrayOf("not-an-image.png"), list()) } } @@ -109,7 +136,27 @@ class DropshotsTest { val dropshots = Dropshots( testStorage = testStorage, filenameFunc = filenameFunc, - testRunConfig = TestRunConfig(isRecording =false, deviceName = "test"), + testRunConfig = TestRunConfig(isRecording = true, deviceName = "test"), + resultValidator = { false }, + imageComparator = SimpleImageComparator(), + ) + + activityScenarioRule.scenario.onActivity { + dropshots.assertSnapshot(it, "MatchesViewScreenshot") + } + + with(File(testStorage.outputDir, "dropshots/diff")) { + assertTrue(exists()) + assertArrayEquals(arrayOf("MatchesViewScreenshot.png"), list()) + } + } + + @Test + fun testWritesDiffImageOnFailureWhenNotRecording() { + val dropshots = Dropshots( + testStorage = testStorage, + filenameFunc = filenameFunc, + testRunConfig = TestRunConfig(isRecording = false, deviceName = "test"), resultValidator = { false }, imageComparator = SimpleImageComparator(), ) @@ -127,9 +174,9 @@ class DropshotsTest { } } - with(File(testStorage.inputDir, "diff")) { + with(File(testStorage.outputDir, "dropshots/diff")) { assertTrue(exists()) - assertArrayEquals(arrayOf(File(this, "MatchesViewScreenshot.png")), listFiles()) + assertArrayEquals(arrayOf("MatchesViewScreenshot.png"), list()) } } @@ -139,7 +186,7 @@ class DropshotsTest { resultValidator = CountValidator(0), testStorage = testStorage, filenameFunc = filenameFunc, - testRunConfig = TestRunConfig(isRecording =false, deviceName = "test"), + testRunConfig = TestRunConfig(isRecording = false, deviceName = "test"), imageComparator = SimpleImageComparator(), ) @@ -165,7 +212,7 @@ class DropshotsTest { resultValidator = FakeResultValidator { true }, testStorage = testStorage, filenameFunc = filenameFunc, - testRunConfig = TestRunConfig(isRecording =false, deviceName = "test"), + testRunConfig = TestRunConfig(isRecording = false, deviceName = "test"), imageComparator = SimpleImageComparator(), ) @@ -189,7 +236,7 @@ class DropshotsTest { resultValidator = FakeResultValidator { false }, testStorage = testStorage, filenameFunc = filenameFunc, - testRunConfig = TestRunConfig(isRecording =false, deviceName = "test"), + testRunConfig = TestRunConfig(isRecording = false, deviceName = "test"), imageComparator = SimpleImageComparator(), ) @@ -220,7 +267,7 @@ class DropshotsTest { resultValidator = CountValidator(0), testStorage = testStorage, filenameFunc = filenameFunc, - testRunConfig = TestRunConfig(isRecording =false, deviceName = "test"), + testRunConfig = TestRunConfig(isRecording = false, deviceName = "test"), imageComparator = SimpleImageComparator(), ) @@ -235,7 +282,7 @@ class DropshotsTest { dropshots.assertSnapshot( bitmap = image, name = "50x50", - filePath = "static" + filePath = "static", ) } catch (e: AssertionError) { caughtError = e diff --git a/dropshots/src/androidTest/assets/MatchesActivityScreenshot.png b/dropshots/src/androidTest/screenshots/connected/MatchesActivityScreenshot.png similarity index 100% rename from dropshots/src/androidTest/assets/MatchesActivityScreenshot.png rename to dropshots/src/androidTest/screenshots/connected/MatchesActivityScreenshot.png diff --git a/dropshots/src/androidTest/assets/MatchesFullScreenshot.png b/dropshots/src/androidTest/screenshots/connected/MatchesFullScreenshot.png similarity index 100% rename from dropshots/src/androidTest/assets/MatchesFullScreenshot.png rename to dropshots/src/androidTest/screenshots/connected/MatchesFullScreenshot.png diff --git a/dropshots/src/androidTest/screenshots/connected/MatchesViewScreenshot.png b/dropshots/src/androidTest/screenshots/connected/MatchesViewScreenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..2dda90b2cb8468a1beebb7b07df8d63d5fb4afb6 GIT binary patch literal 13094 zcmeAS@N?(olHy`uVBq!ia0y~yV6k9eU_Zja1{B%9VtoOSVk{1FcVbv~PUa<$!&2^aj$iwXT{MY-N;VyECiOZPwiqD;UlM$$g<98fC zkYd=O3NntNl8GHiPEa4^jE2EzIvC9hqvgS9nK)W6j8=!EO@h&8(P-0fw0TU=E<$?Q zx33&P3mc^6WPp^zyaS*P%M;E~&S)5nri0PEFj^jrmWiYF!f16k+9Vil7L7IyhfMSM z!{+ScY79pl1dhCW_&fh+ObfHILg0bS|^j@_TGGB5}o0j&j~mz|^bj8^rd%^hGa7;Wy1h5;}c z4DNLB@8)%3kR#t!jIR0`UG+7(>T7h>*XXLR(N$ld8eudYjE2EzIv8F3HE0 h8E-g8wa_HQ#LX^?HKvuXnhgwO22WQ%mvv4FO#razX bitmap{width=${bitmap.width}, height=${bitmap.height}}", @@ -170,9 +170,9 @@ public class Dropshots @JvmOverloads constructor( // Assert if (!resultValidator(result)) { writeReferenceImage(filename, filePath, bitmap) + writeDiffImage(filename, filePath, bitmap, reference, mask) if (!testRunConfig.isRecording) { - writeDiffImage(filename, filePath, bitmap, reference, mask) throw AssertionError( "\"$name\" failed to match reference image. ${result.pixelDifferences} pixels differ " + "(${(result.pixelDifferences / result.pixelCount.toFloat()) * 100} %)" @@ -186,7 +186,7 @@ public class Dropshots @JvmOverloads constructor( * file path of the file that was written. */ private fun writeReferenceImage(name: String, filePath: String?, screenshot: Bitmap) { - writeImage("reference".appendPath(filePath).appendPath(name), screenshot) + writeImage(path("reference", filePath, name), screenshot) } /** @@ -202,7 +202,7 @@ public class Dropshots @JvmOverloads constructor( mask: Mask? ) { val diffImage = generateDiffImage(referenceImage, screenshot, mask) - writeImage("diff".appendPath(filePath).appendPath(name), diffImage) + writeImage(path("diff", filePath, name), diffImage) } @Throws(IOException::class) diff --git a/gradle/build-logic/build.gradle.kts b/gradle/build-logic/build.gradle.kts new file mode 100644 index 0000000..28af1d8 --- /dev/null +++ b/gradle/build-logic/build.gradle.kts @@ -0,0 +1,18 @@ +import java.util.Properties + +plugins { + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlinx.serialization) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.ktlint) apply false + alias(libs.plugins.mavenPublish) apply false +} + +subprojects { + val props = Properties() + project.rootProject.file("../../gradle.properties").inputStream().use(props::load) + props.stringPropertyNames().forEach { k -> + project.ext[k] = props[k] + } +} diff --git a/gradle/build-logic/settings.gradle.kts b/gradle/build-logic/settings.gradle.kts new file mode 100644 index 0000000..4d43e81 --- /dev/null +++ b/gradle/build-logic/settings.gradle.kts @@ -0,0 +1,42 @@ +rootProject.name = "build-logic" + +pluginManagement { + repositories { + google { + @Suppress("UnstableApiUsage") + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("../libs.versions.toml")) + } + } + + @Suppress("UnstableApiUsage") + repositories { + google { + @Suppress("UnstableApiUsage") + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + } +} + +include(":dropshots-gradle-plugin") +project(":dropshots-gradle-plugin").projectDir = File("../../dropshots-gradle-plugin") +include(":model") +project(":model").projectDir = File("../../model") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 800885e..f130f36 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ agp = "8.7.2" androidTools = "31.7.2" kotlin = "2.0.21" jvmTarget = "11" +mavenPublish= "0.30.0" [libraries] android = { module = "com.android.tools.build:gradle", version.ref = "agp" } @@ -26,6 +27,9 @@ kotlin-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version. kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.7.3" } +# build logic +mavenPublishPlugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "mavenPublish" } + [plugins] android-library = { id = "com.android.library", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" } @@ -34,6 +38,6 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version = "12.1.1" } -mavenPublish = { id = "com.vanniktech.maven.publish.base", version = "0.30.0" } +mavenPublish = { id = "com.vanniktech.maven.publish.base", version.ref = "mavenPublish" } binaryCompatibilityValidator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version = "0.16.3" } diff --git a/model/build.gradle.kts b/model/build.gradle.kts index d44cb73..f172bfe 100644 --- a/model/build.gradle.kts +++ b/model/build.gradle.kts @@ -2,10 +2,13 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlinx.serialization) alias(libs.plugins.dokka) - alias(libs.plugins.mavenPublish) alias(libs.plugins.binaryCompatibilityValidator) } +if (rootProject.name == "dropshots-root") { + apply(plugin = libs.plugins.mavenPublish.get().pluginId) +} + kotlin { explicitApi() } diff --git a/settings.gradle.kts b/settings.gradle.kts index 7f70160..5b06960 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,4 +1,6 @@ pluginManagement { + includeBuild("gradle/build-logic") + repositories { google { @Suppress("UnstableApiUsage") From b73a80df62131063c511254f2997018746b732ee Mon Sep 17 00:00:00 2001 From: Ryan Harter Date: Thu, 5 Dec 2024 14:49:24 -0600 Subject: [PATCH 07/21] Updates github action and documentation. --- .github/workflows/publish.yml | 15 ++++++++++----- README.md | 8 ++++---- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bab4f83..0fb287a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -171,14 +171,19 @@ jobs: run: | echo "::error::Screenshot tests failed, please create a PR in your fork first." && exit 1 - - name: Pull screenshots + - name: Record screenshots id: screenshotspull continue-on-error: true + uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d # v2.33.0 if: steps.screenshotsverify.outcome == 'failure' && github.event_name == 'pull_request' - run: | - echo "Pulling $(ls -1q dropshots/build/reports/androidTests/dropshots/reference/*.png | wc -l) files..." - ls -1q dropshots/build/reports/androidTests/dropshots/reference/*.png - cp dropshots/build/reports/androidTests/dropshots/reference/*.png dropshots/src/androidTest/assets/ + with: + api-level: 31 + arch: x86_64 + profile: pixel_5 + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: ./gradlew updateDropshotsScreenshots --stacktrace # Since commits from actions don't trigger new actions, we validate the new screenshots here # before we commit them to ensure there isn't flakiness in the tests. diff --git a/README.md b/README.md index a93a37b..150313d 100644 --- a/README.md +++ b/README.md @@ -123,16 +123,16 @@ class MyTest { With this test in place, any time the `connectedAndroidTest` task is run the screenshot of the Activity or View will be validated against the reference images stored in the repository. If any screenshots fail to match the reference images (within configurable thresholds), then an image will -be written to the test report folder that shows the reference image, the actual image, and the diff -of the two. By default, the test report folder is -`${project.buildDir}/outputs/androidTest-results/connected`. +be written to the additional test output folder that shows the reference image, the actual image, +and the diff of the two. By default, the test report folder is +`${project.buildDir}/outputs/connected_android_test_additional_output/debugAndroidTest/$device/connected`. The first time you create a screenshot test, however, there won't be any reference images, so you'll have to create them... ### Updating reference images -Updating reference screenshots is as simple as running the `record[variant]Screenshots` Gradle task. +Updating reference screenshots is as simple as running the `updateDropshotsScreenshots` Gradle task. This makes it easy to update screenshots in a single step, without requiring you to interact with the emulator or use esoteric `adb` commands. From 35189062033d84c0fa2f10fcb83942dab12bab97 Mon Sep 17 00:00:00 2001 From: Ryan Harter Date: Fri, 6 Dec 2024 08:46:54 -0600 Subject: [PATCH 08/21] Updates test collection scripts to handle empty find results. --- .github/workflows/publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0fb287a..a7a7151 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -89,7 +89,7 @@ jobs: - name: (Fail-only) Bundle test reports if: failure() - run: find . -type d -name 'reports' | zip -@ -r unit-test-build-reports.zip + run: find . -type d -name 'reports' -exec zip -r unit-test-build-reports.zip {} + - name: (Fail-only) Upload the build report if: failure() @@ -209,7 +209,7 @@ jobs: - name: Bundle test reports if: always() - run: find . -type d '(' -name 'reports' -o -name 'androidTest-results' ')' | zip -@ -r instrumentation-test-build-reports.zip + run: find . -type d '(' -name 'reports' -o -name 'androidTest-results' ')' -exec zip -r instrumentation-test-build-reports.zip {} + - name: Upload the build report if: always() From ff8826cd82746da21c993632686c86d44f163998 Mon Sep 17 00:00:00 2001 From: Ryan Harter Date: Fri, 6 Dec 2024 09:32:47 -0600 Subject: [PATCH 09/21] Removes erroneous project options service call used for experimentation. --- .../src/main/kotlin/com/dropbox/dropshots/DropshotsPlugin.kt | 5 ----- 1 file changed, 5 deletions(-) diff --git a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/DropshotsPlugin.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/DropshotsPlugin.kt index 2671953..b8ab9ad 100644 --- a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/DropshotsPlugin.kt +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/DropshotsPlugin.kt @@ -11,8 +11,6 @@ import com.android.build.gradle.internal.tasks.DeviceProviderInstrumentTestTask import com.android.build.gradle.internal.tasks.ManagedDeviceInstrumentationTestTask import com.android.build.gradle.internal.tasks.ManagedDeviceTestTask import com.android.build.gradle.internal.tasks.factory.dependsOn -import com.android.build.gradle.options.BooleanOption -import com.android.build.gradle.options.ProjectOptionService import com.android.builder.core.ComponentType import com.dropbox.dropshots.tasks.GenerateReferenceScreenshotsTask import com.dropbox.dropshots.tasks.PullScreenshotsTask @@ -65,9 +63,6 @@ public class DropshotsPlugin : Plugin { testedExtension.deviceProviders.forEach { deviceProvider -> addTasksForDeviceProvider(variant, deviceTestComponent, deviceProvider.name, adbProvider) } - - val optionService = ProjectOptionService.RegistrationAction(this).execute().get() - optionService.projectOptions.get(BooleanOption.ENABLE_ADDITIONAL_ANDROID_TEST_OUTPUT) } } } From 3f1d4f5fd7f2767a76a9f8a9936b5dfa865e39db Mon Sep 17 00:00:00 2001 From: Ryan Harter Date: Fri, 6 Dec 2024 09:52:57 -0600 Subject: [PATCH 10/21] Updates changelog. --- CHANGELOG.md | 13 +++++++++---- .../connected/MatchesActivityScreenshot.png | Bin 24653 -> 24162 bytes .../connected/MatchesFullScreenshot.png | Bin 29293 -> 31019 bytes .../connected/MatchesViewScreenshot.png | Bin 13094 -> 12750 bytes 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91f3250..603cd04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,10 @@ [Unreleased]: https://github.com/dropbox/dropshots/compare/0.4.2...HEAD New: -- Nothing yet! +- Adds support for `additional_test_output` directory. Changed: +- Updates runtime to always record reference **and** diff images. - Updates Gradle plugin to deprecate the `dropshots.record` property. Fixed: @@ -15,12 +16,16 @@ Fixed: ### Updated Gradle tasks This version of Dropshots updates the Gradle plugin to deprecate the use of the -`dropshots.record` property in favor of a new `recordDebugAndroidTestScreenshots` task +`dropshots.record` property in favor of a new `updateDropshotsScreenshots` task to update the local reference images. +By adding support for the `additional_test_output` directory, the Dropshots runtime +now records images in the `build/outputs/connected_android_test_additional_output` +directory. + With this change the behavior of `Dropshots` has also changed, such that it will **always** -record reference images on the test device so that the local copies can be updated without -the need to recompile the app. +record reference images so that the local copies can be updated without the need to +recompile the app. ## [0.4.2] = 2024-05-21 [0.4.2]: https://github.com/dropbox/dropshots/releases/tags/0.4.2 diff --git a/dropshots/src/androidTest/screenshots/connected/MatchesActivityScreenshot.png b/dropshots/src/androidTest/screenshots/connected/MatchesActivityScreenshot.png index 34fa767ce5bea73981f2613ffabb54f752208e59..fa0160c59d4137126b89e3818d3884fb7ace6917 100644 GIT binary patch delta 118 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 SQ>G&E0*dnIMxkRUmj06{@Tf@A^7StLgVM6%>0 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 diff --git a/dropshots/src/androidTest/screenshots/connected/MatchesViewScreenshot.png b/dropshots/src/androidTest/screenshots/connected/MatchesViewScreenshot.png index 2dda90b2cb8468a1beebb7b07df8d63d5fb4afb6..9713d79fdcfbc861842945c9544db8552dd62195 100644 GIT binary patch delta 105 zcmZ3Mb}o5>I9DGB8v_G_V0K&8M#U~3p7ONx_KagC>u~L4=C7_O+&3+1EteZ2`__#NIU}2nGX6P)+`np(5G@d6!A7p~3tDnm{ Hr-UW|b^{`w delta 141 zcmX??yew^kIM)#lHUDH Nc)I$ztaD0e0sv@1Egb*= From 3e5aeaaec00296217c4f3389535d6a73c9e7a7cc Mon Sep 17 00:00:00 2001 From: Ryan Harter Date: Fri, 6 Dec 2024 11:25:50 -0600 Subject: [PATCH 11/21] Removes unused import : --- build.gradle.kts | 1 - 1 file changed, 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 1a6e7c7..4630972 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,5 @@ import com.vanniktech.maven.publish.MavenPublishBaseExtension import com.vanniktech.maven.publish.SonatypeHost -import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.KotlinVersion import org.jetbrains.kotlin.gradle.plugin.KotlinBasePlugin import org.jetbrains.kotlin.gradle.tasks.KotlinCompile From 8c7e17bd112d29c88d52643194a641a59bdf460f Mon Sep 17 00:00:00 2001 From: Ryan Harter Date: Fri, 6 Dec 2024 12:10:51 -0600 Subject: [PATCH 12/21] updates API dump. --- .../api/dropshots-gradle-plugin.api | 58 ++++++++++++++----- dropshots/api/dropshots.api | 11 ++-- model/api/model.api | 7 ++- 3 files changed, 54 insertions(+), 22 deletions(-) diff --git a/dropshots-gradle-plugin/api/dropshots-gradle-plugin.api b/dropshots-gradle-plugin/api/dropshots-gradle-plugin.api index e7cdbd4..28586bd 100644 --- a/dropshots-gradle-plugin/api/dropshots-gradle-plugin.api +++ b/dropshots-gradle-plugin/api/dropshots-gradle-plugin.api @@ -1,4 +1,25 @@ -public abstract class com/dropbox/dropshots/ClearScreenshotsTask : org/gradle/api/DefaultTask { +public abstract class com/dropbox/dropshots/DropshotsExtension { + public fun (Lorg/gradle/api/model/ObjectFactory;)V + public final fun getApplyDependency ()Lorg/gradle/api/provider/Property; +} + +public final class com/dropbox/dropshots/DropshotsPlugin : org/gradle/api/Plugin { + public fun ()V + public synthetic fun apply (Ljava/lang/Object;)V + public fun apply (Lorg/gradle/api/Project;)V +} + +public final class com/dropbox/dropshots/VersionKt { + public static final field VERSION Ljava/lang/String; +} + +public abstract class com/dropbox/dropshots/device/DeviceProviderFactory { + public fun ()V + public final fun getDeviceProvider (Lorg/gradle/api/provider/Provider;Lcom/android/utils/ILogger;Ljava/lang/String;)Lcom/android/builder/testing/api/DeviceProvider; + public static synthetic fun getDeviceProvider$default (Lcom/dropbox/dropshots/device/DeviceProviderFactory;Lorg/gradle/api/provider/Provider;Lcom/android/utils/ILogger;Ljava/lang/String;ILjava/lang/Object;)Lcom/android/builder/testing/api/DeviceProvider; +} + +public abstract class com/dropbox/dropshots/tasks/ClearScreenshotsTask : org/gradle/api/DefaultTask { public fun ()V public final fun clearScreenshots ()V public abstract fun getAdbExecutable ()Lorg/gradle/api/provider/Property; @@ -6,32 +27,37 @@ public abstract class com/dropbox/dropshots/ClearScreenshotsTask : org/gradle/ap public abstract fun getScreenshotDir ()Lorg/gradle/api/provider/Property; } -public final class com/dropbox/dropshots/DropshotsPlugin : org/gradle/api/Plugin { +public abstract class com/dropbox/dropshots/tasks/GenerateReferenceScreenshotsTask : org/gradle/api/DefaultTask { public fun ()V - public synthetic fun apply (Ljava/lang/Object;)V - public fun apply (Lorg/gradle/api/Project;)V + public abstract fun getOutputDir ()Lorg/gradle/api/file/DirectoryProperty; + public abstract fun getReferenceImageDir ()Lorg/gradle/api/file/DirectoryProperty; + public final fun performAction ()V } -public abstract class com/dropbox/dropshots/PullScreenshotsTask : org/gradle/api/DefaultTask { +public abstract class com/dropbox/dropshots/tasks/PullScreenshotsTask : org/gradle/api/DefaultTask { public fun ()V public abstract fun getAdbExecutable ()Lorg/gradle/api/provider/Property; - protected abstract fun getExecOperations ()Lorg/gradle/process/ExecOperations; public abstract fun getOutputDirectory ()Lorg/gradle/api/file/DirectoryProperty; - public abstract fun getScreenshotDir ()Lorg/gradle/api/provider/Property; + public abstract fun getRemoteDir ()Lorg/gradle/api/provider/Property; public final fun pullScreenshots ()V } -public abstract class com/dropbox/dropshots/PushFileTask : org/gradle/api/DefaultTask { +public abstract class com/dropbox/dropshots/tasks/UpdateScreenshotsTask : org/gradle/api/DefaultTask { public fun ()V - public abstract fun getAdbExecutable ()Lorg/gradle/api/provider/Property; - protected abstract fun getExecOperations ()Lorg/gradle/process/ExecOperations; - public abstract fun getFileContents ()Lorg/gradle/api/provider/Property; - public abstract fun getRemotePath ()Lorg/gradle/api/provider/Property; - protected abstract fun getTempFileProvider ()Lorg/gradle/api/internal/file/temp/TemporaryFileProvider; - public final fun push ()V + public abstract fun getDeviceProviderName ()Lorg/gradle/api/provider/Property; + public abstract fun getOutputBasePath ()Lorg/gradle/api/provider/Property; + public abstract fun getOutputDir ()Lorg/gradle/api/file/DirectoryProperty; + public abstract fun getReferenceImageDir ()Lorg/gradle/api/file/DirectoryProperty; + public final fun performAction ()V } -public final class com/dropbox/dropshots/VersionKt { - public static final field VERSION Ljava/lang/String; +public abstract class com/dropbox/dropshots/tasks/WriteConfigFileTask : org/gradle/api/DefaultTask { + public fun ()V + public abstract fun getAdbExecutable ()Lorg/gradle/api/provider/Property; + public abstract fun getDeviceProviderFactory ()Lcom/dropbox/dropshots/device/DeviceProviderFactory; + public abstract fun getDeviceProviderName ()Lorg/gradle/api/provider/Property; + public abstract fun getRecordingScreenshots ()Lorg/gradle/api/provider/Property; + public abstract fun getRemoteDir ()Lorg/gradle/api/provider/Property; + public final fun performAction ()V } diff --git a/dropshots/api/dropshots.api b/dropshots/api/dropshots.api index e82f3fb..f9ea38f 100644 --- a/dropshots/api/dropshots.api +++ b/dropshots/api/dropshots.api @@ -1,10 +1,11 @@ public final class com/dropbox/dropshots/Dropshots : org/junit/rules/TestRule { public fun ()V - public fun (Lkotlin/jvm/functions/Function1;)V - public fun (Lkotlin/jvm/functions/Function1;Z)V - public fun (Lkotlin/jvm/functions/Function1;ZLcom/dropbox/differ/ImageComparator;)V - public fun (Lkotlin/jvm/functions/Function1;ZLcom/dropbox/differ/ImageComparator;Lkotlin/jvm/functions/Function1;)V - public synthetic fun (Lkotlin/jvm/functions/Function1;ZLcom/dropbox/differ/ImageComparator;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Landroidx/test/platform/io/PlatformTestStorage;)V + public fun (Landroidx/test/platform/io/PlatformTestStorage;Lcom/dropbox/dropshots/model/TestRunConfig;)V + public fun (Landroidx/test/platform/io/PlatformTestStorage;Lcom/dropbox/dropshots/model/TestRunConfig;Lkotlin/jvm/functions/Function1;)V + public fun (Landroidx/test/platform/io/PlatformTestStorage;Lcom/dropbox/dropshots/model/TestRunConfig;Lkotlin/jvm/functions/Function1;Lcom/dropbox/differ/ImageComparator;)V + public fun (Landroidx/test/platform/io/PlatformTestStorage;Lcom/dropbox/dropshots/model/TestRunConfig;Lkotlin/jvm/functions/Function1;Lcom/dropbox/differ/ImageComparator;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Landroidx/test/platform/io/PlatformTestStorage;Lcom/dropbox/dropshots/model/TestRunConfig;Lkotlin/jvm/functions/Function1;Lcom/dropbox/differ/ImageComparator;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun apply (Lorg/junit/runners/model/Statement;Lorg/junit/runner/Description;)Lorg/junit/runners/model/Statement; public final fun assertSnapshot (Landroid/app/Activity;Ljava/lang/String;Ljava/lang/String;)V public final fun assertSnapshot (Landroid/graphics/Bitmap;Ljava/lang/String;Ljava/lang/String;)V diff --git a/model/api/model.api b/model/api/model.api index cf26350..e5b3d20 100644 --- a/model/api/model.api +++ b/model/api/model.api @@ -1,3 +1,7 @@ +public final class com/dropbox/dropshots/ConstantsKt { + public static final field configFileName Ljava/lang/String; +} + public final class com/dropbox/dropshots/model/TestRunConfig { public static final field Companion Lcom/dropbox/dropshots/model/TestRunConfig$Companion; public fun (ZLjava/lang/String;)V @@ -10,7 +14,7 @@ public final class com/dropbox/dropshots/model/TestRunConfig { public fun hashCode ()I public final fun isRecording ()Z public fun toString ()Ljava/lang/String; - public final fun write (Ljava/io/OutputStream;)V + public final fun write ()Ljava/lang/String; } public final class com/dropbox/dropshots/model/TestRunConfig$$serializer : kotlinx/serialization/internal/GeneratedSerializer { @@ -26,6 +30,7 @@ public final class com/dropbox/dropshots/model/TestRunConfig$$serializer : kotli public final class com/dropbox/dropshots/model/TestRunConfig$Companion { public final fun read (Ljava/io/InputStream;)Lcom/dropbox/dropshots/model/TestRunConfig; + public final fun read (Ljava/lang/String;)Lcom/dropbox/dropshots/model/TestRunConfig; public final fun serializer ()Lkotlinx/serialization/KSerializer; } From c12970561f2eba44b1e5d5748e3ab57a963b0775 Mon Sep 17 00:00:00 2001 From: Ryan Harter Date: Mon, 9 Dec 2024 16:50:59 -0600 Subject: [PATCH 13/21] Adds alternative build directory. --- dropshots-gradle-plugin/build.gradle.kts | 10 +++++++++- model/build.gradle.kts | 9 +++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/dropshots-gradle-plugin/build.gradle.kts b/dropshots-gradle-plugin/build.gradle.kts index faf4fd5..5848768 100644 --- a/dropshots-gradle-plugin/build.gradle.kts +++ b/dropshots-gradle-plugin/build.gradle.kts @@ -8,12 +8,20 @@ plugins { alias(libs.plugins.dokka) alias(libs.plugins.binaryCompatibilityValidator) } - +// This module is included in two projects: +// - In the root project where it's released as one of our artifacts +// - In build-logic project where we can use it for the runtime and samples. +// +// We only want to publish when it's being built in the root project. if (rootProject.name == "dropshots-root") { apply(plugin = libs.plugins.mavenPublish.get().pluginId) extensions.configure { configure(GradlePlugin(Dokka("dokkaJavadoc"))) } +} else { + // Move the build directory when included in build-support so as to not poison the real build. + // If we don't the configuration cache is broken and all tasks are considered not up-to-date. + layout.buildDirectory = File(rootDir, "build/dropshots-gradle-plugin") } val generateVersionTask = tasks.register("generateVersion") { diff --git a/model/build.gradle.kts b/model/build.gradle.kts index f172bfe..df67e1c 100644 --- a/model/build.gradle.kts +++ b/model/build.gradle.kts @@ -5,8 +5,17 @@ plugins { alias(libs.plugins.binaryCompatibilityValidator) } +// This module is included in two projects: +// - In the root project where it's released as one of our artifacts +// - In build-logic project where we can use it for the runtime and samples. +// +// We only want to publish when it's being built in the root project. if (rootProject.name == "dropshots-root") { apply(plugin = libs.plugins.mavenPublish.get().pluginId) +} else { + // Move the build directory when included in build-support so as to not poison the real build. + // If we don't the configuration cache is broken and all tasks are considered not up-to-date. + layout.buildDirectory = File(rootDir, "build/model") } kotlin { From 899642925f96cd900db5e46bf9327c5f811a83a5 Mon Sep 17 00:00:00 2001 From: Ryan Harter Date: Tue, 10 Dec 2024 11:33:35 -0600 Subject: [PATCH 14/21] Updates test failure artifact contents --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a7a7151..ff5e469 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -209,7 +209,7 @@ jobs: - name: Bundle test reports if: always() - run: find . -type d '(' -name 'reports' -o -name 'androidTest-results' ')' -exec zip -r instrumentation-test-build-reports.zip {} + + run: find . -type d '(' -name 'reports' -o -name 'androidTest-results' -o -name 'connected_android_test_additional_output' ')' -exec zip -r instrumentation-test-build-reports.zip {} + - name: Upload the build report if: always() From 0add684abbe955f20350a4166a934ba10ee60ca0 Mon Sep 17 00:00:00 2001 From: Ryan Harter Date: Wed, 11 Dec 2024 11:49:27 -0600 Subject: [PATCH 15/21] Updates workflow to ensure new screenshot validation succeeds. --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ff5e469..c06253c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -173,7 +173,7 @@ jobs: - name: Record screenshots id: screenshotspull - continue-on-error: true + continue-on-error: false uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d # v2.33.0 if: steps.screenshotsverify.outcome == 'failure' && github.event_name == 'pull_request' with: From 9b9c6a865bb9c6dee8317f5dc1a0c3bef526126d Mon Sep 17 00:00:00 2001 From: Ryan Harter Date: Wed, 11 Dec 2024 12:10:10 -0600 Subject: [PATCH 16/21] Restores the emulator setup gradle tasks for runtime tests. --- dropshots/build.gradle.kts | 49 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/dropshots/build.gradle.kts b/dropshots/build.gradle.kts index 04479cb..3b71a64 100644 --- a/dropshots/build.gradle.kts +++ b/dropshots/build.gradle.kts @@ -1,4 +1,6 @@ +import com.android.build.gradle.internal.tasks.factory.dependsOn import com.vanniktech.maven.publish.AndroidSingleVariantLibrary +import java.util.Locale plugins { alias(libs.plugins.android.library) @@ -60,3 +62,50 @@ mavenPublishing { publishJavadocJar = true, )) } + +val adbExecutablePath = provider { android.adbExecutable.path } +android.testVariants.all { + val connectedAndroidTest = connectedInstrumentTestProvider + val variantSlug = name.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } + + val setupEmulatorTask = tasks.register("setup${variantSlug}ScreenshotEmulator") { + description = "Configures the test device for screenshots." + group = "verification" + doLast { + val adb = adbExecutablePath.get() + fun adbCommand(cmd: String): ExecResult { + return project.exec { + executable = adb + args = cmd.split(" ") + } + } + + adbCommand("root") + adbCommand("wait-for-device") + adbCommand("shell settings put global sysui_demo_allowed 1") + adbCommand("shell am broadcast -a com.android.systemui.demo -e command enter") + .assertNormalExitValue() + adbCommand("shell am broadcast -a com.android.systemui.demo -e command clock -e hhmm 1234") + adbCommand("shell am broadcast -a com.android.systemui.demo -e command battery -e plugged false") + adbCommand("shell am broadcast -a com.android.systemui.demo -e command battery -e level 100") + adbCommand("shell am broadcast -a com.android.systemui.demo -e command network -e wifi show -e level 4") + adbCommand("shell am broadcast -a com.android.systemui.demo -e command network -e mobile show -e datatype none -e level 4") + adbCommand("shell am broadcast -a com.android.systemui.demo -e command notifications -e visible false") + } + } + val restoreEmulatorTask = tasks.register("restore${variantSlug}ScreenshotEmulator") { + description = "Restores the test device from screenshot mode." + group = "verification" + doLast { + project.exec { + executable = adbExecutablePath.get() + args = "shell am broadcast -a com.android.systemui.demo -e command exit".split(" ") + } + } + } + + connectedAndroidTest.configure { + dependsOn(setupEmulatorTask) + finalizedBy(restoreEmulatorTask) + } +} From 653da2cdb705d1c4ddb0cc58fc0ed1c0972724d8 Mon Sep 17 00:00:00 2001 From: Ryan Harter Date: Wed, 11 Dec 2024 12:56:55 -0600 Subject: [PATCH 17/21] Adds some logging to test run config file to validate github actions behavior. --- .../kotlin/com/dropbox/dropshots/tasks/WriteConfigFileTask.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/WriteConfigFileTask.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/WriteConfigFileTask.kt index 20fa51b..52c0b6c 100644 --- a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/WriteConfigFileTask.kt +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/WriteConfigFileTask.kt @@ -65,7 +65,7 @@ public abstract class WriteConfigFileTask : DefaultTask() { executeShellCommand("mkdir -p $remotePath", loggingReceiver) val configFile = "$remotePath/$configFileName" - logger.warn("DeviceConnector '$deviceName': writing config file to $configFile") + logger.warn("DeviceConnector '$deviceName': writing config TestRunConfig(isRecording=${config.isRecording}, deviceName=${config.deviceName}) to $configFile") executeShellCommand( "echo '${config.write()}' > $configFile", loggingReceiver, From dbd3f924148dad550a54c18bf84100e67169110e Mon Sep 17 00:00:00 2001 From: rharter Date: Wed, 11 Dec 2024 19:06:32 +0000 Subject: [PATCH 18/21] =?UTF-8?q?=F0=9F=A4=96=20Updates=20screenshots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../connected/MatchesActivityScreenshot.png | Bin 24162 -> 24653 bytes .../connected/MatchesFullScreenshot.png | Bin 31019 -> 29293 bytes .../connected/MatchesViewScreenshot.png | Bin 12750 -> 13094 bytes 3 files changed, 0 insertions(+), 0 deletions(-) diff --git a/dropshots/src/androidTest/screenshots/connected/MatchesActivityScreenshot.png b/dropshots/src/androidTest/screenshots/connected/MatchesActivityScreenshot.png index fa0160c59d4137126b89e3818d3884fb7ace6917..34fa767ce5bea73981f2613ffabb54f752208e59 100644 GIT binary patch delta 132 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 literal 31019 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 SQ>G&E0*dnIDH Nc)I$ztaD0e0sv@1Egb*= delta 105 zcmZ3Mb}o5>I9DGB8v_G_V0K&8M#U~3p7ONx_KagC>u~L4=C7_O+&3+1EteZ2`__#NIU}2nGX6P)+`np(5G@d6!A7p~3tDnm{ Hr-UW|b^{`w From bd68f675ccc657ae34525a1ddc802bd95c7282e2 Mon Sep 17 00:00:00 2001 From: Ryan Harter Date: Wed, 11 Dec 2024 14:00:56 -0600 Subject: [PATCH 19/21] Moves test config writer log message to debug level. --- .../kotlin/com/dropbox/dropshots/tasks/WriteConfigFileTask.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/WriteConfigFileTask.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/WriteConfigFileTask.kt index 52c0b6c..312fdcc 100644 --- a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/WriteConfigFileTask.kt +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/WriteConfigFileTask.kt @@ -65,7 +65,7 @@ public abstract class WriteConfigFileTask : DefaultTask() { executeShellCommand("mkdir -p $remotePath", loggingReceiver) val configFile = "$remotePath/$configFileName" - logger.warn("DeviceConnector '$deviceName': writing config TestRunConfig(isRecording=${config.isRecording}, deviceName=${config.deviceName}) to $configFile") + logger.debug("DeviceConnector '$deviceName': writing config TestRunConfig(isRecording=${config.isRecording}, deviceName=${config.deviceName}) to $configFile") executeShellCommand( "echo '${config.write()}' > $configFile", loggingReceiver, From 6362aac6c8680dba734523332f122e87ed539cf3 Mon Sep 17 00:00:00 2001 From: Ryan Harter Date: Wed, 11 Dec 2024 14:39:18 -0600 Subject: [PATCH 20/21] Updates write config task to warn level. --- .../kotlin/com/dropbox/dropshots/tasks/WriteConfigFileTask.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/WriteConfigFileTask.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/WriteConfigFileTask.kt index 312fdcc..52c0b6c 100644 --- a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/WriteConfigFileTask.kt +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/WriteConfigFileTask.kt @@ -65,7 +65,7 @@ public abstract class WriteConfigFileTask : DefaultTask() { executeShellCommand("mkdir -p $remotePath", loggingReceiver) val configFile = "$remotePath/$configFileName" - logger.debug("DeviceConnector '$deviceName': writing config TestRunConfig(isRecording=${config.isRecording}, deviceName=${config.deviceName}) to $configFile") + logger.warn("DeviceConnector '$deviceName': writing config TestRunConfig(isRecording=${config.isRecording}, deviceName=${config.deviceName}) to $configFile") executeShellCommand( "echo '${config.write()}' > $configFile", loggingReceiver, From 97a85b826cec5d65a4e8b90268eea8ebf211f11c Mon Sep 17 00:00:00 2001 From: Ryan Harter Date: Wed, 11 Dec 2024 16:20:19 -0600 Subject: [PATCH 21/21] Adds directory checks and logging for update task. --- .../dropshots/tasks/UpdateScreenshotsTask.kt | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/UpdateScreenshotsTask.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/UpdateScreenshotsTask.kt index a902d14..df3d3c3 100644 --- a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/UpdateScreenshotsTask.kt +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/UpdateScreenshotsTask.kt @@ -1,8 +1,12 @@ package com.dropbox.dropshots.tasks +import java.io.FileNotFoundException import java.nio.file.Files import kotlin.io.path.ExperimentalPathApi import kotlin.io.path.copyToRecursively +import kotlin.io.path.exists +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.name import org.gradle.api.DefaultTask import org.gradle.api.file.DirectoryProperty import org.gradle.api.logging.Logging @@ -40,7 +44,21 @@ public abstract class UpdateScreenshotsTask : DefaultTask() { val deviceName = deviceProviderName.get() logger.lifecycle("Copying reference images for $deviceName") - val referenceImagePath = devicePath.resolve("dropshots/reference") + val dropshotsPath = devicePath.resolve("dropshots") + if (!dropshotsPath.exists()) { + logger.error("Missing directory 'dropshots' in test output directory '$devicePath'") + throw FileNotFoundException("Missing directory 'dropshots' in test output directory '$devicePath'") + } + + val referenceImagePath = dropshotsPath.resolve("reference") + if (!referenceImagePath.exists()) { + logger.error("Missing reference image directory in test output directory '$dropshotsPath'") + throw FileNotFoundException( + "Missing directory 'dropshots' in test output directory '$dropshotsPath'. " + + "Directories: [${dropshotsPath.listDirectoryEntries().joinToString(", ") { it.name } }]" + ) + } + val outputPath = to.resolve(deviceName) Files.createDirectories(outputPath) referenceImagePath.copyToRecursively(