Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 18 additions & 34 deletions Plugins/Gradle/src/main/kotlin/dev/testify/TestifyExtension.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@

package dev.testify

import dev.testify.internal.VariantPackageIdStore
import dev.testify.internal.android
import dev.testify.internal.applicationTargetPackageId
import dev.testify.internal.inferredAndroidTestInstallTask
import dev.testify.internal.inferredDefaultTestVariantId
import dev.testify.internal.inferredInstallTask
import org.gradle.api.GradleException
import org.gradle.api.Project
import org.gradle.api.provider.Provider

internal data class TestifySettings(

Expand All @@ -56,24 +56,24 @@ internal data class TestifySettings(
val useTestStorage: Boolean,

/**
* The package ID for the test APK
* The package ID for the test APK (resolved at execution time via onVariants API when inferred).
*
* For a typical application, testify requires two APKs: the target apk under test,
* and a test apk containing your tests.
*
* e.g. com.testify.example.test
*/
val testPackageId: String,
val testPackageIdProvider: Provider<String>,

/**
* The package ID for the APK under test
* The package ID for the APK under test (resolved at execution time via onVariants API when inferred).
*
* For a typical application, testify requires two APKs: the target apk under test,
* and a test apk containing your tests.
*
* e.g. com.testify.example
*/
val targetPackageId: String,
val targetPackageIdProvider: Provider<String>,
val installTask: String?,
val installAndroidTestTask: String?,
val autoImplementLibrary: Boolean = true,
Expand Down Expand Up @@ -118,8 +118,12 @@ internal data class TestifySettings(
?: "src/androidTest/assets"
val testRunner = extension.testRunner ?: android.defaultConfig.testInstrumentationRunner ?: "unknown"
val pullWaitTime = extension.pullWaitTime ?: 0L
val testPackageId = extension.testPackageId ?: project.inferredDefaultTestVariantId
val targetPackageId = extension.applicationPackageId ?: project.inferredTargetPackageId
val testPackageIdProvider = extension.testPackageId?.let { project.provider { it } }
?: VariantPackageIdStore.getTestPackageIdProvider(project)
?: project.provider { "" }
val targetPackageIdProvider = extension.applicationPackageId?.let { project.provider { it } }
?: VariantPackageIdStore.getApplicationPackageIdProvider(project)
?: project.provider { "" }
val version = TestifySettings::class.java.getPackage().implementationVersion
val isSnapshot = version?.contains("SNAPSHOT", ignoreCase = true) ?: false
val autoImplementLibrary = extension.autoImplementLibrary ?: !isSnapshot
Expand All @@ -142,8 +146,8 @@ internal data class TestifySettings(
testRunner = testRunner,
useSdCard = useSdCard,
useTestStorage = useTestStorage,
testPackageId = testPackageId,
targetPackageId = targetPackageId,
testPackageIdProvider = testPackageIdProvider,
targetPackageIdProvider = targetPackageIdProvider,
installTask = installTask,
installAndroidTestTask = installAndroidTestTask,
autoImplementLibrary = autoImplementLibrary,
Expand All @@ -155,10 +159,11 @@ internal data class TestifySettings(
}
}

fun validate() {
fun validate(project: Project) {
val extension = project.getTestifyExtension()
val (propertyName, examplePackage) = when {
targetPackageId.isEmpty() -> "applicationPackageId" to "com.example.app"
testPackageId.isEmpty() -> "testPackageId" to "com.example.app.test"
extension.applicationPackageId?.isEmpty() == true -> "applicationPackageId" to "com.example.app"
extension.testPackageId?.isEmpty() == true -> "testPackageId" to "com.example.app.test"
else -> null to null
}

Expand All @@ -176,27 +181,6 @@ internal data class TestifySettings(
}
}

/**
* Infer the package ID for the app under test.
*
* For an application this is usually just the applicationId e.g. com.testify.example
* However, the app under test may have been modified by an applicationIdSuffix (e.g. .debug).
* Or, it may not be an app at all and could be a library project. In this case, you must specify
* the `applicationPackageId` in your `testify` extension block.
*/
private val Project.inferredTargetPackageId: String
get() {
var targetPackageId: String? = this.applicationTargetPackageId

// If we still do not have a targetPackageId, it is likely a library project
// Infer the package from the test configuration
if (targetPackageId.isNullOrEmpty()) {
targetPackageId = this.inferredDefaultTestVariantId
}

return targetPackageId
}

open class TestifyExtension {

var baselineSourceDir: String? = null
Expand Down
6 changes: 4 additions & 2 deletions Plugins/Gradle/src/main/kotlin/dev/testify/TestifyPlugin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ package dev.testify

import dev.testify.TestifyPlugin.Companion.EVALUATED_SETTINGS
import dev.testify.internal.Adb
import dev.testify.internal.VariantPackageIdStore
import dev.testify.internal.Style.Description
import dev.testify.internal.android
import dev.testify.internal.isVerbose
Expand Down Expand Up @@ -61,6 +62,7 @@ class TestifyPlugin : Plugin<Project> {
with(project) {
styledTextOutput = project.serviceOf<StyledTextOutputFactory>().create("testifyOutput")
extensions.create(TestifyExtension.NAME, TestifyExtension::class.java)
VariantPackageIdStore.register(project)
createTasks()
afterEvaluate(AfterEvaluate)
}
Expand All @@ -69,7 +71,7 @@ class TestifyPlugin : Plugin<Project> {
private object AfterEvaluate : Action<Project> {
override fun execute(project: Project) {
val settings = TestifySettings.create(project)
settings.validate()
settings.validate(project)
project.extensions.add(EVALUATED_SETTINGS, settings)

project.addManifestPlaceholders(settings)
Expand All @@ -94,7 +96,7 @@ class TestifyPlugin : Plugin<Project> {
val module = settings.moduleName
val isRecordMode = settings.isRecordMode.toString()
val parallelThreads = settings.parallelThreads.toString()
android.defaultConfig {
android.defaultConfig.apply {
resValue("string", "testifyDestination", destination)
resValue("string", "testifyModule", module)
resValue("string", "isRecordMode", isRecordMode)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,48 @@ import dev.testify.testifySettings
import org.gradle.api.Project
import java.io.File

internal val Project.root: String
internal fun Project.root(targetPackageId: String): String =
computeRoot(targetPackageId, testifySettings.useSdCard, testifySettings.rootDestinationDirectory)

internal fun Project.screenshotDirectory(targetPackageId: String): String =
computeScreenshotDirectory(targetPackageId, testifySettings.useSdCard, testifySettings.rootDestinationDirectory)

/**
* Pure functions for path computation - safe to call at task execution time without Project.
* Used for configuration cache compatibility.
*/
internal fun computeRoot(
targetPackageId: String,
useSdCard: Boolean,
rootDestinationDirectory: String?
): String {
@Suppress("SdCardPath")
get() = testifySettings.rootDestinationDirectory ?: if (testifySettings.useSdCard) {
"/sdcard/Android/data/${testifySettings.targetPackageId}/files/testify_"
return rootDestinationDirectory ?: if (useSdCard) {
"/sdcard/Android/data/$targetPackageId/files/testify_"
} else {
"./app_"
}
}

internal val Project.screenshotDirectory: String
get() = if (testifySettings.useSdCard) {
"${root}images/"
internal fun computeScreenshotDirectory(
targetPackageId: String,
useSdCard: Boolean,
rootDestinationDirectory: String?
): String {
val r = computeRoot(targetPackageId, useSdCard, rootDestinationDirectory)
return if (useSdCard) {
"${r}images/"
} else {
"${root}images/$SCREENSHOT_DIR"
"${r}images/$SCREENSHOT_DIR"
}
}

internal fun computeReportFilePath(
targetPackageId: String,
useSdCard: Boolean,
rootDestinationDirectory: String?
): String =
computeRoot(targetPackageId, useSdCard, rootDestinationDirectory).replace("testify_", "") + "testify"

internal fun Adb.listFiles(path: String): List<String> {
val log = this
Expand Down Expand Up @@ -87,8 +115,8 @@ internal fun listFailedScreenshots(
return files.map { it.replace(src, dst) }
}

internal val Project.reportFilePath: String
get() = "${root.replace("testify_", "")}testify"
internal fun Project.reportFilePath(targetPackageId: String): String =
computeReportFilePath(targetPackageId, testifySettings.useSdCard, testifySettings.rootDestinationDirectory)

internal fun File.deleteOnDevice(targetPackageId: String) {
Adb()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,34 +59,3 @@ val Project.inferredAndroidTestInstallTask: String?
return installTasks.firstOrNull()
}

val Project.inferredDefaultTestVariantId: String
get() {
return this.applicationTargetPackageId?.let { "$it.test" } ?: ""
}

val Project.applicationTargetPackageId: String?
get() {
val appExtension = this.extensions.findByType(ApplicationExtension::class.java) ?: return null
return try {
val baseApplicationId = appExtension.defaultConfig.applicationId ?: return null

// 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()

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
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
* The MIT License (MIT)
*
* Modified work copyright (c) 2022-2024 ndtp
* Original work copyright (c) 2019 Shopify Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and the associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the requirements of the following condition:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING WARRANTIES OF CONDITIONS OF ANY KIND, EITHER EXPRESS OR
* IMPLIED, INCLUDING THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
* PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
* BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
* CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

package dev.testify.internal

import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import com.android.build.api.variant.ApplicationVariant
import com.android.build.api.variant.DeviceTestBuilder
import com.android.build.api.variant.LibraryAndroidComponentsExtension
import com.android.build.api.variant.LibraryVariant
import org.gradle.api.Project
import org.gradle.api.provider.Provider

/**
* Collects package IDs from the onVariants API for execution-time resolution.
*
* Original behavior: first debug app variant (sorted by name) for applicationId,
* first test variant (sorted by testedVariant.flavorName) for test package ID.
* We use selector().withBuildType("debug") and collect all debug variants,
* then at execution time pick first by name (app) and first by flavorName (test).
*/
internal object VariantPackageIdStore {

private val appPackageIds = mutableMapOf<String, MutableList<Pair<String, Provider<String>>>>()
private val testPackageIds = mutableMapOf<String, MutableList<Pair<String, Provider<String>>>>()

fun register(project: Project) {
val projectPath = project.path
val appComponents = project.extensions.findByType(ApplicationAndroidComponentsExtension::class.java)
val libComponents = project.extensions.findByType(LibraryAndroidComponentsExtension::class.java)

appComponents?.let { components ->
appPackageIds[projectPath] = mutableListOf()
testPackageIds[projectPath] = mutableListOf()
components.onVariants(components.selector().withBuildType("debug")) { variant ->
val appVariant = variant as ApplicationVariant
appPackageIds[projectPath]!!.add(variant.name to appVariant.applicationId)
val androidTest = appVariant.deviceTests[DeviceTestBuilder.ANDROID_TEST_TYPE]
if (androidTest != null) {
val flavorKey = appVariant.flavorName ?: ""
testPackageIds[projectPath]!!.add(flavorKey to androidTest.applicationId)
}
}
}

libComponents?.let { components ->
if (!testPackageIds.containsKey(projectPath)) {
testPackageIds[projectPath] = mutableListOf()
}
components.onVariants(components.selector().withBuildType("debug")) { variant ->
val libVariant = variant as LibraryVariant
val androidTest = libVariant.deviceTests[DeviceTestBuilder.ANDROID_TEST_TYPE]
if (androidTest != null) {
val flavorKey = libVariant.flavorName ?: ""
testPackageIds[projectPath]!!.add(flavorKey to androidTest.applicationId)
}
}
}
}

fun getApplicationPackageIdProvider(project: Project): Provider<String>? {
val entries = appPackageIds[project.path] ?: return null
return project.provider {
entries.minByOrNull { it.first }?.second?.get() ?: ""
}
}

fun getTestPackageIdProvider(project: Project): Provider<String>? {
val entries = testPackageIds[project.path] ?: return null
return project.provider {
entries.minByOrNull { it.first }?.second?.get() ?: ""
}
}
}
Loading