diff --git a/Plugins/Gradle/gradle/libs.versions.toml b/Plugins/Gradle/gradle/libs.versions.toml index d4642876..b1d87ff6 100644 --- a/Plugins/Gradle/gradle/libs.versions.toml +++ b/Plugins/Gradle/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] -gradle = "7.4.2" +gradle = "8.13.2" junitJupiter = "5.8.2" -kotlin = "1.9.24" +kotlin = "2.2.0" mockk = "1.13.16" truthVersion = "1.2.0" diff --git a/Plugins/Gradle/settings.gradle b/Plugins/Gradle/settings.gradle index 6c41f6ef..d50ca2dc 100644 --- a/Plugins/Gradle/settings.gradle +++ b/Plugins/Gradle/settings.gradle @@ -9,7 +9,7 @@ pluginManagement { } } plugins { - id 'org.jetbrains.kotlin.jvm' version '1.9.24' + id 'org.jetbrains.kotlin.jvm' version '2.2.0' id 'org.jetbrains.dokka' version '1.8.10' } } diff --git a/Plugins/Gradle/src/main/kotlin/dev/testify/TestifyExtension.kt b/Plugins/Gradle/src/main/kotlin/dev/testify/TestifyExtension.kt index e952dde4..2d15863b 100644 --- a/Plugins/Gradle/src/main/kotlin/dev/testify/TestifyExtension.kt +++ b/Plugins/Gradle/src/main/kotlin/dev/testify/TestifyExtension.kt @@ -113,8 +113,9 @@ internal data class TestifySettings( val android = project.android val extension = project.getTestifyExtension() - val assetsSet = android.sourceSets.getByName("""androidTest""").assets - val baselineSourceDir = extension.baselineSourceDir ?: assetsSet.srcDirs.first().path + val baselineSourceDir = extension.baselineSourceDir + ?: project.android.sourceSets.findByName("androidTest")?.assets?.directories?.firstOrNull() + ?: "src/androidTest/assets" val testRunner = extension.testRunner ?: android.defaultConfig.testInstrumentationRunner ?: "unknown" val pullWaitTime = extension.pullWaitTime ?: 0L val testPackageId = extension.testPackageId ?: project.inferredDefaultTestVariantId diff --git a/Plugins/Gradle/src/main/kotlin/dev/testify/TestifyPlugin.kt b/Plugins/Gradle/src/main/kotlin/dev/testify/TestifyPlugin.kt index bf72d053..f5e48428 100644 --- a/Plugins/Gradle/src/main/kotlin/dev/testify/TestifyPlugin.kt +++ b/Plugins/Gradle/src/main/kotlin/dev/testify/TestifyPlugin.kt @@ -95,10 +95,10 @@ class TestifyPlugin : Plugin { val isRecordMode = settings.isRecordMode.toString() val parallelThreads = settings.parallelThreads.toString() android.defaultConfig { - it.resValue("string", "testifyDestination", destination) - it.resValue("string", "testifyModule", module) - it.resValue("string", "isRecordMode", isRecordMode) - it.resValue("string", "parallelThreads", parallelThreads) + resValue("string", "testifyDestination", destination) + resValue("string", "testifyModule", module) + resValue("string", "isRecordMode", isRecordMode) + resValue("string", "parallelThreads", parallelThreads) } } diff --git a/Plugins/Gradle/src/main/kotlin/dev/testify/internal/Adb.kt b/Plugins/Gradle/src/main/kotlin/dev/testify/internal/Adb.kt index fd7d0e4d..7d8199a9 100644 --- a/Plugins/Gradle/src/main/kotlin/dev/testify/internal/Adb.kt +++ b/Plugins/Gradle/src/main/kotlin/dev/testify/internal/Adb.kt @@ -25,6 +25,8 @@ package dev.testify.internal +import com.android.build.api.variant.ApplicationAndroidComponentsExtension +import com.android.build.api.variant.LibraryAndroidComponentsExtension import dev.testify.internal.StreamData.BufferedStream import dev.testify.internal.Style.Description import org.gradle.api.GradleException @@ -121,18 +123,29 @@ class Adb { } companion object { - private lateinit var adbPath: String + private var adbPathProvider: (() -> String)? = null private var verbose: Boolean = false var forcedUser: Int? = null private var deviceTargetIndex: Int = 0 fun init(project: Project) { - adbPath = project.android.adbExecutable.absolutePath - ?: throw GradleException("adb not found. Have you defined an `android` block?") + adbPathProvider = { + val androidComponents = project.extensions.findByType( + ApplicationAndroidComponentsExtension::class.java + ) ?: project.extensions.findByType( + LibraryAndroidComponentsExtension::class.java + ) + androidComponents?.sdkComponents?.adb?.get()?.asFile?.absolutePath + ?: throw GradleException("adb not found via androidComponents.sdkComponents") + } deviceTargetIndex = (project.properties["device"] as? String)?.toInt() ?: 0 verbose = project.isVerbose forcedUser = project.user } + + private val adbPath: String + get() = adbPathProvider?.invoke() + ?: throw GradleException("Adb.init() must be called before using Adb") } } diff --git a/Plugins/Gradle/src/main/kotlin/dev/testify/internal/GradleProjectExtensions.kt b/Plugins/Gradle/src/main/kotlin/dev/testify/internal/GradleProjectExtensions.kt index 1b6aed0f..fe43c5d8 100644 --- a/Plugins/Gradle/src/main/kotlin/dev/testify/internal/GradleProjectExtensions.kt +++ b/Plugins/Gradle/src/main/kotlin/dev/testify/internal/GradleProjectExtensions.kt @@ -25,13 +25,15 @@ package dev.testify.internal -import com.android.build.gradle.AppExtension -import com.android.build.gradle.TestedExtension +import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.dsl.CommonExtension +import com.android.build.api.dsl.LibraryExtension import org.gradle.api.GradleException import org.gradle.api.Project -val Project.android: TestedExtension - get() = this.properties["android"] as? TestedExtension +val Project.android: CommonExtension<*, *, *, *, *, *> + get() = this.extensions.findByType(ApplicationExtension::class.java) + ?: this.extensions.findByType(LibraryExtension::class.java) ?: throw GradleException("Gradle project must contain an `android` closure") val Project.isVerbose: Boolean @@ -59,30 +61,32 @@ val Project.inferredAndroidTestInstallTask: String? val Project.inferredDefaultTestVariantId: String get() { - val testVariant = this.android.testVariants.sortedBy { it.testedVariant.flavorName }.firstOrNull() - return try { - testVariant?.applicationId - } catch (e: Throwable) { - this.applicationTargetPackageId?.let { "$it.test" } ?: "" - } ?: "" + return this.applicationTargetPackageId?.let { "$it.test" } ?: "" } val Project.applicationTargetPackageId: String? get() { - var targetPackageId: String? = null + val appExtension = this.extensions.findByType(ApplicationExtension::class.java) ?: return null + return try { + val baseApplicationId = appExtension.defaultConfig.applicationId ?: return null - // Prefer the debug variant - if (this.android is AppExtension) { - val appExtension = this.android as AppExtension - val allDebugVariants = appExtension.applicationVariants.filter { - it.name == "debug" || it.name.endsWith("Debug") - }.sortedBy { it.name } - targetPackageId = allDebugVariants.firstOrNull()?.applicationId - } + // Prefer debug build type suffix (most common for testing), fall back to any build type + val debugBuildType = appExtension.buildTypes.findByName("debug") + val buildType = debugBuildType ?: appExtension.buildTypes.firstOrNull() - // For apks without a debug variant, use the default applicationId - if (targetPackageId.isNullOrEmpty()) { - targetPackageId = this.android.defaultConfig.applicationId + val suffix = buildType?.applicationIdSuffix + if (suffix != null && suffix.isNotEmpty()) { + // Remove leading dot if present, then append with dot + val cleanSuffix = suffix.removePrefix(".") + "$baseApplicationId.$cleanSuffix" + } else { + baseApplicationId + } + } catch (e: Throwable) { + try { + appExtension.defaultConfig.applicationId + } catch (e2: Throwable) { + null + } } - return targetPackageId } diff --git a/Plugins/Gradle/src/main/kotlin/dev/testify/tasks/main/ScreenshotPullTask.kt b/Plugins/Gradle/src/main/kotlin/dev/testify/tasks/main/ScreenshotPullTask.kt index 08698492..95dba4e1 100644 --- a/Plugins/Gradle/src/main/kotlin/dev/testify/tasks/main/ScreenshotPullTask.kt +++ b/Plugins/Gradle/src/main/kotlin/dev/testify/tasks/main/ScreenshotPullTask.kt @@ -102,13 +102,23 @@ open class ScreenshotPullTask : TestifyDefaultTask() { private fun String.toLocalPath(): String { val src = screenshotDirectory val dst = destinationImageDirectory + val dstFile = if (File(dst).isAbsolute) { + File(dst) + } else { + File(project.projectDir, dst) + } val key = this.removePrefix("$src/").replace('/', File.separatorChar) - return "$dst${File.separatorChar}$SCREENSHOT_DIR${File.separatorChar}$key" + return File(dstFile, "$SCREENSHOT_DIR${File.separatorChar}$key").path } private fun pullScreenshots() { val dst = destinationImageDirectory - File(dst).assurePath() + val dstFile = if (File(dst).isAbsolute) { + File(dst) + } else { + File(project.projectDir, dst) + } + dstFile.assurePath() val failedScreenshots = listFailedScreenshotsWithPath( src = screenshotDirectory, diff --git a/Plugins/Gradle/src/main/kotlin/dev/testify/tasks/main/ScreenshotRecordTask.kt b/Plugins/Gradle/src/main/kotlin/dev/testify/tasks/main/ScreenshotRecordTask.kt index 951a803b..9ef335d9 100644 --- a/Plugins/Gradle/src/main/kotlin/dev/testify/tasks/main/ScreenshotRecordTask.kt +++ b/Plugins/Gradle/src/main/kotlin/dev/testify/tasks/main/ScreenshotRecordTask.kt @@ -56,8 +56,10 @@ open class ScreenshotRecordTask : TestifyDefaultTask() { ScreenshotTestTask.setDependencies(taskNameProvider, project) - screenshotClearTask.mustRunAfter(getInstallDebugAndroidTestTask(project)) - screenshotPullTask.mustRunAfter(getInstallDebugAndroidTestTask(project)) + getInstallDebugAndroidTestTask(project)?.let { installDebugAndroidTestTask -> + screenshotClearTask.mustRunAfter(installDebugAndroidTestTask) + screenshotPullTask.mustRunAfter(installDebugAndroidTestTask) + } } override fun taskName() = "screenshotRecord" diff --git a/Plugins/Gradle/src/test/kotlin/dev/testify/internal/AdbTest.kt b/Plugins/Gradle/src/test/kotlin/dev/testify/internal/AdbTest.kt index 4d4380ce..b57198fa 100644 --- a/Plugins/Gradle/src/test/kotlin/dev/testify/internal/AdbTest.kt +++ b/Plugins/Gradle/src/test/kotlin/dev/testify/internal/AdbTest.kt @@ -23,18 +23,20 @@ */ package dev.testify.internal -import com.android.build.gradle.TestedExtension +import com.android.build.api.dsl.SdkComponents +import com.android.build.api.variant.ApplicationAndroidComponentsExtension +import com.android.build.api.variant.LibraryAndroidComponentsExtension import com.google.common.truth.Truth.assertThat import dev.testify.test.BaseTest import io.mockk.every import io.mockk.impl.annotations.RelaxedMockK -import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.slot -import io.mockk.unmockkStatic import io.mockk.verify import org.gradle.api.GradleException import org.gradle.api.Project +import org.gradle.api.file.RegularFile +import org.gradle.api.provider.Provider import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows @@ -45,7 +47,19 @@ class AdbTest : BaseTest() { lateinit var project: Project @RelaxedMockK - lateinit var extension: TestedExtension + lateinit var extensions: org.gradle.api.plugins.ExtensionContainer + + @RelaxedMockK + lateinit var androidComponents: ApplicationAndroidComponentsExtension + + @RelaxedMockK + lateinit var sdkComponents: SdkComponents + + @RelaxedMockK + lateinit var adbProvider: Provider + + @RelaxedMockK + lateinit var regularFile: RegularFile @RelaxedMockK lateinit var adbExecutable: File @@ -64,17 +78,21 @@ class AdbTest : BaseTest() { override fun setUp() { super.setUp() - every { extension.adbExecutable } returns adbExecutable + every { project.extensions } returns extensions + every { extensions.findByType(ApplicationAndroidComponentsExtension::class.java) } returns androidComponents + every { extensions.findByType(LibraryAndroidComponentsExtension::class.java) } returns null + every { androidComponents.sdkComponents } returns sdkComponents + every { sdkComponents.adb } returns adbProvider + every { adbProvider.get() } returns regularFile + every { regularFile.asFile } returns adbExecutable + every { adbExecutable.absolutePath } returns "/usr/bin/adb" mockkStatic("dev.testify.internal.ClientUtilitiesKt") - mockkStatic(Project::android) mockkStatic(Project::isVerbose) mockkStatic(Project::user) mockkStatic(::println) mockkStatic(::runProcess) - mockkObject(Device) - every { any().android } returns extension every { any().isVerbose } returns false every { any().user } returns null every { println(any(), any()) } returns Unit @@ -97,9 +115,11 @@ class AdbTest : BaseTest() { @Test fun `WHEN init AND no android closure THEN throw exception`() { - unmockkStatic(Project::android) + every { extensions.findByType(ApplicationAndroidComponentsExtension::class.java) } returns null + every { extensions.findByType(LibraryAndroidComponentsExtension::class.java) } returns null + Adb.init(project) assertThrows { - Adb.init(project) + Adb().argument("test").execute() } } @@ -112,8 +132,9 @@ class AdbTest : BaseTest() { fun `WHEN init AND no adb path THEN throw exception`() { @Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS") every { adbExecutable.absolutePath } returns null + Adb.init(project) assertThrows { - Adb.init(project) + Adb().argument("test").execute() } }