From 6f3d4fbe92ee8a6f70f6e2efd18088b3fc4eff3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Santiago=20Turi=C3=B1o?= Date: Mon, 23 Feb 2026 11:43:42 +0100 Subject: [PATCH 1/3] Fix configuration cache compatibility --- .../AndroidSnaptestingPlugin.kt | 124 ++++++++++-------- .../androidsnaptesting/DeviceFileManager.kt | 32 ++--- 2 files changed, 84 insertions(+), 72 deletions(-) diff --git a/include-build/gradle-plugin/src/main/java/com/telefonica/androidsnaptesting/AndroidSnaptestingPlugin.kt b/include-build/gradle-plugin/src/main/java/com/telefonica/androidsnaptesting/AndroidSnaptestingPlugin.kt index 59a8dd9..164675a 100644 --- a/include-build/gradle-plugin/src/main/java/com/telefonica/androidsnaptesting/AndroidSnaptestingPlugin.kt +++ b/include-build/gradle-plugin/src/main/java/com/telefonica/androidsnaptesting/AndroidSnaptestingPlugin.kt @@ -1,18 +1,14 @@ package com.telefonica.androidsnaptesting -import com.android.build.gradle.internal.tasks.AndroidVariantTask +import com.android.build.gradle.TestedExtension import com.android.build.gradle.internal.tasks.DeviceProviderInstrumentTestTask import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.Task -import org.gradle.internal.build.event.BuildEventListenerRegistryInternal -import org.gradle.tooling.events.OperationCompletionListener +import org.gradle.api.provider.ProviderFactory import java.io.File -import javax.inject.Inject -class AndroidSnaptestingPlugin @Inject constructor( - private val buildEventListenerRegistry: BuildEventListenerRegistryInternal -) : Plugin { +class AndroidSnaptestingPlugin : Plugin { override fun apply(project: Project) { project.afterEvaluate { @@ -24,32 +20,76 @@ class AndroidSnaptestingPlugin @Inject constructor( throw AndroidSnaptestingNoDeviceProviderInstrumentTestTasksException() } - deviceProviderInstrumentTestTasks - .forEach { deviceProviderTask -> - val capitalizedVariant = deviceProviderTask.variantName.capitalizeFirstLetter() - val beforeTaskName = "androidSnaptestingBefore$capitalizedVariant" - project.tasks.register(beforeTaskName, Task::class.java) { task -> - task.doFirst { - deviceProviderTask.deviceFileManager().clearAllSnapshots() - } - } - deviceProviderTask.dependsOn(beforeTaskName) + val extension = project.extensions.findByType(TestedExtension::class.java) + ?: throw RuntimeException("TestedExtension not found") - val afterTaskName = "androidSnaptestingAfter$capitalizedVariant" - project.tasks.register(afterTaskName, Task::class.java) { task -> - task.doLast { - deviceProviderTask.afterExecution() - } - } - deviceProviderTask.onTaskCompleted { - deviceProviderTask.afterExecution() - } + val isRecordMode = project.properties["android.testInstrumentationRunnerArguments.record"] == "true" + val projectDir = project.projectDir + val providerFactory: ProviderFactory = project.providers + + deviceProviderInstrumentTestTasks.names.forEach { taskName -> + val deviceProviderTask = project.tasks.named( + taskName, + DeviceProviderInstrumentTestTask::class.java, + ).get() + val capitalizedVariant = deviceProviderTask.variantName.capitalizeFirstLetter() + + @Suppress("DEPRECATION") + val testedVariant = extension.testVariants + .firstOrNull { it.name == deviceProviderTask.variantName } + ?: throw RuntimeException("TestVariant not found for ${deviceProviderTask.variantName}") + val applicationIdProvider = providerFactory.provider { testedVariant.applicationId } + val adbExecutablePath = extension.adbExecutable.absolutePath + + val goldenSnapshotsSourcePath = run { + val variantSourceFolder = deviceProviderTask + .variantName + .replace("AndroidTest", "") + .capitalizeFirstLetter() + .let { "androidTest$it" } + "$projectDir/src/$variantSourceFolder/assets/android-snaptesting-golden-files" + } + + // Attach work directly on the deviceProviderTask (config-cache safe). + // Note: in Kotlin, doFirst/doLast lambdas receive the task as 'it', not 'this'. + deviceProviderTask.doFirst { + (it as DeviceProviderInstrumentTestTask) + .deviceFileManager(applicationIdProvider.get(), adbExecutablePath, providerFactory) + .clearAllSnapshots() + } + + deviceProviderTask.doLast { + (it as DeviceProviderInstrumentTestTask) + .afterExecution( + applicationId = applicationIdProvider.get(), + adbExecutablePath = adbExecutablePath, + providerFactory = providerFactory, + isRecordMode = isRecordMode, + goldenSnapshotsSourcePath = goldenSnapshotsSourcePath, + ) } + + // Keep empty before/after tasks as dependency anchors for CI scripts + // (e.g. ci.gradle.kts references these task names). + val beforeTaskName = "androidSnaptestingBefore$capitalizedVariant" + project.tasks.register(beforeTaskName, Task::class.java) + deviceProviderTask.dependsOn(beforeTaskName) + + val afterTaskName = "androidSnaptestingAfter$capitalizedVariant" + project.tasks.register(afterTaskName, Task::class.java) + deviceProviderTask.finalizedBy(afterTaskName) + } } } - private fun DeviceProviderInstrumentTestTask.afterExecution() { - val deviceFileManager = deviceFileManager() + private fun DeviceProviderInstrumentTestTask.afterExecution( + applicationId: String, + adbExecutablePath: String, + providerFactory: ProviderFactory, + isRecordMode: Boolean, + goldenSnapshotsSourcePath: String, + ) { + val deviceFileManager = deviceFileManager(applicationId, adbExecutablePath, providerFactory) val reportsFolder = reportsDir.get().dir("androidSnaptesting") val recordedFolderFile = reportsFolder.dir("recorded").asFile.apply { @@ -64,7 +104,7 @@ class AndroidSnaptestingPlugin @Inject constructor( val goldenForFailuresReportFolderFile = reportsFolder.dir("golden").asFile.apply { mkdirs() } - val goldenFolderFile = File(getAbsoluteGoldenSnapshotsSourcePath()) + val goldenFolderFile = File(goldenSnapshotsSourcePath) File("${reportsFolder.asFile.absolutePath}/recorded.html").apply { createNewFile() @@ -76,7 +116,7 @@ class AndroidSnaptestingPlugin @Inject constructor( writeText(report) } - if (project.properties["android.testInstrumentationRunnerArguments.record"] != "true") { + if (!isRecordMode) { File("${reportsFolder.asFile.absolutePath}/failures.html").apply { createNewFile() val failuresFiles = failuresFolderFile.listFiles()?.asList() ?: emptyList() @@ -99,35 +139,13 @@ class AndroidSnaptestingPlugin @Inject constructor( writeText(report) } } else { - File(getAbsoluteGoldenSnapshotsSourcePath()).apply { + File(goldenSnapshotsSourcePath).apply { mkdirs() recordedFolderFile.copyRecursively(this, true) } } } - private fun Task.onTaskCompleted(onCompleted: () -> Unit) { - buildEventListenerRegistry.onTaskCompletion( - project.provider { - OperationCompletionListener { - if (it.descriptor.name != path) { - return@OperationCompletionListener - } - onCompleted() - } - } - ) - } - - private fun AndroidVariantTask.getAbsoluteGoldenSnapshotsSourcePath(): String { - val variantSourceFolder = this - .variantName - .replace("AndroidTest", "") - .capitalizeFirstLetter() - .let { "androidTest$it" } - return "${project.projectDir}/src/$variantSourceFolder/assets/android-snaptesting-golden-files" - } - private fun String.capitalizeFirstLetter(): String { return replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } } diff --git a/include-build/gradle-plugin/src/main/java/com/telefonica/androidsnaptesting/DeviceFileManager.kt b/include-build/gradle-plugin/src/main/java/com/telefonica/androidsnaptesting/DeviceFileManager.kt index 8c09838..d784555 100644 --- a/include-build/gradle-plugin/src/main/java/com/telefonica/androidsnaptesting/DeviceFileManager.kt +++ b/include-build/gradle-plugin/src/main/java/com/telefonica/androidsnaptesting/DeviceFileManager.kt @@ -1,7 +1,5 @@ package com.telefonica.androidsnaptesting -import com.android.build.gradle.TestedExtension -import com.android.build.gradle.api.TestVariant import com.android.build.gradle.internal.tasks.DeviceProviderInstrumentTestTask import com.android.build.gradle.internal.testing.ConnectedDevice import com.android.ddmlib.CollectingOutputReceiver @@ -9,25 +7,21 @@ import com.android.ddmlib.FileListingService import com.android.ddmlib.FileListingService.FileEntry import com.android.ddmlib.IDevice import org.gradle.api.file.RegularFile +import org.gradle.api.provider.ProviderFactory import java.io.File -fun DeviceProviderInstrumentTestTask.deviceFileManager(): DeviceFileManager = - DeviceFileManager(this) +fun DeviceProviderInstrumentTestTask.deviceFileManager( + applicationId: String, + adbExecutablePath: String, + providerFactory: ProviderFactory, +): DeviceFileManager = DeviceFileManager(this, applicationId, adbExecutablePath, providerFactory) class DeviceFileManager( private val testTask: DeviceProviderInstrumentTestTask, + private val applicationId: String, + private val adbExecutablePath: String, + private val providerFactory: ProviderFactory, ) { - private val extension: TestedExtension = testTask - .project - .extensions - .findByType(TestedExtension::class.java) - ?: throw RuntimeException("TestedExtension not found") - - @Suppress("DEPRECATION") - private val testedVariant: TestVariant = extension - .testVariants - .firstOrNull { it.name == testTask.variantName } - ?: throw RuntimeException("TestVariant not found") fun pullRecordedSnapshots( destinationPath: String, @@ -61,15 +55,15 @@ class DeviceFileManager( } private fun getDeviceAndroidSnaptestingRootAbsolutePath(): String = - "${FileListingService.DIRECTORY_SDCARD}/Download/android-snaptesting/${testedVariant.applicationId}" + "${FileListingService.DIRECTORY_SDCARD}/Download/android-snaptesting/$applicationId" private fun getDeviceAndroidSnaptestingSubfolderAbsolutePath(subFolder: String): String = "${getDeviceAndroidSnaptestingRootAbsolutePath()}/$subFolder" @Suppress("UnstableApiUsage") private fun withConnectedDevices(runnable: (List) -> Unit) { testTask.deviceProviderFactory.getDeviceProvider( - testTask.project.provider { - RegularFile { File(extension.adbExecutable.absolutePath) } + providerFactory.provider { + RegularFile { File(adbExecutablePath) } }, System.getenv("ANDROID_SERIAL"), ).let { @@ -104,4 +98,4 @@ class DeviceFileManager( device.pullFile(it.fullPath, "$destinationPath/${it.name}") } } -} \ No newline at end of file +} From ee4537264c9f359658200fe2e21baa292c1dba39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Santiago=20Turi=C3=B1o?= Date: Mon, 23 Feb 2026 15:28:23 +0100 Subject: [PATCH 2/3] Fix post-processing --- .../AndroidSnaptestingPlugin.kt | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/include-build/gradle-plugin/src/main/java/com/telefonica/androidsnaptesting/AndroidSnaptestingPlugin.kt b/include-build/gradle-plugin/src/main/java/com/telefonica/androidsnaptesting/AndroidSnaptestingPlugin.kt index 164675a..85dbb23 100644 --- a/include-build/gradle-plugin/src/main/java/com/telefonica/androidsnaptesting/AndroidSnaptestingPlugin.kt +++ b/include-build/gradle-plugin/src/main/java/com/telefonica/androidsnaptesting/AndroidSnaptestingPlugin.kt @@ -50,7 +50,6 @@ class AndroidSnaptestingPlugin : Plugin { "$projectDir/src/$variantSourceFolder/assets/android-snaptesting-golden-files" } - // Attach work directly on the deviceProviderTask (config-cache safe). // Note: in Kotlin, doFirst/doLast lambdas receive the task as 'it', not 'this'. deviceProviderTask.doFirst { (it as DeviceProviderInstrumentTestTask) @@ -58,25 +57,26 @@ class AndroidSnaptestingPlugin : Plugin { .clearAllSnapshots() } - deviceProviderTask.doLast { - (it as DeviceProviderInstrumentTestTask) - .afterExecution( + // Before task as dependency anchor for CI scripts. + val beforeTaskName = "androidSnaptestingBefore$capitalizedVariant" + project.tasks.register(beforeTaskName, Task::class.java) + deviceProviderTask.dependsOn(beforeTaskName) + + // After task runs post-processing via finalizedBy, which guarantees + // execution even when the test task fails (needed to pull snapshot + // results and generate reports on failure). + val afterTaskName = "androidSnaptestingAfter$capitalizedVariant" + project.tasks.register(afterTaskName, Task::class.java) { task -> + task.doLast { + deviceProviderTask.afterExecution( applicationId = applicationIdProvider.get(), adbExecutablePath = adbExecutablePath, providerFactory = providerFactory, isRecordMode = isRecordMode, goldenSnapshotsSourcePath = goldenSnapshotsSourcePath, ) + } } - - // Keep empty before/after tasks as dependency anchors for CI scripts - // (e.g. ci.gradle.kts references these task names). - val beforeTaskName = "androidSnaptestingBefore$capitalizedVariant" - project.tasks.register(beforeTaskName, Task::class.java) - deviceProviderTask.dependsOn(beforeTaskName) - - val afterTaskName = "androidSnaptestingAfter$capitalizedVariant" - project.tasks.register(afterTaskName, Task::class.java) deviceProviderTask.finalizedBy(afterTaskName) } } From aeb1d5393fe089a486d2ec5840ed16200ce8fdaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Santiago=20Turi=C3=B1o?= Date: Tue, 24 Feb 2026 11:16:14 +0100 Subject: [PATCH 3/3] Fix reports generation while preserving configuration cache support --- .../AndroidSnaptestingPlugin.kt | 116 ++++++++++-------- .../androidsnaptesting/DeviceFileManager.kt | 6 +- 2 files changed, 71 insertions(+), 51 deletions(-) diff --git a/include-build/gradle-plugin/src/main/java/com/telefonica/androidsnaptesting/AndroidSnaptestingPlugin.kt b/include-build/gradle-plugin/src/main/java/com/telefonica/androidsnaptesting/AndroidSnaptestingPlugin.kt index 85dbb23..4d95713 100644 --- a/include-build/gradle-plugin/src/main/java/com/telefonica/androidsnaptesting/AndroidSnaptestingPlugin.kt +++ b/include-build/gradle-plugin/src/main/java/com/telefonica/androidsnaptesting/AndroidSnaptestingPlugin.kt @@ -5,6 +5,7 @@ import com.android.build.gradle.internal.tasks.DeviceProviderInstrumentTestTask import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.Task +import org.gradle.api.file.Directory import org.gradle.api.provider.ProviderFactory import java.io.File @@ -24,7 +25,6 @@ class AndroidSnaptestingPlugin : Plugin { ?: throw RuntimeException("TestedExtension not found") val isRecordMode = project.properties["android.testInstrumentationRunnerArguments.record"] == "true" - val projectDir = project.projectDir val providerFactory: ProviderFactory = project.providers deviceProviderInstrumentTestTasks.names.forEach { taskName -> @@ -32,66 +32,86 @@ class AndroidSnaptestingPlugin : Plugin { taskName, DeviceProviderInstrumentTestTask::class.java, ).get() - val capitalizedVariant = deviceProviderTask.variantName.capitalizeFirstLetter() - - @Suppress("DEPRECATION") - val testedVariant = extension.testVariants - .firstOrNull { it.name == deviceProviderTask.variantName } - ?: throw RuntimeException("TestVariant not found for ${deviceProviderTask.variantName}") - val applicationIdProvider = providerFactory.provider { testedVariant.applicationId } - val adbExecutablePath = extension.adbExecutable.absolutePath - - val goldenSnapshotsSourcePath = run { - val variantSourceFolder = deviceProviderTask - .variantName - .replace("AndroidTest", "") - .capitalizeFirstLetter() - .let { "androidTest$it" } - "$projectDir/src/$variantSourceFolder/assets/android-snaptesting-golden-files" - } + registerTasksForVariant(project, taskName, deviceProviderTask, extension, isRecordMode, providerFactory) + } + } + } - // Note: in Kotlin, doFirst/doLast lambdas receive the task as 'it', not 'this'. - deviceProviderTask.doFirst { - (it as DeviceProviderInstrumentTestTask) - .deviceFileManager(applicationIdProvider.get(), adbExecutablePath, providerFactory) - .clearAllSnapshots() - } + @Suppress("DEPRECATION") + private fun registerTasksForVariant( + project: Project, + taskName: String, + deviceProviderTask: DeviceProviderInstrumentTestTask, + extension: TestedExtension, + isRecordMode: Boolean, + providerFactory: ProviderFactory, + ) { + val capitalizedVariant = deviceProviderTask.variantName.capitalizeFirstLetter() + + val testedVariant = extension.testVariants + .firstOrNull { it.name == deviceProviderTask.variantName } + ?: throw RuntimeException("TestVariant not found for ${deviceProviderTask.variantName}") + val applicationIdProvider = providerFactory.provider { testedVariant.applicationId } + val adbExecutablePath = extension.adbExecutable.absolutePath + + val goldenSnapshotsSourcePath = run { + val variantSourceFolder = deviceProviderTask + .variantName + .replace("AndroidTest", "") + .capitalizeFirstLetter() + .let { "androidTest$it" } + "${project.projectDir}/src/$variantSourceFolder/assets/android-snaptesting-golden-files" + } - // Before task as dependency anchor for CI scripts. - val beforeTaskName = "androidSnaptestingBefore$capitalizedVariant" - project.tasks.register(beforeTaskName, Task::class.java) - deviceProviderTask.dependsOn(beforeTaskName) - - // After task runs post-processing via finalizedBy, which guarantees - // execution even when the test task fails (needed to pull snapshot - // results and generate reports on failure). - val afterTaskName = "androidSnaptestingAfter$capitalizedVariant" - project.tasks.register(afterTaskName, Task::class.java) { task -> - task.doLast { - deviceProviderTask.afterExecution( - applicationId = applicationIdProvider.get(), - adbExecutablePath = adbExecutablePath, - providerFactory = providerFactory, - isRecordMode = isRecordMode, - goldenSnapshotsSourcePath = goldenSnapshotsSourcePath, - ) - } - } - deviceProviderTask.finalizedBy(afterTaskName) + // Shared provider — used by both before and after tasks (config-cache safe: references task by name) + val deviceProviderFactoryProvider = project.tasks.named(taskName, DeviceProviderInstrumentTestTask::class.java) + .map { it.deviceProviderFactory } + + // Before task clears snapshots and serves as dependency anchor for CI scripts. + val beforeTaskName = "androidSnaptestingBefore$capitalizedVariant" + project.tasks.register(beforeTaskName, Task::class.java) { task -> + task.doFirst { + DeviceFileManager(deviceProviderFactoryProvider.get(), applicationIdProvider.get(), adbExecutablePath, providerFactory) + .clearAllSnapshots() + } + } + deviceProviderTask.dependsOn(beforeTaskName) + + // After task runs post-processing via finalizedBy, which guarantees + // execution even when the test task fails (needed to pull snapshot + // results and generate reports on failure). + val afterTaskName = "androidSnaptestingAfter$capitalizedVariant" + val reportsDirProvider = project.tasks.named(taskName, DeviceProviderInstrumentTestTask::class.java) + .flatMap { it.reportsDir } + + project.tasks.register(afterTaskName, Task::class.java) { task -> + task.doLast { + afterExecution( + deviceProviderFactory = deviceProviderFactoryProvider.get(), + reportsDir = reportsDirProvider.get(), + applicationId = applicationIdProvider.get(), + adbExecutablePath = adbExecutablePath, + providerFactory = providerFactory, + isRecordMode = isRecordMode, + goldenSnapshotsSourcePath = goldenSnapshotsSourcePath, + ) } } + deviceProviderTask.finalizedBy(afterTaskName) } - private fun DeviceProviderInstrumentTestTask.afterExecution( + private fun afterExecution( + deviceProviderFactory: DeviceProviderInstrumentTestTask.DeviceProviderFactory, + reportsDir: Directory, applicationId: String, adbExecutablePath: String, providerFactory: ProviderFactory, isRecordMode: Boolean, goldenSnapshotsSourcePath: String, ) { - val deviceFileManager = deviceFileManager(applicationId, adbExecutablePath, providerFactory) + val deviceFileManager = DeviceFileManager(deviceProviderFactory, applicationId, adbExecutablePath, providerFactory) - val reportsFolder = reportsDir.get().dir("androidSnaptesting") + val reportsFolder = reportsDir.dir("androidSnaptesting") val recordedFolderFile = reportsFolder.dir("recorded").asFile.apply { mkdirs() deviceFileManager.pullRecordedSnapshots(absolutePath) diff --git a/include-build/gradle-plugin/src/main/java/com/telefonica/androidsnaptesting/DeviceFileManager.kt b/include-build/gradle-plugin/src/main/java/com/telefonica/androidsnaptesting/DeviceFileManager.kt index d784555..aac1a7d 100644 --- a/include-build/gradle-plugin/src/main/java/com/telefonica/androidsnaptesting/DeviceFileManager.kt +++ b/include-build/gradle-plugin/src/main/java/com/telefonica/androidsnaptesting/DeviceFileManager.kt @@ -14,10 +14,10 @@ fun DeviceProviderInstrumentTestTask.deviceFileManager( applicationId: String, adbExecutablePath: String, providerFactory: ProviderFactory, -): DeviceFileManager = DeviceFileManager(this, applicationId, adbExecutablePath, providerFactory) +): DeviceFileManager = DeviceFileManager(this.deviceProviderFactory, applicationId, adbExecutablePath, providerFactory) class DeviceFileManager( - private val testTask: DeviceProviderInstrumentTestTask, + private val deviceProviderFactory: DeviceProviderInstrumentTestTask.DeviceProviderFactory, private val applicationId: String, private val adbExecutablePath: String, private val providerFactory: ProviderFactory, @@ -61,7 +61,7 @@ class DeviceFileManager( @Suppress("UnstableApiUsage") private fun withConnectedDevices(runnable: (List) -> Unit) { - testTask.deviceProviderFactory.getDeviceProvider( + deviceProviderFactory.getDeviceProvider( providerFactory.provider { RegularFile { File(adbExecutablePath) } },