From fd3aa1e4a3468267d377b9d2663bf2ea82bbd8e2 Mon Sep 17 00:00:00 2001 From: Alex Decker Date: Fri, 13 Mar 2026 12:14:31 -0500 Subject: [PATCH 1/6] MF-8059 add AGP 9 support --- .../com/dropbox/dropshots/DropshotsPlugin.kt | 205 ++++++++++-------- .../dropbox/dropshots/DropshotsPluginTest.kt | 27 ++- dropshots/build.gradle.kts | 132 +---------- gradle/libs.versions.toml | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 5 files changed, 138 insertions(+), 230 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 ab9837c..ea2c8b2 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,130 +1,147 @@ package com.dropbox.dropshots -import com.android.build.gradle.AppExtension -import com.android.build.gradle.LibraryExtension -import com.android.build.gradle.TestedExtension -import com.android.build.gradle.api.ApkVariant -import com.android.build.gradle.internal.tasks.AndroidTestTask -import com.android.build.gradle.internal.tasks.factory.dependsOn -import java.util.Locale +import com.android.build.api.dsl.AndroidSourceSet +import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.dsl.LibraryExtension +import com.android.build.api.variant.AndroidComponentsExtension +import com.android.build.api.variant.HasAndroidTest +import com.android.build.api.variant.Variant +import com.android.build.api.variant.impl.capitalizeFirstChar +import kotlin.jvm.java +import org.gradle.api.NamedDomainObjectContainer import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.api.provider.Provider import org.gradle.api.tasks.Copy private const val recordScreenshotsArg = "dropshots.record" public class DropshotsPlugin : Plugin { override fun apply(project: Project) { - val dropshotsExtension = project.extensions.create("dropshots", DropshotsExtension::class.java) + val dropshotsExtension: DropshotsExtension = + project.extensions.create("dropshots", DropshotsExtension::class.java) project.pluginManager.withPlugin("com.android.application") { - val extension = project.extensions.findByType(AppExtension::class.java) - ?: throw Exception("Failed to find Android Application extension") - project.configureDropshots(dropshotsExtension, extension) + project.extensions.getByType(ApplicationExtension::class.java).apply { + sourceSets.addDropshotsAssetsDir(dropshotsExtension) + } + project.configureDropshots(dropshotsExtension) } project.pluginManager.withPlugin("com.android.library") { - val extension = project.extensions.findByType(LibraryExtension::class.java) - ?: throw Exception("Failed to find Android Library extension") - project.configureDropshots(dropshotsExtension, extension) + project.extensions.getByType(LibraryExtension::class.java).apply { + sourceSets.addDropshotsAssetsDir(dropshotsExtension) + } + project.configureDropshots(dropshotsExtension) + } + } + + private fun NamedDomainObjectContainer.addDropshotsAssetsDir( + dropshotsExtension: DropshotsExtension + ) { + named("androidTest") { + it.assets.directories += dropshotsExtension.referenceOutputDirectory.get() } } private fun Project.configureDropshots( - dropshotsExtension: DropshotsExtension, - extension: TestedExtension + dropshotsExtension: DropshotsExtension ) { - project.afterEvaluate { - it.dependencies.add( + val androidComponents = project.extensions.getByType(AndroidComponentsExtension::class.java) + val adbExecutablePath = androidComponents.sdkComponents.adb.map { it.asFile.path } + + androidComponents.onVariants { + if (it is HasAndroidTest) { + configureVariant(it, adbExecutablePath, dropshotsExtension) + } + } + + androidComponents.finalizeDsl { + 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") - - val referenceScreenshotDirectory = layout.projectDirectory.dir(dropshotsExtension.referenceOutputDirectory) - - androidTestSourceSet.assets { - srcDirs(referenceScreenshotDirectory) + private fun Project.configureVariant( + variant: V, + adbExecutablePath: Provider, + dropshotsExtension: DropshotsExtension + ) where V : HasAndroidTest, V : Variant { + val androidTest = variant.androidTest + if (androidTest == null) { + project.logger.warn( + "Variant ${variant.name} does not have an androidTest component. " + + "Dropshots tasks will not be created for this variant." + ) + return } - 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 - } - "/storage/emulated/0/Download/screenshots/$appId" - } + val androidTestVariantSlug = androidTest.name.capitalizeFirstChar() - val clearScreenshotsTask = tasks.register( - "clear${variantSlug}Screenshots", - ClearScreenshotsTask::class.java, - ) { - it.adbExecutable.set(adbExecutablePath) - it.screenshotDir.set(screenshotDir) - } + val deviceScreenshotDir = + androidTest.applicationId.map { "/storage/emulated/0/Download/screenshots/$it" } + val buildScreenshotDir = + layout.buildDirectory.dir("test-results/dropshots/$androidTestVariantSlug") + val testTaskName = "connected${variant.name.capitalizeFirstChar()}AndroidTest" - val pullScreenshotsTask = tasks.register( - "pull${variantSlug}Screenshots", - PullScreenshotsTask::class.java, - ) { - it.adbExecutable.set(adbExecutablePath) - it.screenshotDir.set(screenshotDir) - it.outputDirectory.set(testTaskProvider.flatMap { (it as AndroidTestTask).resultsDir }) - it.finalizedBy(clearScreenshotsTask) - } + val clearScreenshotsTask = tasks.register( + "clear${androidTestVariantSlug}Screenshots", + ClearScreenshotsTask::class.java, + ) { + it.adbExecutable.set(adbExecutablePath) + it.screenshotDir.set(deviceScreenshotDir) + } - val recordScreenshotsTask = tasks.register( - "record${variantSlug}Screenshots", - Copy::class.java, - ) { - it.group = "verification" - it.description = "Updates the local reference screenshots" - it.from( - testTaskProvider.flatMap { - (it as AndroidTestTask).resultsDir.map { base -> "$base/reference" } - } - ) - it.into(referenceScreenshotDirectory) - it.dependsOn(pullScreenshotsTask) - it.finalizedBy(clearScreenshotsTask) - } + val pullScreenshotsTask = tasks.register( + "pull${androidTestVariantSlug}Screenshots", + PullScreenshotsTask::class.java, + ) { + it.adbExecutable.set(adbExecutablePath) + it.screenshotDir.set(deviceScreenshotDir) + it.outputDirectory.set(buildScreenshotDir) + it.finalizedBy(clearScreenshotsTask) + } - val isRecordingScreenshots = project.objects.property(Boolean::class.java) - if (providers.gradleProperty(recordScreenshotsArg).isPresent) { - 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 recordScreenshotsTask = tasks.register( + "record${androidTestVariantSlug}Screenshots", + Copy::class.java, + ) { task -> + task.group = "verification" + task.description = "Updates the local reference screenshots" + task.from(pullScreenshotsTask.flatMap { it.outputDirectory.dir("reference") }) + task.into(dropshotsExtension.referenceOutputDirectory) + task.dependsOn(testTaskName) + task.finalizedBy(clearScreenshotsTask) + } - 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) + val isRecordingScreenshots = project.objects.property(Boolean::class.java) + if (providers.gradleProperty(recordScreenshotsArg).isPresent) { + 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) }) + } - testTaskProvider.configure { - it.finalizedBy(pullScreenshotsTask) - } + val writeMarkerFileTask = tasks.register( + "push${androidTestVariantSlug}ScreenshotMarkerFile", + PushFileTask::class.java, + ) { task -> + task.onlyIf { isRecordingScreenshots.get() } + task.adbExecutable.set(adbExecutablePath) + task.fileContents.set("\n") + task.remotePath.set(deviceScreenshotDir.map { "$it/.isRecordingScreenshots" }) + task.finalizedBy(clearScreenshotsTask) + } + + tasks.named { it == testTaskName }.configureEach { + it.finalizedBy(pullScreenshotsTask) + it.dependsOn(writeMarkerFileTask) } } } 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 bc952b8..97353cd 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 @@ -7,15 +7,29 @@ import org.gradle.testkit.runner.TaskOutcome import org.junit.Before import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized import org.junit.rules.TemporaryFolder -class DropshotsPluginTest { +@RunWith(Parameterized::class) +class DropshotsPluginTest(private val agpVersion: String) { @get:Rule val tmpFolder = TemporaryFolder() private lateinit var buildFile: File private lateinit var gradleRunner: GradleRunner + companion object { + @JvmStatic + @Parameterized.Parameters(name = "AGP {0}") + fun agpVersions(): Collection> { + return listOf( + arrayOf("8.7.2"), + arrayOf("9.1.0"), + ) + } + } + @Before fun setup() { val localMavenRepo = File("../build/localMaven").absolutePath @@ -59,7 +73,7 @@ class DropshotsPluginTest { // language=groovy """ plugins { - alias(libs.plugins.android.library) + id("com.android.library") version "$agpVersion" id("com.dropbox.dropshots") } @@ -97,7 +111,7 @@ class DropshotsPluginTest { """ plugins { id("com.dropbox.dropshots") - alias(libs.plugins.android.library) + id("com.android.library") version "$agpVersion" } android { @@ -121,8 +135,9 @@ class DropshotsPluginTest { @Test fun `executes marker file push only when record task is run`() { val result = gradleRunner - .withArguments("recordDebugAndroidTestScreenshots") + .withArguments("recordDebugAndroidTestScreenshots", "--stacktrace") .build() + println(result.output) with(result.task(":pushDebugAndroidTestScreenshotMarkerFile")) { assertThat(this).isNotNull() assertThat(this!!.outcome).isEqualTo(TaskOutcome.SUCCESS) @@ -148,7 +163,7 @@ class DropshotsPluginTest { """ plugins { id("com.dropbox.dropshots") - alias(libs.plugins.android.library) + id("com.android.library") version "$agpVersion" } dropshots { @@ -190,7 +205,7 @@ class DropshotsPluginTest { """ plugins { id("com.dropbox.dropshots") - alias(libs.plugins.android.library) + id("com.android.library") version "$agpVersion" } android { diff --git a/dropshots/build.gradle.kts b/dropshots/build.gradle.kts index 0e59338..7d58333 100644 --- a/dropshots/build.gradle.kts +++ b/dropshots/build.gradle.kts @@ -1,10 +1,8 @@ import com.vanniktech.maven.publish.AndroidSingleVariantLibrary -import java.io.ByteArrayOutputStream -import java.util.Locale +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) alias(libs.plugins.dokka) alias(libs.plugins.mavenPublish) alias(libs.plugins.binaryCompatibilityValidator) @@ -25,13 +23,13 @@ android { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } - kotlinOptions { - jvmTarget = "1.8" - } } kotlin { explicitApi() + compilerOptions { + jvmTarget = JvmTarget.JVM_1_8 + } } dependencies { @@ -62,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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0e2c2bd..011b759 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] kotlin = "2.0.21" -agp = "8.7.2" +agp = "9.1.0" [libraries] android = { module = "com.android.tools.build:gradle", version.ref = "agp" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 5c40527..e1f0f9a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From 81f17a4664ae96d81b47b826b8548c0ffd80fd24 Mon Sep 17 00:00:00 2001 From: Alex Decker Date: Fri, 13 Mar 2026 13:35:48 -0500 Subject: [PATCH 2/6] Fix validation issues --- .../main/kotlin/com/dropbox/dropshots/ClearScreenshotsTask.kt | 4 ++++ .../src/main/kotlin/com/dropbox/dropshots/DropshotsPlugin.kt | 2 +- .../main/kotlin/com/dropbox/dropshots/PullScreenshotsTask.kt | 4 ++++ .../src/main/kotlin/com/dropbox/dropshots/PushFileTask.kt | 4 ++++ 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/ClearScreenshotsTask.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/ClearScreenshotsTask.kt index 88f2e4e..dd4d21e 100644 --- a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/ClearScreenshotsTask.kt +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/ClearScreenshotsTask.kt @@ -7,7 +7,11 @@ import org.gradle.api.tasks.Destroys import org.gradle.api.tasks.Input import org.gradle.api.tasks.TaskAction import org.gradle.process.ExecOperations +import org.gradle.work.DisableCachingByDefault +@DisableCachingByDefault( + because = "The task interacts with the connected test device, so caching is not applicable." +) public abstract class ClearScreenshotsTask : DefaultTask() { @get:Input 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 ea2c8b2..e18d9e2 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 @@ -56,7 +56,7 @@ public class DropshotsPlugin : Plugin { } } - androidComponents.finalizeDsl { + afterEvaluate { dependencies.add( "androidTestImplementation", "com.dropbox.dropshots:dropshots:$VERSION" 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..10785e0 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 @@ -9,7 +9,11 @@ import org.gradle.api.tasks.Input import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.TaskAction import org.gradle.process.ExecOperations +import org.gradle.work.DisableCachingByDefault +@DisableCachingByDefault( + because = "The task interacts with the connected test device, so caching is not applicable." +) public abstract class PullScreenshotsTask : DefaultTask() { @get:Input 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 index 9751bb5..197abf5 100644 --- a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/PushFileTask.kt +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/PushFileTask.kt @@ -10,7 +10,11 @@ import org.gradle.api.provider.Property import org.gradle.api.tasks.Input import org.gradle.api.tasks.TaskAction import org.gradle.process.ExecOperations +import org.gradle.work.DisableCachingByDefault +@DisableCachingByDefault( + because = "The task interacts with the connected test device, so caching is not applicable." +) public abstract class PushFileTask : DefaultTask() { @get:Input From e5b132dc3d4e50d3ed044151056aa95aba145234 Mon Sep 17 00:00:00 2001 From: Alex Decker Date: Fri, 13 Mar 2026 16:43:48 -0500 Subject: [PATCH 3/6] Remove noisy logs --- .../main/kotlin/com/dropbox/dropshots/DropshotsPlugin.kt | 9 +-------- 1 file changed, 1 insertion(+), 8 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 e18d9e2..547dcd9 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 @@ -69,14 +69,7 @@ public class DropshotsPlugin : Plugin { adbExecutablePath: Provider, dropshotsExtension: DropshotsExtension ) where V : HasAndroidTest, V : Variant { - val androidTest = variant.androidTest - if (androidTest == null) { - project.logger.warn( - "Variant ${variant.name} does not have an androidTest component. " + - "Dropshots tasks will not be created for this variant." - ) - return - } + val androidTest = variant.androidTest ?: return val androidTestVariantSlug = androidTest.name.capitalizeFirstChar() From 9ea0ecd603099d8a856ed1bc189e31b2ab149898 Mon Sep 17 00:00:00 2001 From: Alex Decker Date: Fri, 20 Mar 2026 09:15:50 -0500 Subject: [PATCH 4/6] Move integration tests to a separate "tests" gradle project --- .github/workflows/publish.yml | 12 +-- dropshots-gradle-plugin/build.gradle.kts | 86 +++++++----------- dropshots-gradle-plugin/gradle | 1 + .../dropbox/dropshots/ClearScreenshotsTask.kt | 2 + .../dropbox/dropshots/DropshotsExtension.kt | 8 ++ .../com/dropbox/dropshots/DropshotsPlugin.kt | 30 +++--- .../dropbox/dropshots/PullScreenshotsTask.kt | 21 +++++ dropshots/build.gradle.kts | 3 +- .../java/com/dropbox/dropshots/Dropshots.kt | 2 +- gradle/libs.versions.toml | 2 +- settings.gradle.kts | 8 ++ tests/build.gradle.kts | 30 ++++++ tests/gradle | 1 + tests/gradlew | 1 + tests/settings.gradle.kts | 21 +++++ .../assets/MatchesActivityScreenshot.png | Bin .../assets/MatchesFullScreenshot.png | Bin .../assets/MatchesViewScreenshot.png | Bin .../src/androidTest/assets/static/50x50.png | Bin .../static/MatchesViewScreenshotBad.png | Bin .../static/MatchesViewScreenshotBadSize.png | Bin .../dropshots/CustomImageComparatorTest.kt | 16 ---- .../com/dropbox/dropshots/DropshotsTest.kt | 19 ++-- .../dropbox/dropshots/FakeResultValidator.kt | 0 .../dropshots/ScreenshotTestFragment.kt | 0 .../src/main}/AndroidManifest.xml | 0 .../com/dropbox/dropshots/TestActivity.kt | 2 +- 27 files changed, 160 insertions(+), 105 deletions(-) create mode 120000 dropshots-gradle-plugin/gradle create mode 100644 tests/build.gradle.kts create mode 120000 tests/gradle create mode 120000 tests/gradlew create mode 100644 tests/settings.gradle.kts rename {dropshots => tests}/src/androidTest/assets/MatchesActivityScreenshot.png (100%) rename {dropshots => tests}/src/androidTest/assets/MatchesFullScreenshot.png (100%) rename {dropshots => tests}/src/androidTest/assets/MatchesViewScreenshot.png (100%) rename {dropshots => tests}/src/androidTest/assets/static/50x50.png (100%) rename {dropshots => tests}/src/androidTest/assets/static/MatchesViewScreenshotBad.png (100%) rename {dropshots => tests}/src/androidTest/assets/static/MatchesViewScreenshotBadSize.png (100%) rename {dropshots => tests}/src/androidTest/kotlin/com/dropbox/dropshots/CustomImageComparatorTest.kt (79%) rename {dropshots => tests}/src/androidTest/kotlin/com/dropbox/dropshots/DropshotsTest.kt (90%) rename {dropshots => tests}/src/androidTest/kotlin/com/dropbox/dropshots/FakeResultValidator.kt (100%) rename {dropshots => tests}/src/androidTest/kotlin/com/dropbox/dropshots/ScreenshotTestFragment.kt (100%) rename {dropshots/src/debug => tests/src/main}/AndroidManifest.xml (100%) rename {dropshots/src/debug => tests/src/main}/kotlin/com/dropbox/dropshots/TestActivity.kt (91%) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index dadbd9c..abb3e28 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -153,7 +153,7 @@ jobs: - name: Run instrumentation tests id: screenshotsverify - continue-on-error: true + continue-on-error: false uses: reactivecircus/android-emulator-runner@1dcd0090116d15e7c562f8db72807de5e036a4ed # v2.34.0 with: api-level: 31 @@ -162,7 +162,7 @@ jobs: 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 connectedCheck --stacktrace + script: ./gradlew connectedCheck --stacktrace && tests/gradlew -p tests :connectedCheck --stacktrace - name: Prevent pushing new screenshots if this is a fork id: checkfork_screenshots @@ -176,9 +176,9 @@ jobs: continue-on-error: true 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/ + echo "Pulling $(ls -1q tests/build/test-results/dropshots/reference/*.png | wc -l) files..." + ls -1q tests/build/test-results/dropshots/reference/*.png + cp tests/build/test-results/dropshots/reference/*.png tests/integration-test/src/androidTest/assets/ # 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. @@ -234,7 +234,7 @@ jobs: uses: gradle/actions/setup-gradle@v4 - name: Upload Snapshot - run: ./gradlew publish -Pdropshots.releaseMode=true --no-daemon --no-parallel --no-configuration-cache --stacktrace + run: ./gradlew publish --no-daemon --no-parallel --no-configuration-cache --stacktrace env: ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_USERNAME }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PASSWORD }} diff --git a/dropshots-gradle-plugin/build.gradle.kts b/dropshots-gradle-plugin/build.gradle.kts index 7ce2c42..f3f547c 100644 --- a/dropshots-gradle-plugin/build.gradle.kts +++ b/dropshots-gradle-plugin/build.gradle.kts @@ -1,11 +1,11 @@ -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.gradle.kotlin.dsl.provideDelegate 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 java.io.FileOutputStream +import java.util.Properties +import kotlin.apply plugins { `java-gradle-plugin` @@ -15,31 +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") { +val generateVersionTask: TaskProvider<*> = tasks.register("generateVersion") { inputs.property("version", project.property("VERSION_NAME") as String) - outputs.dir(project.layout.projectDirectory.dir("src/generated/kotlin")) + outputs.dir(layout.buildDirectory.dir("generated/generateVersion")) doLast { val output = File(outputs.files.first(), "com/dropbox/dropshots/Version.kt") @@ -52,23 +34,9 @@ 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) +sourceSets { + main.configure { + java.srcDir(generateVersionTask.map { it.outputs.files }) } } @@ -77,6 +45,11 @@ tasks.withType().configureEach { } kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + apiVersion.set(KotlinVersion.KOTLIN_2_0) + languageVersion.set(KotlinVersion.KOTLIN_2_0) + } explicitApi() } @@ -89,20 +62,29 @@ gradlePlugin { } } -// See https://github.com/slackhq/keeper/pull/11#issuecomment-579544375 for context -val releaseMode = hasProperty("dropshots.releaseMode") +val addTestPlugin: Configuration = configurations.create("addTestPlugin") { + attributes { + attribute(ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE, ArtifactTypeDefinition.JAR_TYPE) + } +} + +configurations { testImplementation.get().extendsFrom(addTestPlugin) } + +tasks.pluginUnderTestMetadata { + // make sure the test can access plugins for coordination. + pluginClasspath.from(addTestPlugin) +} dependencies { + addTestPlugin(gradleApi()) + addTestPlugin(libs.android) + addTestPlugin(libs.kotlin.plugin) + + compileOnly(gradleApi()) + compileOnly(libs.android) + compileOnly(libs.kotlin.plugin) + implementation(platform(libs.kotlin.bom)) - // Don't impose our version of KGP on consumers - - if (releaseMode) { - compileOnly(libs.android) - compileOnly(libs.kotlin.plugin) - } else { - implementation(libs.android) - implementation(libs.kotlin.plugin) - } testImplementation(gradleTestKit()) testImplementation(platform(libs.kotlin.bom)) diff --git a/dropshots-gradle-plugin/gradle b/dropshots-gradle-plugin/gradle new file mode 120000 index 0000000..3337596 --- /dev/null +++ b/dropshots-gradle-plugin/gradle @@ -0,0 +1 @@ +../gradle \ No newline at end of file diff --git a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/ClearScreenshotsTask.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/ClearScreenshotsTask.kt index dd4d21e..4f2e908 100644 --- a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/ClearScreenshotsTask.kt +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/ClearScreenshotsTask.kt @@ -26,6 +26,8 @@ public abstract class ClearScreenshotsTask : DefaultTask() { init { description = "Removes the test screenshots from the test device." group = "verification" + + outputs.upToDateWhen { false } } @TaskAction 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 index 662047a..9117a91 100644 --- a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/DropshotsExtension.kt +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/DropshotsExtension.kt @@ -12,4 +12,12 @@ public abstract class DropshotsExtension @Inject constructor(objects: ObjectFact */ public val referenceOutputDirectory: Property = objects.property(String::class.java) .convention("src/androidTest/screenshots") + + /** + * Whether to record screenshots on test failure. If true and recording is enabled, the + * screenshots from the test device will be pulled and saved to the [referenceOutputDirectory] + * even if the test fails. + */ + public val recordOnFailure: 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 547dcd9..c54905c 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 @@ -12,7 +12,6 @@ import org.gradle.api.NamedDomainObjectContainer import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.provider.Provider -import org.gradle.api.tasks.Copy private const val recordScreenshotsArg = "dropshots.record" @@ -87,29 +86,34 @@ public class DropshotsPlugin : Plugin { it.screenshotDir.set(deviceScreenshotDir) } + val isRecordingScreenshots = project.objects.property(Boolean::class.java) + + val canRecordScreenshots = dropshotsExtension.recordOnFailure.map { + project.state.failure != null || it + } + val pullScreenshotsTask = tasks.register( "pull${androidTestVariantSlug}Screenshots", PullScreenshotsTask::class.java, - ) { - it.adbExecutable.set(adbExecutablePath) - it.screenshotDir.set(deviceScreenshotDir) - it.outputDirectory.set(buildScreenshotDir) - it.finalizedBy(clearScreenshotsTask) + ) { task -> + task.adbExecutable.set(adbExecutablePath) + task.screenshotDir.set(deviceScreenshotDir) + task.outputDirectory.set(buildScreenshotDir) + task.shouldWriteReferences.set(isRecordingScreenshots.map { it && (canRecordScreenshots.get()) }) + task.referenceOutputDirectory.set(dropshotsExtension.referenceOutputDirectory.map { layout.projectDirectory.dir(it) }) } val recordScreenshotsTask = tasks.register( "record${androidTestVariantSlug}Screenshots", - Copy::class.java, ) { task -> task.group = "verification" - task.description = "Updates the local reference screenshots" - task.from(pullScreenshotsTask.flatMap { it.outputDirectory.dir("reference") }) - task.into(dropshotsExtension.referenceOutputDirectory) + task.description = "Indicates that local reference screenshots should be updated" + // This task being present on the task graph is used as an indicator that we should update + // source screenshots. The actual work is performed in pullScreenshotsTask since it is + // a finalizer task. task.dependsOn(testTaskName) - task.finalizedBy(clearScreenshotsTask) } - val isRecordingScreenshots = project.objects.property(Boolean::class.java) if (providers.gradleProperty(recordScreenshotsArg).isPresent) { project.logger.warn( "The 'dropshots.record' property has been deprecated and will " + @@ -129,7 +133,7 @@ public class DropshotsPlugin : Plugin { task.adbExecutable.set(adbExecutablePath) task.fileContents.set("\n") task.remotePath.set(deviceScreenshotDir.map { "$it/.isRecordingScreenshots" }) - task.finalizedBy(clearScreenshotsTask) + task.dependsOn(clearScreenshotsTask) } tasks.named { it == testTaskName }.configureEach { 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 10785e0..7c59b68 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 @@ -4,9 +4,13 @@ import javax.inject.Inject import java.io.ByteArrayOutputStream import org.gradle.api.DefaultTask import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.FileSystemOperations import org.gradle.api.provider.Property import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import org.gradle.process.ExecOperations import org.gradle.work.DisableCachingByDefault @@ -22,12 +26,22 @@ public abstract class PullScreenshotsTask : DefaultTask() { @get:Input public abstract val screenshotDir: Property + @get:Input + public abstract val shouldWriteReferences: Property + + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + public abstract val referenceOutputDirectory: DirectoryProperty + @get:OutputDirectory public abstract val outputDirectory: DirectoryProperty @get:Inject protected abstract val execOperations: ExecOperations + @get:Inject + protected abstract val fileOperations: FileSystemOperations + init { description = "Pull screenshots from the test device." group = "verification" @@ -64,6 +78,13 @@ public abstract class PullScreenshotsTask : DefaultTask() { print(output.toString(Charsets.UTF_8)) } } + + if(shouldWriteReferences.get()) { + fileOperations.copy { + it.from(outputDirectory.dir("reference") ) + it.into(referenceOutputDirectory) + } + } } } diff --git a/dropshots/build.gradle.kts b/dropshots/build.gradle.kts index 7d58333..da58dfe 100644 --- a/dropshots/build.gradle.kts +++ b/dropshots/build.gradle.kts @@ -39,12 +39,11 @@ dependencies { implementation(libs.androidx.test.runner) implementation(libs.androidx.test.rules) - debugImplementation(libs.androidx.fragment) - testImplementation(platform(libs.kotlin.bom)) testImplementation(libs.junit) testImplementation(libs.kotlin.test) + androidTestImplementation(libs.androidx.fragment) androidTestImplementation(libs.junit) androidTestImplementation(libs.androidx.test.core) androidTestImplementation(libs.androidx.test.ext.junit) diff --git a/dropshots/src/main/java/com/dropbox/dropshots/Dropshots.kt b/dropshots/src/main/java/com/dropbox/dropshots/Dropshots.kt index fea5b15..98a62b5 100644 --- a/dropshots/src/main/java/com/dropbox/dropshots/Dropshots.kt +++ b/dropshots/src/main/java/com/dropbox/dropshots/Dropshots.kt @@ -221,7 +221,7 @@ public class Dropshots internal constructor( val file = File(dir, "${name.replace(" ", "_")}.png") if (file.exists()){ - throw IllegalStateException("Unable to create screenshot, file already exists. Please " + + throw IllegalStateException("Unable to create screenshot, ${file.path} already exists. Please " + "specify name param with something more specific when calling assertSnapshot function") } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 011b759..393c89b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -kotlin = "2.0.21" +kotlin = "2.2.21" agp = "9.1.0" [libraries] diff --git a/settings.gradle.kts b/settings.gradle.kts index 4cab8bb..f12c85e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -6,6 +6,14 @@ pluginManagement { } } +dependencyResolutionManagement { + repositories { + mavenCentral() + google() + gradlePluginPortal() + } +} + rootProject.name = "dropshots-root" include(":dropshots-gradle-plugin") diff --git a/tests/build.gradle.kts b/tests/build.gradle.kts new file mode 100644 index 0000000..bc1d231 --- /dev/null +++ b/tests/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + alias(libs.plugins.android.library) + id("com.dropbox.dropshots") +} + +dropshots { + referenceOutputDirectory = "src/androidTest/assets" +} + +android { + namespace = "com.dropbox.dropshots.integrationtests" + compileSdk = 34 + + defaultConfig { + minSdk = 21 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } +} + +dependencies { + implementation(libs.androidx.fragment) + + androidTestImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.uiautomator) +} diff --git a/tests/gradle b/tests/gradle new file mode 120000 index 0000000..3337596 --- /dev/null +++ b/tests/gradle @@ -0,0 +1 @@ +../gradle \ No newline at end of file diff --git a/tests/gradlew b/tests/gradlew new file mode 120000 index 0000000..502f5a2 --- /dev/null +++ b/tests/gradlew @@ -0,0 +1 @@ +../gradlew \ No newline at end of file diff --git a/tests/settings.gradle.kts b/tests/settings.gradle.kts new file mode 100644 index 0000000..9ee27e7 --- /dev/null +++ b/tests/settings.gradle.kts @@ -0,0 +1,21 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + google() + } + includeBuild("..") +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + google() + } +} + +includeBuild("..") { + dependencySubstitution { + substitute(module("com.dropbox:dropshots")).using(project(":dropshots")) + } +} diff --git a/dropshots/src/androidTest/assets/MatchesActivityScreenshot.png b/tests/src/androidTest/assets/MatchesActivityScreenshot.png similarity index 100% rename from dropshots/src/androidTest/assets/MatchesActivityScreenshot.png rename to tests/src/androidTest/assets/MatchesActivityScreenshot.png diff --git a/dropshots/src/androidTest/assets/MatchesFullScreenshot.png b/tests/src/androidTest/assets/MatchesFullScreenshot.png similarity index 100% rename from dropshots/src/androidTest/assets/MatchesFullScreenshot.png rename to tests/src/androidTest/assets/MatchesFullScreenshot.png diff --git a/dropshots/src/androidTest/assets/MatchesViewScreenshot.png b/tests/src/androidTest/assets/MatchesViewScreenshot.png similarity index 100% rename from dropshots/src/androidTest/assets/MatchesViewScreenshot.png rename to tests/src/androidTest/assets/MatchesViewScreenshot.png diff --git a/dropshots/src/androidTest/assets/static/50x50.png b/tests/src/androidTest/assets/static/50x50.png similarity index 100% rename from dropshots/src/androidTest/assets/static/50x50.png rename to tests/src/androidTest/assets/static/50x50.png diff --git a/dropshots/src/androidTest/assets/static/MatchesViewScreenshotBad.png b/tests/src/androidTest/assets/static/MatchesViewScreenshotBad.png similarity index 100% rename from dropshots/src/androidTest/assets/static/MatchesViewScreenshotBad.png rename to tests/src/androidTest/assets/static/MatchesViewScreenshotBad.png diff --git a/dropshots/src/androidTest/assets/static/MatchesViewScreenshotBadSize.png b/tests/src/androidTest/assets/static/MatchesViewScreenshotBadSize.png similarity index 100% rename from dropshots/src/androidTest/assets/static/MatchesViewScreenshotBadSize.png rename to tests/src/androidTest/assets/static/MatchesViewScreenshotBadSize.png diff --git a/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/CustomImageComparatorTest.kt b/tests/src/androidTest/kotlin/com/dropbox/dropshots/CustomImageComparatorTest.kt similarity index 79% rename from dropshots/src/androidTest/kotlin/com/dropbox/dropshots/CustomImageComparatorTest.kt rename to tests/src/androidTest/kotlin/com/dropbox/dropshots/CustomImageComparatorTest.kt index 905aca7..591ad66 100644 --- a/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/CustomImageComparatorTest.kt +++ b/tests/src/androidTest/kotlin/com/dropbox/dropshots/CustomImageComparatorTest.kt @@ -1,14 +1,11 @@ package com.dropbox.dropshots -import android.os.Environment import android.view.View import androidx.test.ext.junit.rules.ActivityScenarioRule import com.dropbox.differ.Image import com.dropbox.differ.ImageComparator import com.dropbox.differ.ImageComparator.ComparisonResult import com.dropbox.differ.Mask -import java.io.File -import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test @@ -16,18 +13,10 @@ import org.junit.Test class CustomImageComparatorTest { @get:Rule val activityScenarioRule = ActivityScenarioRule(TestActivity::class.java) - - private val imageDirectory = File( - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), - "screenshots/custom-image-comparator-test", - ) val comparator = FakeImageComparator() @get:Rule val dropshots = Dropshots( - rootScreenshotDirectory = imageDirectory, - filenameFunc = defaultFilenameFunc, - recordScreenshots = false, imageComparator = comparator, resultValidator = CountValidator(0), ) @@ -41,11 +30,6 @@ class CustomImageComparatorTest { } } - @After - fun after() { - imageDirectory.deleteRecursively() - } - @Test fun imageComparatorIsConfigurable() { val calls = mutableListOf>() diff --git a/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/DropshotsTest.kt b/tests/src/androidTest/kotlin/com/dropbox/dropshots/DropshotsTest.kt similarity index 90% rename from dropshots/src/androidTest/kotlin/com/dropbox/dropshots/DropshotsTest.kt rename to tests/src/androidTest/kotlin/com/dropbox/dropshots/DropshotsTest.kt index 6ce91ff..5a83eef 100644 --- a/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/DropshotsTest.kt +++ b/tests/src/androidTest/kotlin/com/dropbox/dropshots/DropshotsTest.kt @@ -4,8 +4,8 @@ import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color import android.os.Environment -import android.view.View import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.platform.app.InstrumentationRegistry import com.dropbox.differ.SimpleImageComparator import java.io.File import org.junit.After @@ -25,14 +25,12 @@ class DropshotsTest { private val fakeValidator = FakeResultValidator() private var filenameFunc: (String, String) -> String = { _, funcName -> funcName } - private val isRecordingScreenshots = isRecordingScreenshots(defaultRootScreenshotDirectory()) private lateinit var imageDirectory: File @get:Rule val testName = TestName() @get:Rule val dropshots = Dropshots( filenameFunc = filenameFunc, - recordScreenshots = isRecordingScreenshots, resultValidator = fakeValidator, imageComparator = SimpleImageComparator( maxDistance = 0.004f, @@ -43,7 +41,9 @@ class DropshotsTest { @Before fun setup() { - imageDirectory = + val context = InstrumentationRegistry.getInstrumentation().targetContext + val externalStorageDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + imageDirectory = File(externalStorageDir, "screenshots/${context.packageName}") File( Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "screenshots/test-${testName.methodName}", @@ -58,6 +58,7 @@ class DropshotsTest { } } + @Test fun testMatchesFullScreenshot() { activityScenarioRule.scenario.onActivity { @@ -76,7 +77,7 @@ class DropshotsTest { fun testMatchesViewScreenshot() { activityScenarioRule.scenario.onActivity { dropshots.assertSnapshot( - it.findViewById(android.R.id.content), + it.findViewById(android.R.id.content), name = "MatchesViewScreenshot" ) } @@ -85,7 +86,6 @@ class DropshotsTest { @Test fun testWritesReferenceImageForMissingImages() { val dropshots = Dropshots( - rootScreenshotDirectory = imageDirectory, filenameFunc = filenameFunc, recordScreenshots = true, resultValidator = { false }, @@ -105,9 +105,7 @@ class DropshotsTest { @Test fun testWritesDiffImageOnFailureWhenRecording() { val dropshots = Dropshots( - rootScreenshotDirectory = imageDirectory, filenameFunc = filenameFunc, - recordScreenshots = false, resultValidator = { false }, imageComparator = SimpleImageComparator(), ) @@ -135,9 +133,7 @@ class DropshotsTest { fun testFailsForDifferences() { val dropshots = Dropshots( resultValidator = CountValidator(0), - rootScreenshotDirectory = imageDirectory, filenameFunc = filenameFunc, - recordScreenshots = false, imageComparator = SimpleImageComparator(), ) @@ -161,7 +157,6 @@ class DropshotsTest { fun testPassesWhenValidatorPasses() { val dropshots = Dropshots( resultValidator = FakeResultValidator { true }, - rootScreenshotDirectory = imageDirectory, filenameFunc = filenameFunc, recordScreenshots = false, imageComparator = SimpleImageComparator(), @@ -185,7 +180,6 @@ class DropshotsTest { fun testFailsWhenValidatorFails() { val dropshots = Dropshots( resultValidator = FakeResultValidator { false }, - rootScreenshotDirectory = imageDirectory, filenameFunc = filenameFunc, recordScreenshots = false, imageComparator = SimpleImageComparator(), @@ -216,7 +210,6 @@ class DropshotsTest { fun fastFailsForMismatchedSize() { val dropshots = Dropshots( resultValidator = CountValidator(0), - rootScreenshotDirectory = imageDirectory, filenameFunc = filenameFunc, recordScreenshots = false, imageComparator = SimpleImageComparator(), diff --git a/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/FakeResultValidator.kt b/tests/src/androidTest/kotlin/com/dropbox/dropshots/FakeResultValidator.kt similarity index 100% rename from dropshots/src/androidTest/kotlin/com/dropbox/dropshots/FakeResultValidator.kt rename to tests/src/androidTest/kotlin/com/dropbox/dropshots/FakeResultValidator.kt diff --git a/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/ScreenshotTestFragment.kt b/tests/src/androidTest/kotlin/com/dropbox/dropshots/ScreenshotTestFragment.kt similarity index 100% rename from dropshots/src/androidTest/kotlin/com/dropbox/dropshots/ScreenshotTestFragment.kt rename to tests/src/androidTest/kotlin/com/dropbox/dropshots/ScreenshotTestFragment.kt diff --git a/dropshots/src/debug/AndroidManifest.xml b/tests/src/main/AndroidManifest.xml similarity index 100% rename from dropshots/src/debug/AndroidManifest.xml rename to tests/src/main/AndroidManifest.xml diff --git a/dropshots/src/debug/kotlin/com/dropbox/dropshots/TestActivity.kt b/tests/src/main/kotlin/com/dropbox/dropshots/TestActivity.kt similarity index 91% rename from dropshots/src/debug/kotlin/com/dropbox/dropshots/TestActivity.kt rename to tests/src/main/kotlin/com/dropbox/dropshots/TestActivity.kt index 8f537b2..ce88419 100644 --- a/dropshots/src/debug/kotlin/com/dropbox/dropshots/TestActivity.kt +++ b/tests/src/main/kotlin/com/dropbox/dropshots/TestActivity.kt @@ -6,7 +6,7 @@ import android.view.View import android.view.ViewGroup import android.widget.LinearLayout -public class TestActivity : androidx.fragment.app.FragmentActivity() { +class TestActivity : androidx.fragment.app.FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView( From 21e31c6043811312ae1248bf7f8fd38ff013159c Mon Sep 17 00:00:00 2001 From: Alex Decker Date: Fri, 20 Mar 2026 15:29:55 -0500 Subject: [PATCH 5/6] Update MatchesFullScreenshot.png. Make rootScreenshotDirectory public --- CHANGELOG.md | 16 +++--- .../api/dropshots-gradle-plugin.api | 4 ++ .../com/dropbox/dropshots/DropshotsPlugin.kt | 2 +- .../dropbox/dropshots/PullScreenshotsTask.kt | 5 +- .../java/com/dropbox/dropshots/Dropshots.kt | 52 ++++++++---------- .../assets/MatchesFullScreenshot.png | Bin 29293 -> 30972 bytes .../dropshots/CustomImageComparatorTest.kt | 15 +++++ .../com/dropbox/dropshots/DropshotsTest.kt | 14 +++-- 8 files changed, 63 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3804583..5fff2c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,14 +3,14 @@ ## [Unreleased] [Unreleased]: https://github.com/dropbox/dropshots/compare/0.5.0...HEAD -New: -- Nothing yet! - -Changed: -- Nothing yet! - -Fixed: -- Nothing yet! +* Support for newDsl in Android Gradle Plugin 9.0.0 +* Changes default behavior of `recordDebugAndroidTestScreenshots` task to record screenshots even + if tests fail. This behavior can be disabled by setting `dropshots.recordOnFailure = false` + on the Gradle plugin configuration. +* Exposed `Dropshots.recordScreenshots` as a public API to allow detecting whether screenshots are + being recorded in the current test run. +* Added new optional argument to `Dropshots` constructor. `rootScreenshotDirectory` allows users to + specify a custom root directory for screenshots on the device. ## [0.5.0] = 2025-04-08 [0.5.0]: https://github.com/dropbox/dropshots/releases/tags/0.5.0 diff --git a/dropshots-gradle-plugin/api/dropshots-gradle-plugin.api b/dropshots-gradle-plugin/api/dropshots-gradle-plugin.api index 08d34b9..a9d2814 100644 --- a/dropshots-gradle-plugin/api/dropshots-gradle-plugin.api +++ b/dropshots-gradle-plugin/api/dropshots-gradle-plugin.api @@ -8,6 +8,7 @@ public abstract class com/dropbox/dropshots/ClearScreenshotsTask : org/gradle/ap public abstract class com/dropbox/dropshots/DropshotsExtension { public fun (Lorg/gradle/api/model/ObjectFactory;)V + public final fun getRecordOnFailure ()Lorg/gradle/api/provider/Property; public final fun getReferenceOutputDirectory ()Lorg/gradle/api/provider/Property; } @@ -21,8 +22,11 @@ public abstract class com/dropbox/dropshots/PullScreenshotsTask : org/gradle/api public fun ()V public abstract fun getAdbExecutable ()Lorg/gradle/api/provider/Property; protected abstract fun getExecOperations ()Lorg/gradle/process/ExecOperations; + protected abstract fun getFileOperations ()Lorg/gradle/api/file/FileSystemOperations; public abstract fun getOutputDirectory ()Lorg/gradle/api/file/DirectoryProperty; + public abstract fun getReferenceOutputDirectory ()Lorg/gradle/api/file/DirectoryProperty; public abstract fun getScreenshotDir ()Lorg/gradle/api/provider/Property; + public abstract fun getShouldWriteReferences ()Lorg/gradle/api/provider/Property; public final fun pullScreenshots ()V } 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 c54905c..f27871e 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 @@ -100,7 +100,7 @@ public class DropshotsPlugin : Plugin { task.screenshotDir.set(deviceScreenshotDir) task.outputDirectory.set(buildScreenshotDir) task.shouldWriteReferences.set(isRecordingScreenshots.map { it && (canRecordScreenshots.get()) }) - task.referenceOutputDirectory.set(dropshotsExtension.referenceOutputDirectory.map { layout.projectDirectory.dir(it) }) + task.referenceOutputDirectory.set(dropshotsExtension.referenceOutputDirectory) } val recordScreenshotsTask = tasks.register( 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 7c59b68..3931b82 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 @@ -29,9 +29,8 @@ public abstract class PullScreenshotsTask : DefaultTask() { @get:Input public abstract val shouldWriteReferences: Property - @get:InputDirectory - @get:PathSensitive(PathSensitivity.RELATIVE) - public abstract val referenceOutputDirectory: DirectoryProperty + @get:Input + public abstract val referenceOutputDirectory: Property @get:OutputDirectory public abstract val outputDirectory: DirectoryProperty diff --git a/dropshots/src/main/java/com/dropbox/dropshots/Dropshots.kt b/dropshots/src/main/java/com/dropbox/dropshots/Dropshots.kt index 98a62b5..81c7de6 100644 --- a/dropshots/src/main/java/com/dropbox/dropshots/Dropshots.kt +++ b/dropshots/src/main/java/com/dropbox/dropshots/Dropshots.kt @@ -21,12 +21,30 @@ 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) -> String, - private val recordScreenshots: Boolean, - private val imageComparator: ImageComparator, - private val resultValidator: ResultValidator, + +public class Dropshots @JvmOverloads public constructor( + /** + * Function to create a filename from the class name and snapshot name (i.e. the name provided when taking + * the snapshot). Default behavior will use the calling function name. + */ + private val filenameFunc: (String, String) -> String = defaultFilenameFunc, + /** + * Indicates whether new reference screenshots should be recorded. Otherwise Dropshots performs + * validation of test screenshots against reference screenshots. + */ + public val recordScreenshots: Boolean = isRecordingScreenshots(defaultRootScreenshotDirectory()), + /** + * 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), + /** + * Device directory where screenshots will be written. + */ + public val rootScreenshotDirectory: File = defaultRootScreenshotDirectory(), ) : 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 the class name and snapshot name (i.e. the name provided when taking - * the snapshot). Default behavior will use the calling function name. - */ - filenameFunc: (String, 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 = "") diff --git a/tests/src/androidTest/assets/MatchesFullScreenshot.png b/tests/src/androidTest/assets/MatchesFullScreenshot.png index d7c93da7f6a4aba2df151c92670a4546895ce334..25fd285cddb136a40687a4f5976ebe66129c0f8a 100644 GIT binary patch literal 30972 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 literal 29293 zcmeIbcTiM|(=EzTF`;;rq=H~V$*4q$DuN1MxkRUmj06{@Tf@A^7StLgVM6%>0 zSwM2m>8)^;=g&3t z95!TJ@)yllBXY04blAakdjE?ZOx5!h0`&sRHuW_Z`1h<8Z2$Jtn+myXIrU)JaU{$k zB@&$)Zf+hQAMe`D zXKC1)v|2B`&71bMP0Jx!%k}*22*>hDzwu*bnFpE8bdS^EH!sH~)1Nu% z4$D*f$j))%$F$%iF2m+H(U+8$v+CmF800nk+o=eao!M?lP&E4X>e%+i{OiDnI#t5k z8yxq(y;93Gq*w};As4k93ALGEfoI^Uxn}P5$!bi|dNk0;B7P z9ip=2badgn4;~koEhCdk=0;$i85x6vgKbHwWXF#iy1a8EJ&|{>!u!{+Um+nhPMgcqy{})sRR47U*8Fg-Nn(b4 ztkhVkK2CBen~aF)!wbWXPY)a&3%pc6o}8VXWmV6z8tRgb^>xgD<{3DDo|x}p-{YvB z=3+H>(i2L_YJMRhNz<+0zklBmE|;v9shMvb*ui6GXV;ZwlBk*yV(1goIkTlOzJzjvYJZ-&}0e z#+GOp#&44nv->IHWrfPZ#zwo?xsbk;)m;UD=I7_j_r85EH@A;TRI1#QqT)53%dhW; z<^Sn)lrgulX$u+uUR&$s;n9&}KInadzf9g;cyr;MVy)4S4_{#DX0H2sdN*#|NX2_r zc%K+JPEJC__Bm}hMlx7eSJyC1O6DJ2P;Lq)1zz)k=d2=T{MSWASJ&3~Mo%1*WL(|l zce-N3H(}%H)2gS$B+b;?IS;Q2E~Y8O%UKTBT-5mF6CS?(yCDh=tlCp>keWMgT-bTT z_RgIT_RnC*#4}1_Vs5Tf9dws1t~+MzqJWE<-K&qLT6*E>Y4NS=qThc`%h)(6hKyaS zAdYq|cpnp7CeLm@=5BSq&GhbFyIusFbmu8*y4c$0xSCkn*eFY{iHnOTC?yBwbaZAK zwJgH1x`0i44cp zxh#{e)_D1mhNv6<0@0Qub$(2Wmo_YM)exiuy39&RGTRx>n`@NZ@v0fRLsfnp8Z zmd2xY_vZRCY~!cT0Cyv>v0S6>?g$s%YJJ z?7@Qv$Liz)1J}6h7k)=E)eA0%G}IOp7PbV$CutR`ZR#f~CDRJn-mz_Vi{j?xRdG_A ztY$Cf%$Q9~Ei!DM`|3*apxVFCdNL^DdAv3eQD1LmZ7u7iOO16MlKwAVz2e+(o@D6n z@Aog@lrQ$&N%G9o)qZ&@x#t$yx!W?re3rwv7o*xzwX?IfUJI?=)GRG|vQyGTHC?B? ztnB)xE+SOWVM&1;z=W7*5Qj0!`4xFvdtJ6QiyZALxVygk`bf(#($Y{X;Hv7zO@4W{ zM_Oq0<61*@kG(`8NpfhiN~%`j$B$=Ao7gpSWD*n@<*8HEGON;t0LqK51ivIIv|k*H zj*jlQe42-zl{Hzb(B9i5ZHSRR{DYhT?}a$4sh_Lx6}QeTvtBmS49gLz&8TeCZu=oW zWtOB|#5+1Zj7qzX^14dPlH`tuRb>O%QPhKD;ZGf)i;4_i`0f)WQzVprr^2f2C?6M3Z@WYmmV2KYisMK7)dYFW8~z`@$#`drqzp$J3b{59xRVm z8YCnn8~~}3f(_l>nodlXKXc68-1ib7-4IxxZYjfdoJ053J!%6J@yhdYI1>%;!#;#ZM@Pq4 zceua5uGQ3F`n`$+T`XD!D)aw&=U?OF)O2)aBfkZNgnHWA+MXnTy8IvO@jt8j5h2pj z_U32aBW7OS_G6PtD(M4y;Z934{UW7C-34|T;(knHCg-HXjBOVN%3o?}X$6V@v6&b# zG4TUk-A)0{`kER}uKsfFC1Cke@!31lGNTPq?rV?hzJLGSo*wnCGoHt!lXLNE+Qa^9 zr6ObWOLKE`YYU@ighC09c}HAD<$bNld|KLtJz2lrNeiXt(%bLy8z>q6(3oYSzS!A? zQ$sl0Xz?%p?Cx%PNnTN2evDSG_YM~i&$9o;pnKnCdNPfa^|EPTL}#SQth*XQ4ok*+ zvhG&;&~tyj`s{-oO*^GR5T`*T=>cS(>4JH0`7VQhPn=g_TD$+L1#1ls;`XVc}vV6ad)bcw6@U zb>Js{sFxL)rC#iEx zOongRZ(Iv!|wpYq>6dicKrbwegs{k*_w3*gg6#9e8Jk}Gv z#oO@Wx{%BBwLHBFaWY?Iw6cslnwu3wYJp31P77~`b8+f_Q25|`P6Hps)ITXT?T5Nq z;R9WktyXoj%VVxIVT4S_cB-K{HPfgK7}7ISzp=4#=|R;*PhrKUt8LJ|)MYLRxXEte2k|teQ$(d&U90W}9ZyL1%eL8Z(hA;G<^KF}Nx#WU% zaXD8!zM4KhV?a44E+wV3Jr8Ik#PM>kOL%awQBm0ZNd59ux5M_83(7MtZa*A_F1Y3ISWl=_0Cb)UPt zONR?}XN@>-%vK=JaTi-1zD6KcL>kGG!Q3XDpVOCAW&b?9%?{$}{{fFHvOhsr728b+ z@AY_FHZqO+&%3JqNi{@`r{V~IeM3sXWTMAbf3S||qsX75I(dRXHHnBu|0Nib=%zaX zz=*E!{r%PdE;HRwZa;XX0X0rS(`NnKckkaHB_oqpnS5IY_NJY#Bl@TksbP7d1J5xo z@seuBAOv3vF1^n?%gSnkC~|F~B`PU?u8y*0fBr!Roq>UYDu32QwvJrO5%6!4L_r^w zd~1`Jl&sD49v&X-1vA&~X2!M;@>&H>c4hAYpi1CZu8D}?l|(f&^YhHIk!1wjQe^iU z@FWEK%?Q)(ys4?F;p}*sh^Mi!u`w4;ojL_Fxz7;K7a2US!=4)fL z1Ss?1$0}4QO=d-_-){L)ox*|i7CAW}&9%4&^O$Y*Jv+2N@fIoA>`Q6k&Cgb&jcYDS z@p2DP*;ZCot}jh8e`EL3c}+m`l44Qh-E+|lo^t~)+5H*iV`Mr989?SPT_-N|p_f>i z>dv<<=JW(+A&+EOPj(s*@TV?ZSewD?NhVFvI-k|=lawga7hwH_4)NBa*$O%r4VH!3 zS@VY(G_#Exx4v2+Fs_&{Ew*VJi7TaQ2|BGUTr)V3G}PYR7B7F^#lNHqt-3 zb?w?$Ds4yE0`24Fv;=H>^R>WkzM!C>#*D+D@vOGCwq*bLa7Q1c!z~X~kk(u&zvq{h zoVQj-Bn1Tp=zuZ>(@5o!JcnX4ZL9HPkc1dp8k6`g{bVMOPBU1Zn~~CGW5J( z1B`eUvuWnN@BIKH{*0!2v!4JhA^OTQCp2$GxVgXxc6WA$Mairs%~u% z@t!@(iAuHA)e^ByU|Q$SpAYA^F@;m30OnR6pPuf8Is%CTRi{6j<{DB7L=xpxEiZ5H zqKExYUHudi6mD6b=?BGGY_7p;i)Ox(z{lp6mg?&3i;)CT(pk>*UFBexMV$gmrsD{E zdV1iZpa^S=&o-T5}DC^KKbuE$T(A-w5!^ST_FmksoyV`7ezo;q~sH9m)U_im*Wja>LB z_}$vFGmgWkH~(gA6Y@M814A4b#ah)nA0JSN46|M#2d3-{b!2|dm%YrM7BK$$@^rAB ze`=~3qzo?CvC#27Va>WTjj^>;NTFzq{zg~%uG)&x_#AZbL~;rm_njr zqEf!~4nBeH>C>6VwfbC2lR0fljrmz%l`#Xt#%*s<9=ff^+aPafXeJ_w}t?x3XFc)MGE-TW0h;f(kOSY~Y z^IAM;;n>&KWOWC7`=IRuEN@JnQS$fZxVztgD8VHy>k@_o9p6J?ICh9m-il8Xxrxga z>6wv{k+SHsiJ9a^+~>3};N1fQ-^X@G?S0D^XY1hb`Z!mDQCo^)g2KhTt4d0({iW`N zaz5(DYc)knFo-<|kAvErnN5+lhbMP|$LBl^)1MtECsZDJ_fcD+Qj#Z+MwW#S+AnqJ zb^rXySsx@|H;3w>Xk~yzvReJK{%nuKG~CcgNSZ>FP;;!#j}ShF&SfLLM5#k}Wv2hx zoy{Y>8z#YcP})gh#C-6`yJ;;&WViC@&42pd4JZ&TuB)r75XZWU zobtrQU3(Q6Vs+y}ccj*KvDu*fSIBE_A(uPD#T)iLhdNH zu?h~-%X$T`@TL!;6q+5G--7dRdidmD92soTM|?L+(asiM}4?BH?En;Aoggg0jT-RXkNW>5&1cS*8o6|{#YNZwp3xexnB_djumqF+QFXlGc)gn zUAEe^UAlA3?_ER+85kaJ#TA6mdEnTk>)Me`qq=n=xu97w!H@Y39CTJZU+JMJ_CAN0e*TP#X z9Q6VjPU}mD&xpOx?{%0i%*GRs9}^jkr)uUeL7qdhyT^%xl8d&Nl)0)cq7d~*M*Z_;!;r{ zkG}441m3kDu=VQI=Z(Fb?=Rf_ir*KxcI}n5gvSwjR0z2`<(@n}wxwV9YyN0aGQUbk znSxiOYUExzeLB*hDJF)m0MZ|L0koG*Sc)ar;X_DhYqSJa?W8==G{ z&u+ij!HW7IuId`?d^NBFhsCi>=S_PKx|jAR_^mYv!q#vttFahpcmSKG;c9kLSU`aC z_3PK`!vt#`8^&_;^IaAiZoqqgj9N+fvmCo{=P~`hk9zfCYaHC(a_~<=c=7V)l(2$s z6d~4w@A(K73(nVY42TT4zcsQ}f~8KMJ{@L17QZ9=z}~$o5Ss97EU75n1y7$I=+Fyi zeOQgua^w2-UAuQv=0xAirxz4-`tp=G=o$(%fY0%6p_3=CYy>i$IWs7{z53d9^5V0> zA5SX!AwR%;PUdc5jM9RO%?XN$Nyo!cgv}>s_Z&H|tMAJwuiN4;Sl2Pk#RKLq@U^lIx;;CxoSVyEypDqp{}n z1ZToEm_juiQ|j(rs; z(G1v>4wy_rf(c%m)%rCuLUs7?UAsg_CnwhJtVEfJuY^zDa;QGm{RwG!HgP;qFYFkf zRb0+28YR`fj8Tjl5Dby?k_>%b{IT=GXcOzc-N%u)LIoYN=yTO%eGy;gjUN~xQUI?K zR!yx`mDRr!JE`OVoIdH4?s2~TU*Ycv&x38}cEuN#AmHChXP)CZnY=BzV zxsp)H$;(#VGUmlqbAwWZlj2SKQ^&9oQ! z3oVAkEi5b$*)XC}YZ7DKE@stFztGw^L@S_g;&2_s@Wn-bQwxhZa1}ISoE#m)9Oo+K zqQyJ5En?zQkFX_$`UM8sL2!ls;?Jy{0*zGc{N?5(RZE;WM5DBnuFTtk zmk4>BPbf|)xf87qGsC>J`Me?ce*JK5FqiOCQaT|=r0X&D@2+lchADiC309LG;dh43DxpzwS1=FPiz1ceIb0BsTH7+_IO`H57Jthva*&%Zu5 zRBd2jfTCq@~1UT{B(*0Gu9XC{q*Tm zmT9-)%7|ISWqE#h@0$6B_R47y3dp_rl6&#gRxR41$JTkx@I>(b+ z2QED9E8#L~eNpYPF(SNuSok5wrAz)Ss-Nr^4HqBP2-+_k6?QJr-CZCRyAF;a9m@A$ zHB^b9o2-t6o3*EXk;#>crRLQCf&8M!nezJt2=CO-+_CjNZOQ63h;u-B5UYBo;n2{~ zgZfB4&&cK!O^2DjFFJPMLFo8EySUFVJ3sF*o}3M|)0?kvKMq>P@3OT4y#~6BprBw& z;?7)W0w;FfTx?(NE_=&efT!uM9iBeo{f+}+tcrXZ7puIK!oI_V?p|8O;SDA zlTE=(x=3R|LkgzwZfGIj04aU!D*zzNsLgb1V>Kw~+0&<;*=A<_sjtC9adlM+?KACJ zLx24E0dVx7I{XL9mtun+L&B8B`+PM6XkoFgjEp=$RWU}qYZq#o%H}GAfkoQ$(qXr6-~K!%YT|5!5Qop+2H$FNh22u+ zW4(v>HYaKot~E&uCo`hQ#XIgqp=lwxIR+Jr*9!383q!|kJyRwhLNuJY;jNKxoveEg zI@w~!zCzSJFXqGK<>mJrqTPUisjsBo3GowYvb;DFyav4~Bg&$;2!iLXt_DRqmKGL8 z>r?sY!hHWtoWB-;(jbiw9K@pkt1EcHAX?VV#)ub#i-jtQF56oJ_Xp4wg@%@J!`(CT zEIqxeyE~y##n0bWM^y;uf=36zhX5b{H?tRUI}ixy-I8saPWt%q<0%n0r0qEQUhr>} zQI~CJ?wI?ILb8jMrsKaJPWoT703^&%=V|z@C(t6`k|%*xTkr~VUoc67D0$M5ZdS19 zBh-+P!{Mgm+b#>g8wPrZkxinaqP&#!DZE9>%_}0>C$ybcMLATO(MAhz2q6$_iQ^&u z{xCx-D>_3QJKAwFAj9P+6tYb851Zw!eb0nOI%Fs8z|5@ z$)Jy`b!gg7x21;1>^9&}Ml)z&$OUDo;`aRaw`YI|K_<;FWda9~Fyx`@NLpUh8f}c0 zl#EFL(e2KA#O2Bsyc`|dq3Rjw%OD$x3RR3}sx>+IJ+;f~5LN8w18k?1WdKq6F=x5B z9)TLfOwrDu4$;oDx+TvbB}x&HEl*A_An>!<CrssOv-00!F>%D ztW0!tBI7R$1N&7n4gFM1ozsOSUc_2iT3Vi$3eg`6$^f-O*W_!M@@;YP<3LdmV-rIz zEnKLXnTJt&7sy3DUcTh8U+S%iR5=bi4uuIiyb|(-GPKZc+7#oJlb(@JfBw84xO@;Z zCxA_8rEjOGEG89Bh0WfSlA@$a`h2h=V&KEzkESO1!XF7?fh|vXjN4E4oE}6q%94}o zc?nV}*?Fm>PQSt9x8dBJu1Mcpd2&z3^E(BUm+nT`LWeIW^Wo5|6R?>IX_y4DU~+z8 z@GbL_O_1%n@2gj@U||F9m!6)Dz5=M)h_yI}2zE!EBrZU8ckELkGz|T3n<&;>>()O3 zmxIfSZRA7G)$NCqhDG_;-H3R@5GO#Of{kQ^UU8thiZ98*`ue99)!xBLr37(NOLXPbl zI;+-fB1k7z=7$eGyVjvP>|)gM+29?oGkip*1nEAqVbZuIPrsu=DaQT33n4 z50KVQJ2wVyvnjlCI$>{T7at&Hh{DB|_J>dJPQdmV-JR6|b)19H4R!jbi?>aOp${;s zs;UNkg{IiibReqObm~t>WTN0kzAdS;kMMzXFXktEoypP$2EJDZx@^jZ<+UNK7r5YEG(&i1towL>l z5ohe$TJopwD(5F1BEcLDYgzS^{RJVzpppZ9>YD+~Cb1|TB@3eS8eRtwNOF^QZEElY z!6@j&idt^{?CMe%$e8T!Kjpn%>tf62VJjsa+vFj98#<6^h^R(i_P(>{Wn~Ue>-hWo zTW_qG&)0i;d4)2|Y!iFB=pqIgW}>dk`l8F{`A&&7x5+i0x$yu_oSBl6cVr8^A?S#C zgi7LzkzHVLw`=7s4(_w@*BMa%>7%aoVkct*D0O-50cE$}xk>5>`7-${P=4BOhkUQ9 zviR;LzragM;ZEz0P&`bXWh=M9pE3FnR0v{uN|Er_g!;(U+BJ|0`vyMI5& zwkMzmJ@;k_nK6&-2y(NoJGwSeac}fn*v;46b8G_`AX}lWT(cYWtz{1~tg3zQRP5p^a}_e{DKMzqFN(kaokwTzc zP}X+x`y;wr&i$(FbzZOS6m{Ta+1G!1_DuDnrpdmxg}V_K_^KIDvC&u>y;zsxSXTKe z<$!ihd7wl?Lqm+NDq2&Iz}?5PWj@`nM3x9rd);&E8D(2LQpxb8Hs}HdS%=x*LqLh+ zHL4>9Sp?rry8NXD;sOSV9z8bb{2V{*p=-_Kx!>cdC+|L}2_JSQa&fpQh!pr|qq)i# zT32Uq-sfMJ?$5vUG(~e5!-n>Uv@=cCfi(H+9{4pns@j*qG7*R5W%SfD#)8VG8hU)5 z^`x5od^L*nPx=j~Nwm9<(75|&{NnwI9Z2cLQw#ZquLo!yj*;#u;l(?gE0BzoeoQw6}t0Ik5j@$|)0N zstBRa;!mnoRHX!#?vHcM#?YgM0Oj=3Jozk>B>-JMqb-C}-`opC29a*CzhxQXc|%tw{dUQF0(Lc>Uc zc7UGE)!9v+q*1h90K-Wo4K4)se~7lgw40AARp_jV z@;HTu&}UB*Dk@b`A1M+N5)S)gdS)4~!UXKl!6=VzB#(t(>B6sqgiBBeA7yiZLk+Ag zvYktrn3$YQKBNFHOqE*9R5maC@TA9998Om`*W$q=50Mn#+V9_gXqXwUN{bKp!@VIk zWfUu3`ZrdEceL?-^{xDjvQuw&vNMoZ3Y6Cm9s|g6mI{hsEC)$Qplt=l`~*M*pwO4PefyPtG)t&d8!By-C~NEMMy>b2 zvzQjv^XWiq!QJsMN>qf}1s@NWYx&g(KfuI8e%6zR1i)DN$8dq?zDR0|TL9#*Rh9_>q@0MQClaQ&dj0f4?(5p=`e~=A%)2C0%v_F;ikf~n!HMh7(@yt`#OG4SY`UahoLf;l}XNpki zRwk-H9h5PjGmm>45>kL-0kumMh}js0^8p$Q7aJ0ahI+kt#MFR~J+4NIa%z~7R_BAz z;hC*N2M&-cKeTTAQ0|8*jT8@c$skU6_U)TDU+7BQ^3Q_)_D;930(}`wcqwKOrfErr zoY^1l_IO&=k)g*$kMU08p6#ysdb8*&f>+Ro_&Cf6g#tdpoF@3>$&-Oy?;+9e2v<>f zk^L3&93x9o6pW{-C3xTE>$vpRp>ZXwmnNSiT(xmKUD53wWJOQc94Thm9&a&}v9~zoG$M z5-kbIh>s)p<0ZSKuybMGnHgVg*wIOb`*XNu}!QpAN*!e*Pxm6@QeOYG1;>i zTlxL__r``}?FUv)PKAwg@$k#WVu^eWB@htrDXT)Cd0f+|^+bC_;9(xLJyF01 zCvXS~ZlZyKI#ZU)pIy7>@MPu-V@*x+^&d=t#?XuR+=dTm!92xlW6TmUhn3J<6gYoI zJ$7-l>4pa1Dx}U$OmDFyeW|E8&&Q``xq~3o6Pio`1ozefH438NCHCrQ#OmWmeWD&q z>*xw|WhanNQ#&|A$G(-J0Bvp;woIt1K|ToP1m&-{#Kn({C~+IL z)@;Q0my}#Ff3~u+a!q^j>l3u+(UeYQ1iyTMo>Ps05tvGykzB+?lCqXvc}*1RfkqAa zIEJb~4n39v*6x+r!CHaiM~}t@(j_reVPJ@}_-Jm7I_lB0Yy}w*@_}YI#Kb)3ox)M3 zK;Q~Cc+7XCcJWlfn=vki_sNa<*%7L{I@L7Z82wV^8At+@$?f&tZNvSWXlmXFg>UU{Ei&(# z=*%1k$shBH4YL<-I$)m5%HJ{H%5BzT?97!5p96|?wO{&vBl1J!QFVv9iV8jGQZfF_ zPK5f-JZy!rz_5jBHkZ($i0{#G(G zCq+;*lm;Hf(!UR$;QI_+$J;ouWRc$pqdy4bi))0I9pviM{?pz0ZC|U zXAB%E7w}#!7<@mlVQPS)g?BC=lYW?)+S(qe2|SZ>c71DoIym?Jb3n_gIRj}hpY_BF z8f-y!n&yY)<`XYp4k5`?qahB;t#+SZ`!QF%H&(64F_-uDL`z~DJQvQ7V^Br+0$`wR zKN_7{v?gwOq$Yk(m|}h^3aTLWYV?GseqJ_;B_?n$oxA$_aWQ4cA%clLIkl(+YP1<# zW(P!PTi;lJ8cEhHb|&<@crvbPX#9joY;1h&*BND*RY(KnrOao~{=y(b^G3lMcv=nm z9ABUB3(81Atp!(XZJvRrdz@#ZbC^hnbqnX~_|ds@yM`164FzhKp}(ID`4Bn|V#PA_ zpCNjqr2)2;gDDY+hG5pzmn}Xvu)3`D@0opN33^YMeS(}5Cv1qJ<{aDE0W|M^G_U!1 z53GeMLYl|0hggzh$>V?U@sxW8Q1;<oTxv zd?}wV8rwvee9O?ApPv_ApWrAxE)J?mMvcKOy|f)(X$AAplTY99;^}G6F^|$! z@`+?1=>I?f%z2pPxP1gA#1P$>@a4Hn~;i%%!j>@`h-7V*Nu|sC$nx-+%87wSo(X+2o#*bsGA|xavG*lBkJ(TyyPQ*j~ zI$AhwjBYu4WX4tjx=Tnl>W>8J=$)bZGN#bd036ol}1`F-?5pA82@tgP71W@wqf zm=Hpm$&27E!^%zkkH&o0_|;{7G4>rS zJQ)QDX3Vp21`y3T^Ex2PDW_-{m<%BuK#&o?b&FB;(|xd6gs*v2FspWvm0{huq2TP% z3kuHj!;+hi*CC4Y`0_^`O5S{V;T{feK>0R^-oW4>YtomtHXBgG80S(c%xvGjU4SW2 z^gW!MoZzl(q?_;Fy<1-xjVe0Xb1XDQuVsqg;@6AeuM3!S!uV6?44DIPA}65&k~wBr z64=mX5>4OAIe74(=4bu4x`Pm$Rp+YJtgJpa95Vt<1p)wLLsnze*Be`lxDK+jr{cl{ zFe^(rGMqmj3#kWFT*K6_PG66FIl34b6@{{G4WhVb_$z6F+2^b8pqJ8eF79ywYsGXU zn@ZXhfltulYwKfbYikKuc~(M;oB@-zEDLjU`wtx8v~QV%QK2oGShfFx+}|wWFUgn` zil6AUc7HI3{1X*by2Fn}wGrLxjYc-8@Y`F;ySJ;$(x@@XggUcpp1g z)DJMB{%_qyC`ZZ1U#mONK&6Hlt+NVulf5IJJ~=dDXN2LR8{RY*^LT%uD|(Bu{FR1$ z@d72g)&^R)2L4rSKOmvhhKu#0W`syP{WJHTVkQKK1BVV#O)s2H23I)7%99cI9sgLq zxOMxo)7s~T%6#vkPoaL8!&-Ikgq$L1*vwGPOxV`ZjkwDJN8r{e+dD7u{Bo4K(8i3& zENL$aaE`T>A}o?Xx6|LhbH|P{VL|WGuY9C107e6>f#%iPn(V^DE|7#>7ga}^Q@9@q zQ0u$Bulp@Ry>&Wd40#?Gn^KvUT}E8I3aHH*wr%srE*$p$^}~S@1d%ew<~XPRNiBy# zNHv)i$%408x%*z(X?vdOQN!?eg9Vi%drA5I15BQgzg@!9W~MY@9xMXUV7TMS`5(~8 zQFp?I&a-mYqM&Q`I?mUk9#Q+0e)ja4GvyA4!UY{#YuvBd+pnR=1tzU|CmVZ02kC@1 z(Ap!_*T1Tfk%4hB@S;;}?(CkPp2rd1iaQydmeTH5HeGufV^g4P`aO%qMA^(ZXSWnwkHqN6i_6&6Ysl{@`Q zb$taXbyZcz(fbO#dD7dHrpKLoLzK4*1SP)sn4`6I7Jb*q$jCNwD9UY~N3+@QNCil2 z!hv(P;@ps^QEO6ii#ev)#?&EPev8xA9sS(?@Mq@)jICps8M|DQv7L-^XdZ*GF?vO9AV@3G&DP~b49KBFDfjM z9mss>>uvfeYh5fp?)Dsl9XLqj)=nX4E|45+g)!Ro;Me!JSckdr$Y&YF1=0@Mi~8c? zo;#&^f=7Zv!7^PQVI2?y1~JYW1z2E0dm9B?LhfD1C&TaLu42h?-Wr;Ujd=t$RH<5@t2FMd78B+KvW*TTbgilO^wJvr zlUPaswZf4TIuQr7XX?hs@E7#J*P$o4EuP|hurB=e>xvzwP0S;k+?$YEFa3vbMJ6^a@%+{|Ed8f_P2p zbNyUORyFKowgWVOq@$I`(o=DvB0@Grm=t4Y4Ii#P!+3{3awP~95*?cL#c&b~cDx!?I=UC+_C;fWu=OnxK>_ML-4GlXat?EBB zkEUvBYFL}A4bbP?D9Nx}&V66HhgEdQtE*%<8zKT*ZB-zAh*eGg&K-|PjNZC4Jbb)2 zhp6HdQF|%Q1J`iOQXe1yE;pPye_i(MbTd0)23#a$)CTK?a^mUN38Ugy-kwUC$pRQ& zGuRj6MHmx*9C;U>3VBa40R6j(pSc-nojYTFrrciR*B`sNyI1f}7jOUvC07akp%hv- zoyUaVBRU41!5EWv^y0oVOG_!pM7b72oYeOpJa9ta*tL;6hv?6&`X$u$$bZR!2hqJ7 zDOfN3@Wb4TFs^?8{@7HYJygt43}96s-`RGCFyj8<#p-x!vDj)p&f+fp>{M0X;9P=}6Q z_#hR^2M0l#h6IS(X#|A~Yx8 zhkq|$!_w9F!&un6;!Pmv7SBPA-<-kH*khUq(ClA(vEg;{27wv>ed%8d{I$UUeij(1 zDGEb)Kb-#?Ptj)c!BU80e`z7|5Gfm zG1)C?LDok5?QTsW32)ldbIfm!Js7s8{MYwjnGUhr!IRS^x;u|@6V2@|sobHH{*Odp z3{#w-bOisyZt`0vS43Lv=!NTl>|Aq6cq3jcw23}e*`JoypBC2NAFuqi!(S)(>jZy6 zf#|Oj{B?rAPVj#sJplRrU*wT?WjkJwh()f66Za9uy;CN!BHj8(bJ83$N9tz0G|nqC zcZI_1N9H?>vvPA)9ZqAko6@LjX<@-?OSz(=B6DQN<8q@QEDG_=KRD(khBdIbLVeRH zug;w%nVgcA_+p>T%*^!J!0npw%`<6NqJ5K-leJRY#uvpIM_eZIOa*9PR}5me$BsL= zCG_9c+fAH=uMEMGqrcsclyPnzp4g_*ONaKSC5SWP&SvY&@2+I(JnfOyhZqfVbQ&tchgvSnu(zL_KN_BriMrDVZ#NfB~YvCGdi ze;9L9le)W@<`O9{Ve?GmB3FuJ9MMLwR_Zdg0Z5#qTWKjH2W@cvOwBs{xxMhASF5bp{qsF&eJ9>Ll3&~aPQiu%UK1_*) z_KyXydJ<6Be4kC>jyan{P+#u}T3Y*7Vht~ue{MEpO^TYfZzgqSlm=mQq+G{ks%B&! z_lYy4$k`FGLq@xav&}K<)+9v9H5iLRq?b! zj-bGy;FFR~1z3W|^&?z5L6V1hHa6J?LDYiRn>cak+=@hxWukpW1y>j3GXsd3lYHlJ z_eb`nGpni@9W@T){R0zm4;|WNBZ>%17ncHYPlPg6#qSr79`J8E6z%I=T>O0JKSUY| zU0H;sp@=>EYbWDMxHMMN`;;GASjai#rd^tdBl?*nS0z~x*l%W-dPGT)aVSPO%tc;2 zzEEG~;5FB-(r9gk3pAwX-&JCf(CE+(N3E``YT?M2!vC!J%G{0b%))MDcUPC?61mT! zePO2XX`=oubq6BTx45(5mpd{J`}@ZI!w*U}85fHhC$BL+aB5Wi#@JmZ52&C1q;}FbtKS43}J8udrQusO-!VUB_WUstW z8Sg1}DgInqi1ocyn{uLmrQ&3oYz0iamf&x=y{6Hm>f>U9Z*<;zj-^iakt;+*0rKJ< zU0v&hZ8x5HBX$xc6~-ZW?aR%Z&Pr{Mu&9(+sG)LUClj_?8dNWn!YF$5|%0ivyL#RP2)6olHMDgu^wVSK60CrSNq!+71zE z;<$hc+%%;5Dfz?7hLfTtRpZO+B&$yI^d?=V#>UNB^hrku&dfN3`}efvnny3~zjK0y zhK6NhE8Ps<~#Q2k6l*w12=(oN^_O=>D+- zyE8f_J|XC0-4@f^m2mUYXz?eB@O5*b3A=SpPUo3YHPvRWJ1Hwq$Nyy_im)&2!44?f zil3T;iV6=lhJvJGmACvjLZz~^vyIqyD5;mr*a6}QNP(lKTFq2&xUC{GlB!oY5&eE- zTAV4mnU+pTt6V16p>2Jci0y;#T+5B4ZDEI7l^7pfII8jMD%Ieu?S-SZ%K|J_xG4iw z0Byj9<6h7AOAKp?DXBMc?%A^k%cPrnW;Ktp*?xYO6Yt6@)Jio7iZQ6$h93dlZ1Igp zLQAc7DoHU0Qc_a>1;TdL)~(}U4HXYrwJskk(yy42w5 rys6*6^#QoFd;S+mLqv~*mxVj_D!%J^Y;b^ZOt(a(uBTkP^Ys4$f8RK~ diff --git a/tests/src/androidTest/kotlin/com/dropbox/dropshots/CustomImageComparatorTest.kt b/tests/src/androidTest/kotlin/com/dropbox/dropshots/CustomImageComparatorTest.kt index 591ad66..69844ce 100644 --- a/tests/src/androidTest/kotlin/com/dropbox/dropshots/CustomImageComparatorTest.kt +++ b/tests/src/androidTest/kotlin/com/dropbox/dropshots/CustomImageComparatorTest.kt @@ -1,11 +1,14 @@ package com.dropbox.dropshots +import android.os.Environment import android.view.View import androidx.test.ext.junit.rules.ActivityScenarioRule import com.dropbox.differ.Image import com.dropbox.differ.ImageComparator import com.dropbox.differ.ImageComparator.ComparisonResult import com.dropbox.differ.Mask +import java.io.File +import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test @@ -13,10 +16,16 @@ import org.junit.Test class CustomImageComparatorTest { @get:Rule val activityScenarioRule = ActivityScenarioRule(TestActivity::class.java) + + private val imageDirectory = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + "screenshots/custom-image-comparator-test", + ) val comparator = FakeImageComparator() @get:Rule val dropshots = Dropshots( + rootScreenshotDirectory = imageDirectory, imageComparator = comparator, resultValidator = CountValidator(0), ) @@ -30,6 +39,12 @@ class CustomImageComparatorTest { } } + @After + fun after() { + imageDirectory.deleteRecursively() + } + + @Test fun imageComparatorIsConfigurable() { val calls = mutableListOf>() diff --git a/tests/src/androidTest/kotlin/com/dropbox/dropshots/DropshotsTest.kt b/tests/src/androidTest/kotlin/com/dropbox/dropshots/DropshotsTest.kt index 5a83eef..4638ab6 100644 --- a/tests/src/androidTest/kotlin/com/dropbox/dropshots/DropshotsTest.kt +++ b/tests/src/androidTest/kotlin/com/dropbox/dropshots/DropshotsTest.kt @@ -5,7 +5,6 @@ import android.graphics.Canvas import android.graphics.Color import android.os.Environment import androidx.test.ext.junit.rules.ActivityScenarioRule -import androidx.test.platform.app.InstrumentationRegistry import com.dropbox.differ.SimpleImageComparator import java.io.File import org.junit.After @@ -41,10 +40,7 @@ class DropshotsTest { @Before fun setup() { - val context = InstrumentationRegistry.getInstrumentation().targetContext - val externalStorageDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) - imageDirectory = File(externalStorageDir, "screenshots/${context.packageName}") - File( + imageDirectory = File( Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "screenshots/test-${testName.methodName}", ) @@ -86,6 +82,7 @@ class DropshotsTest { @Test fun testWritesReferenceImageForMissingImages() { val dropshots = Dropshots( + rootScreenshotDirectory = imageDirectory, filenameFunc = filenameFunc, recordScreenshots = true, resultValidator = { false }, @@ -105,7 +102,9 @@ class DropshotsTest { @Test fun testWritesDiffImageOnFailureWhenRecording() { val dropshots = Dropshots( + rootScreenshotDirectory = imageDirectory, filenameFunc = filenameFunc, + recordScreenshots = false, resultValidator = { false }, imageComparator = SimpleImageComparator(), ) @@ -133,7 +132,9 @@ class DropshotsTest { fun testFailsForDifferences() { val dropshots = Dropshots( resultValidator = CountValidator(0), + rootScreenshotDirectory = imageDirectory, filenameFunc = filenameFunc, + recordScreenshots = false, imageComparator = SimpleImageComparator(), ) @@ -157,6 +158,7 @@ class DropshotsTest { fun testPassesWhenValidatorPasses() { val dropshots = Dropshots( resultValidator = FakeResultValidator { true }, + rootScreenshotDirectory = imageDirectory, filenameFunc = filenameFunc, recordScreenshots = false, imageComparator = SimpleImageComparator(), @@ -180,6 +182,7 @@ class DropshotsTest { fun testFailsWhenValidatorFails() { val dropshots = Dropshots( resultValidator = FakeResultValidator { false }, + rootScreenshotDirectory = imageDirectory, filenameFunc = filenameFunc, recordScreenshots = false, imageComparator = SimpleImageComparator(), @@ -210,6 +213,7 @@ class DropshotsTest { fun fastFailsForMismatchedSize() { val dropshots = Dropshots( resultValidator = CountValidator(0), + rootScreenshotDirectory = imageDirectory, filenameFunc = filenameFunc, recordScreenshots = false, imageComparator = SimpleImageComparator(), From 04afb29901bb42d0a3ac61c840f41bb786c10e3a Mon Sep 17 00:00:00 2001 From: Alex Decker Date: Mon, 23 Mar 2026 15:18:37 -0500 Subject: [PATCH 6/6] update api --- dropshots-gradle-plugin/api/dropshots-gradle-plugin.api | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dropshots-gradle-plugin/api/dropshots-gradle-plugin.api b/dropshots-gradle-plugin/api/dropshots-gradle-plugin.api index a9d2814..433f880 100644 --- a/dropshots-gradle-plugin/api/dropshots-gradle-plugin.api +++ b/dropshots-gradle-plugin/api/dropshots-gradle-plugin.api @@ -24,7 +24,7 @@ public abstract class com/dropbox/dropshots/PullScreenshotsTask : org/gradle/api protected abstract fun getExecOperations ()Lorg/gradle/process/ExecOperations; protected abstract fun getFileOperations ()Lorg/gradle/api/file/FileSystemOperations; public abstract fun getOutputDirectory ()Lorg/gradle/api/file/DirectoryProperty; - public abstract fun getReferenceOutputDirectory ()Lorg/gradle/api/file/DirectoryProperty; + public abstract fun getReferenceOutputDirectory ()Lorg/gradle/api/provider/Property; public abstract fun getScreenshotDir ()Lorg/gradle/api/provider/Property; public abstract fun getShouldWriteReferences ()Lorg/gradle/api/provider/Property; public final fun pullScreenshots ()V