diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bab4f83..c06253c 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() @@ -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 + continue-on-error: false + 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. @@ -204,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' -o -name 'connected_android_test_additional_output' ')' -exec zip -r instrumentation-test-build-reports.zip {} + - name: Upload the build report if: always() 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/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. diff --git a/build.gradle.kts b/build.gradle.kts index bc89002..4630972 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,10 +1,14 @@ import com.vanniktech.maven.publish.MavenPublishBaseExtension import com.vanniktech.maven.publish.SonatypeHost +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 { 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) @@ -14,11 +18,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) @@ -36,6 +35,23 @@ allprojects { } } } + + plugins.withType().configureEach { + tasks.withType().configureEach { + compilerOptions { + 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(21)) + } + } + } } tasks.register("printVersionName") { 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-gradle-plugin/build.gradle.kts b/dropshots-gradle-plugin/build.gradle.kts index 7ce2c42..5848768 100644 --- a/dropshots-gradle-plugin/build.gradle.kts +++ b/dropshots-gradle-plugin/build.gradle.kts @@ -1,46 +1,32 @@ -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) } - -buildscript { - repositories { - mavenCentral() - gradlePluginPortal() - } -} - -repositories { - mavenCentral() - gradlePluginPortal() -} - -sourceSets { - main.configure { - java.srcDir("src/generated/kotlin") +// 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"))) } -} - -mavenPublishing { - 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") { 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 +38,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,8 +60,12 @@ val releaseMode = hasProperty("dropshots.releaseMode") dependencies { compileOnly(gradleApi()) implementation(platform(libs.kotlin.bom)) - // Don't impose our version of KGP on consumers + 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) { compileOnly(libs.android) compileOnly(libs.kotlin.plugin) @@ -115,7 +85,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/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 c7037b4..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 @@ -1,122 +1,172 @@ 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.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.ApkVariant -import com.android.build.gradle.internal.tasks.AndroidTestTask +import com.android.build.gradle.api.AndroidBasePlugin +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 java.util.Locale +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 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) { + val dropshotsExtension = extensions.create("dropshots", DropshotsExtension::class.java) + + plugins.withType(AndroidBasePlugin::class.java) { plugin -> + + // Add dropshots dependency + afterEvaluate { + if (dropshotsExtension.applyDependency.get()) { + it.dependencies.add( + "androidTestImplementation", + "com.dropbox.dropshots:dropshots:$VERSION" + ) + } + } - 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 updateAllTask = tasks.register("updateDropshotsScreenshots") { + it.description = "Updates screenshots for all variants." + it.group = JavaBasePlugin.VERIFICATION_GROUP + } - private fun Project.configureDropshots(extension: TestedExtension) { - project.afterEvaluate { - it.dependencies.add( - "androidTestImplementation", - "com.dropbox.dropshots:dropshots:$VERSION" - ) - } + val componentsExtension = extensions.getByType(AndroidComponentsExtension::class.java) + val testedExtension = extensions.getByType(TestedExtension::class.java) - //check this to have resource based on flavours - val androidTestSourceSet = extension.sourceSets.findByName("androidTest") - ?: throw Exception("Failed to find androidTest source set") + @Suppress("UnstableApiUsage") + componentsExtension.onVariants { variant -> + if (!variant.debuggable || variant !is HasDeviceTests) { + return@onVariants + } - // TODO configure this via extension - val referenceScreenshotDirectory = layout.projectDirectory.dir("src/androidTest/screenshots") + val deviceTestComponent = variant.deviceTests[DeviceTestBuilder.ANDROID_TEST_TYPE] ?: return@onVariants + val adbProvider = provider { testedExtension.adbExecutable } - androidTestSourceSet.assets { - srcDirs(referenceScreenshotDirectory) - } + // Create a test connected check task + addTasksForDeviceProvider(variant, deviceTestComponent, "connected", adbProvider) - 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 + testedExtension.deviceProviders.forEach { deviceProvider -> + addTasksForDeviceProvider(variant, deviceTestComponent, deviceProvider.name, adbProvider) } - "/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) } + } + } - 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) + 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 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/$deviceProviderName/${variant.name}") + 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 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")) + } + deviceTest.sources.assets?.addGeneratedSourceDirectory(addReferenceAssetsTask) { task -> task.outputDir } + + 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(additionalTestOutputDir) + it.deviceProviderName.set(deviceProviderName) + it.outputDir.set(layout.projectDirectory.dir("src/androidTest/screenshots/")) + } + tasks.named("updateDropshotsScreenshots").dependsOn(updateTaskProvider) - 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 isRecordingScreenshots = project.objects.property(Boolean::class.java) + project.gradle.taskGraph.whenReady { graph -> + isRecordingScreenshots.set(updateTaskProvider.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) + // 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" + } - testTaskProvider.configure { - it.finalizedBy(pullScreenshotsTask) + val writeConfigTaskProvider = tasks.register( + "write${testTask.name.capitalize()}ScreenshotConfigFile", + WriteConfigFileTask::class.java, + ) { task -> + task.group = JavaBasePlugin.VERIFICATION_GROUP + task.recordingScreenshots.set(isRecordingScreenshots) + task.deviceProviderName.set(deviceProviderName) + task.adbExecutable.set(adbProvider) + task.remoteDir.set(remoteDirProvider) + } + testTask.dependsOn(writeConfigTaskProvider) + + 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) + } + testTask.finalizedBy(pullScreenshotsTask) + updateTaskProvider.dependsOn(pullScreenshotsTask) } - } } } 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 deleted file mode 100644 index 7e6f16b..0000000 --- a/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/PullScreenshotsTask.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.dropbox.dropshots - -import javax.inject.Inject -import java.io.ByteArrayOutputStream -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 - - @get:Input - public abstract val screenshotDir: 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" - outputs.upToDateWhen { false } - } - - @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)) - } - } - } -} - 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/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/device/DeviceProviderFactory.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/device/DeviceProviderFactory.kt new file mode 100644 index 0000000..e3f16cf --- /dev/null +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/device/DeviceProviderFactory.kt @@ -0,0 +1,30 @@ +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 { + @Suppress("DEPRECATION") + @get:Internal + internal var deviceProvider: DeviceProvider? = null + + @Suppress("DEPRECATION") + 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/tasks/GenerateReferenceScreenshotsTask.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/GenerateReferenceScreenshotsTask.kt new file mode 100644 index 0000000..416a757 --- /dev/null +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/GenerateReferenceScreenshotsTask.kt @@ -0,0 +1,31 @@ +package com.dropbox.dropshots.tasks + +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/tasks/PullScreenshotsTask.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/PullScreenshotsTask.kt new file mode 100644 index 0000000..3f7873a --- /dev/null +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/PullScreenshotsTask.kt @@ -0,0 +1,54 @@ +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 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 + +public abstract class PullScreenshotsTask : DefaultTask() { + + @get:Input + public abstract val adbExecutable: Property + + @get:Input + public abstract val remoteDir: Property + + @get:OutputDirectory + public abstract val outputDirectory: DirectoryProperty + + init { + description = "Pull screenshots from the test device." + group = "verification" + outputs.upToDateWhen { false } + } + + @TaskAction + public fun pullScreenshots() { + 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/tasks/UpdateScreenshotsTask.kt b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/UpdateScreenshotsTask.kt new file mode 100644 index 0000000..df3d3c3 --- /dev/null +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/UpdateScreenshotsTask.kt @@ -0,0 +1,71 @@ +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 +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:Input + public abstract val deviceProviderName: Property + + @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() + val logger = Logging.getLogger(UpdateScreenshotsTask::class.java) + + Files.list(from).forEach { devicePath -> + val deviceName = deviceProviderName.get() + logger.lifecycle("Copying reference images for $deviceName") + + 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( + outputPath, + followLinks = true, + overwrite = true, + ) + } + } +} 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 new file mode 100644 index 0000000..52c0b6c --- /dev/null +++ b/dropshots-gradle-plugin/src/main/kotlin/com/dropbox/dropshots/tasks/WriteConfigFileTask.kt @@ -0,0 +1,76 @@ +package com.dropbox.dropshots.tasks + +import com.android.build.gradle.internal.LoggerWrapper +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 + +@DisableCachingByDefault(because = "Not worth caching") +public abstract class WriteConfigFileTask : DefaultTask() { + + @get:Input + public abstract val recordingScreenshots: Property + + @get:Input + public abstract val deviceProviderName: Property + + @get:Input + public abstract val adbExecutable: Property + + @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 } + } + + @TaskAction + public fun performAction() { + val iLogger = LoggerWrapper(logger) + val deviceProvider = deviceProviderFactory + .getDeviceProvider(adbExecutable, iLogger) + + 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(recordingScreenshots.get(), deviceProviderName.get()) + 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 directories $remotePath") + executeShellCommand("mkdir -p $remotePath", loggingReceiver) + + val configFile = "$remotePath/$configFileName" + logger.warn("DeviceConnector '$deviceName': writing config TestRunConfig(isRecording=${config.isRecording}, deviceName=${config.deviceName}) to $configFile") + executeShellCommand( + "echo '${config.write()}' > $configFile", + loggingReceiver, + ) + } + } + } +} 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 0000000..87b3b45 Binary files /dev/null and b/dropshots-gradle-plugin/src/test/projects/connected-device/src/androidTest/screenshots/connected/basicActivityView.png differ diff --git a/dropshots-gradle-plugin/src/test/projects/connected-device/src/androidTest/screenshots/connected/views/colors/purple.png b/dropshots-gradle-plugin/src/test/projects/connected-device/src/androidTest/screenshots/connected/views/colors/purple.png new file mode 100644 index 0000000..4e397f9 Binary files /dev/null and b/dropshots-gradle-plugin/src/test/projects/connected-device/src/androidTest/screenshots/connected/views/colors/purple.png differ diff --git a/dropshots-gradle-plugin/src/test/projects/connected-device/src/androidTest/screenshots/connected/views/colors/red.png b/dropshots-gradle-plugin/src/test/projects/connected-device/src/androidTest/screenshots/connected/views/colors/red.png new file mode 100644 index 0000000..4a88bd1 Binary files /dev/null and b/dropshots-gradle-plugin/src/test/projects/connected-device/src/androidTest/screenshots/connected/views/colors/red.png differ 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 0000000..a07c69f Binary files /dev/null and b/dropshots-gradle-plugin/src/test/projects/connected-device/src/main/res/drawable/icon.png differ diff --git a/dropshots-gradle-plugin/src/test/projects/connected-device/src/main/res/layout/acivity_main.xml b/dropshots-gradle-plugin/src/test/projects/connected-device/src/main/res/layout/acivity_main.xml new file mode 100644 index 0000000..0b46d58 --- /dev/null +++ b/dropshots-gradle-plugin/src/test/projects/connected-device/src/main/res/layout/acivity_main.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + 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/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/dropshots/build.gradle.kts b/dropshots/build.gradle.kts index 0e59338..3b71a64 100644 --- a/dropshots/build.gradle.kts +++ b/dropshots/build.gradle.kts @@ -1,5 +1,5 @@ +import com.android.build.gradle.internal.tasks.factory.dependsOn import com.vanniktech.maven.publish.AndroidSingleVariantLibrary -import java.io.ByteArrayOutputStream import java.util.Locale plugins { @@ -8,6 +8,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 { @@ -21,13 +27,6 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = "1.8" - } } kotlin { @@ -36,6 +35,7 @@ kotlin { dependencies { api(libs.differ) + api(projects.model) implementation(libs.androidx.annotation) implementation(libs.androidx.test.runner) @@ -65,35 +65,9 @@ mavenPublishing { 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" @@ -130,56 +104,7 @@ android.testVariants.all { } } - 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 905aca7..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 @@ -25,9 +26,7 @@ class CustomImageComparatorTest { @get:Rule val dropshots = Dropshots( - rootScreenshotDirectory = imageDirectory, 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 c1de654..4858bde 100644 --- a/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/DropshotsTest.kt +++ b/dropshots/src/androidTest/kotlin/com/dropbox/dropshots/DropshotsTest.kt @@ -3,10 +3,12 @@ package com.dropbox.dropshots import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color -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.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 @@ -25,14 +27,12 @@ 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 val dropshots = Dropshots( filenameFunc = filenameFunc, - recordScreenshots = isRecordingScreenshots, resultValidator = fakeValidator, imageComparator = SimpleImageComparator( maxDistance = 0.004f, @@ -43,19 +43,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 @@ -83,31 +83,80 @@ class DropshotsTest { } @Test - fun testWritesReferenceImageForMissingImages() { + fun testWritesReferenceImageForMissingImagesWhenRecording() { + val dropshots = Dropshots( + testStorage = testStorage, + filenameFunc = filenameFunc, + testRunConfig = TestRunConfig(isRecording = true, deviceName = "test"), + resultValidator = { false }, + imageComparator = SimpleImageComparator(), + ) + + activityScenarioRule.scenario.onActivity { + dropshots.assertSnapshot(it, "not-an-image") + } + + with(File(testStorage.outputDir, "dropshots/reference")) { + assertTrue(exists()) + assertArrayEquals(arrayOf("not-an-image.png"), list()) + } + } + + @Test + fun testWritesReferenceImageForMissingImagesWhenNotRecording() { val dropshots = Dropshots( - rootScreenshotDirectory = imageDirectory, + testStorage = testStorage, filenameFunc = filenameFunc, - recordScreenshots = true, + testRunConfig = TestRunConfig(isRecording = false, deviceName = "test"), resultValidator = { false }, imageComparator = SimpleImageComparator(), ) activityScenarioRule.scenario.onActivity { - dropshots.assertSnapshot(it, "MatchesViewScreenshotBad") + 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(imageDirectory, "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 testWritesDiffImageOnFailureWhenRecording() { val dropshots = Dropshots( - rootScreenshotDirectory = imageDirectory, + testStorage = testStorage, + filenameFunc = filenameFunc, + 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, - recordScreenshots = false, + testRunConfig = TestRunConfig(isRecording = false, deviceName = "test"), resultValidator = { false }, imageComparator = SimpleImageComparator(), ) @@ -125,9 +174,9 @@ class DropshotsTest { } } - with(File(imageDirectory, "diff")) { + with(File(testStorage.outputDir, "dropshots/diff")) { assertTrue(exists()) - assertArrayEquals(arrayOf(File(this, "MatchesViewScreenshot.png")), listFiles()) + assertArrayEquals(arrayOf("MatchesViewScreenshot.png"), list()) } } @@ -135,9 +184,9 @@ class DropshotsTest { fun testFailsForDifferences() { val dropshots = Dropshots( resultValidator = CountValidator(0), - rootScreenshotDirectory = imageDirectory, + testStorage = testStorage, filenameFunc = filenameFunc, - recordScreenshots = false, + testRunConfig = TestRunConfig(isRecording = false, deviceName = "test"), imageComparator = SimpleImageComparator(), ) @@ -161,9 +210,9 @@ class DropshotsTest { fun testPassesWhenValidatorPasses() { val dropshots = Dropshots( resultValidator = FakeResultValidator { true }, - rootScreenshotDirectory = imageDirectory, + testStorage = testStorage, filenameFunc = filenameFunc, - recordScreenshots = false, + testRunConfig = TestRunConfig(isRecording = false, deviceName = "test"), imageComparator = SimpleImageComparator(), ) @@ -185,9 +234,9 @@ class DropshotsTest { fun testFailsWhenValidatorFails() { val dropshots = Dropshots( resultValidator = FakeResultValidator { false }, - rootScreenshotDirectory = imageDirectory, + testStorage = testStorage, filenameFunc = filenameFunc, - recordScreenshots = false, + testRunConfig = TestRunConfig(isRecording = false, deviceName = "test"), imageComparator = SimpleImageComparator(), ) @@ -216,9 +265,9 @@ class DropshotsTest { fun fastFailsForMismatchedSize() { val dropshots = Dropshots( resultValidator = CountValidator(0), - rootScreenshotDirectory = imageDirectory, + testStorage = testStorage, filenameFunc = filenameFunc, - recordScreenshots = false, + testRunConfig = TestRunConfig(isRecording = false, deviceName = "test"), imageComparator = SimpleImageComparator(), ) @@ -233,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/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/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 0000000..2dda90b Binary files /dev/null and b/dropshots/src/androidTest/screenshots/connected/MatchesViewScreenshot.png differ diff --git a/dropshots/src/main/java/com/dropbox/dropshots/Dropshots.kt b/dropshots/src/main/java/com/dropbox/dropshots/Dropshots.kt index abdfa05..9d3ceb1 100644 --- a/dropshots/src/main/java/com/dropbox/dropshots/Dropshots.kt +++ b/dropshots/src/main/java/com/dropbox/dropshots/Dropshots.kt @@ -1,32 +1,49 @@ 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.util.Base64 +import android.util.Log import android.view.View import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.rule.GrantPermissionRule +import androidx.test.platform.io.PlatformTestStorage +import androidx.test.platform.io.PlatformTestStorageRegistry 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 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(), + + /** + * 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,41 +53,20 @@ 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 = "") 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 +// } } /** @@ -121,18 +117,19 @@ public class Dropshots internal 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) { 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`.", + "Failed to find reference image file at $referencePath. " + + "If this is a new test, you may need to record screenshots by running the `updateDropshotsScreenshots` gradle task.", e ) } @@ -142,13 +139,12 @@ public class Dropshots internal constructor( if (bitmap.width != reference.width || bitmap.height != reference.height) { writeReferenceImage(filename, filePath, bitmap) + writeDiffImage(filename, filePath, bitmap, reference, null) - if (!recordScreenshots) { - val outputPath = writeDiffImage(filename, filePath, bitmap, reference, null) + if (!testRunConfig.isRecording) { 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})." ) } } @@ -158,13 +154,12 @@ public class Dropshots internal constructor( imageComparator.compare(BitmapImage(reference), BitmapImage(bitmap), mask) } catch (e: IllegalArgumentException) { writeReferenceImage(filename, filePath, bitmap) + writeDiffImage(filename, filePath, bitmap, reference, mask) - if (!recordScreenshots) { - val outputPath = writeDiffImage(filename, filePath, bitmap, reference, mask) + if (!testRunConfig.isRecording) { 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, ) } @@ -175,13 +170,12 @@ public class Dropshots internal constructor( // Assert if (!resultValidator(result)) { writeReferenceImage(filename, filePath, bitmap) + writeDiffImage(filename, filePath, bitmap, reference, mask) - if (!recordScreenshots) { - val outputPath = writeDiffImage(filename, filePath, bitmap, reference, mask) + if (!testRunConfig.isRecording) { 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 +185,32 @@ 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(path("reference", filePath, 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(path("diff", filePath, 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) { + 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) } - return file.absolutePath } /** @@ -279,9 +268,21 @@ 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(): Boolean { + return loadConfig().isRecording +} + +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) + .use { inputStream -> + requireNotNull(inputStream) + TestRunConfig.read(inputStream) + } } /** @@ -305,6 +306,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/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 003edcb..f130f36 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,9 +1,15 @@ [versions] -kotlin = "2.0.21" 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" } +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" } @@ -19,6 +25,10 @@ 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-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" } @@ -26,7 +36,8 @@ 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" } +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/api/model.api b/model/api/model.api new file mode 100644 index 0000000..e5b3d20 --- /dev/null +++ b/model/api/model.api @@ -0,0 +1,36 @@ +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 + 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/lang/String; +} + +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 read (Ljava/lang/String;)Lcom/dropbox/dropshots/model/TestRunConfig; + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + diff --git a/model/build.gradle.kts b/model/build.gradle.kts new file mode 100644 index 0000000..df67e1c --- /dev/null +++ b/model/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlinx.serialization) + 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) +} 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 { + explicitApi() +} + +dependencies { + implementation(libs.kotlinx.serialization) +} 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..f714605 --- /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 = "dropshots-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 new file mode 100644 index 0000000..8f59c3d --- /dev/null +++ b/model/src/main/kotlin/com/dropbox/dropshots/model/TestRunConfig.kt @@ -0,0 +1,27 @@ +package com.dropbox.dropshots.model + +import java.io.InputStream +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +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 + * 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(data: InputStream): TestRunConfig = Json.decodeFromStream(data) + public fun read(data: String): TestRunConfig = Json.decodeFromString(data) + } + + public fun write(): String = Json.encodeToString(this) +} + diff --git a/sample/app/src/androidTest/screenshots/basicActivityView.png b/sample/app/src/androidTest/screenshots/basicActivityView.png deleted file mode 100644 index 99237de..0000000 Binary files a/sample/app/src/androidTest/screenshots/basicActivityView.png and /dev/null differ 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 0000000..87b3b45 Binary files /dev/null and b/sample/app/src/androidTest/screenshots/connected/basicActivityView.png differ diff --git a/sample/app/src/androidTest/screenshots/connected/views/colors/purple.png b/sample/app/src/androidTest/screenshots/connected/views/colors/purple.png new file mode 100644 index 0000000..4e397f9 Binary files /dev/null and b/sample/app/src/androidTest/screenshots/connected/views/colors/purple.png differ diff --git a/sample/app/src/androidTest/screenshots/connected/views/colors/red.png b/sample/app/src/androidTest/screenshots/connected/views/colors/red.png new file mode 100644 index 0000000..4a88bd1 Binary files /dev/null and b/sample/app/src/androidTest/screenshots/connected/views/colors/red.png differ 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 b89b1fd..0000000 Binary files a/sample/app/src/androidTest/screenshots/views/colors/purple.png and /dev/null differ 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 f813245..0000000 Binary files a/sample/app/src/androidTest/screenshots/views/colors/red.png and /dev/null differ 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")) } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 4cab8bb..5b06960 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,12 +1,39 @@ pluginManagement { + includeBuild("gradle/build-logic") + 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() } } +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + rootProject.name = "dropshots-root" include(":dropshots-gradle-plugin") include(":dropshots") +include(":model")