All Downloads are FREE. Search and download functionalities are using the official Maven repository.

ru.tinkoff.testops.droidherd.DroidherdClient.kt Maven / Gradle / Ivy

Go to download

A library that parallelizes Android Test execution to all connected devices and emulators

There is a newer version: 1.0.0
Show newest version
package ru.tinkoff.testops.droidherd

import org.slf4j.Logger
import org.slf4j.LoggerFactory
import ru.tinkoff.testops.droidherd.api.*
import ru.tinkoff.testops.droidherd.impl.DroidherdApiProvider
import java.io.BufferedReader
import java.time.Duration
import java.util.concurrent.TimeUnit
import kotlin.math.min
import kotlin.system.measureTimeMillis

class DroidherdClient(
    private val adbPath: String,
    private val droidherdApi: DroidherdApiProvider,
    val config: DroidherdConfig
) {
    private val connectedEmulatorsIds = mutableSetOf()
    private val logger: Logger = LoggerFactory.getLogger(DroidherdClient::class.java)
    private var normalizedMinimumRequiredEmulator = config.minimumRequiredEmulators
    private var normalizedRequiredEmulators = config.emulators
    private var emulatorsPendingTime: Long = -1
    private val emulatorParameters = config.emulatorParameters.map {
        EmulatorParameter().apply {
            name = it.key
            value = it.value
        }
    }.toList()
    private val isDebug: Boolean = System.getenv("DROIDHERD_DEBUG") == "true"
    private val releaseSessionShutdownHook = object : Thread() {
        override fun run() {
            logger.info("Abnormal exit, release session")
            releaseEmulators()
        }
    }
    private val implementationVersion: String = javaClass.`package`.implementationVersion ?: "UNKNOWN"
    private var pinger: DroidherdPinger = DroidherdPinger(droidherdApi)

    @Volatile
    private var loginSuccess = false

    fun run(testCasesCount: Int) {
        val emulators = prepareEmulators(testCasesCount)
        connectToEmulators(emulators)
        checkMinimumEmulatorsRequired()
    }

    fun connectedEmulatorsCount() = connectedEmulatorsIds.size

    private fun checkMinimumEmulatorsRequired() {
        if (connectedEmulatorsIds.size < normalizedMinimumRequiredEmulator) {
            releaseEmulators()
            throw Exception("not enough emulators to run build")
        }
    }

    fun releaseEmulators() {
        if ("true" == System.getenv("FORK_NO_RELEASE_EMULATORS")) {
            logger.error("FORK_NO_RELEASE_EMULATORS = true, skip release")
            return
        }
        if (loginSuccess) {
            logger.info("Release session")
            runCatching { pinger.finish() }
            runCatching { Runtime.getRuntime().removeShutdownHook(releaseSessionShutdownHook) }
            runCatching {
                droidherdApi.release()
            }.onFailure {
                logger.warn("Failed to release session", it)
            }
            val builder = ProcessBuilder(adbPath, "disconnect")
            val result = builder.start().waitFor()
            logger.info("adb disconnect result code: $result")
        }
    }

    fun postMetrics(metrics: DroidherdClientMetricCollector) {
        metrics.add(
            DroidherdClientMetricCollector.Key.PendingEmulatorsDurationMs,
            emulatorsPendingTime.toDouble())
        droidherdApi.postMetrics(metrics.all)
    }

    private fun prepareEmulators(testCasesCount: Int): Collection {
        emulatorsPendingTime = -1
        connectedEmulatorsIds.clear()

        try {
            logger.info("Logging to droidherd ...")
            droidherdApi.login()
        } catch (e: Exception) {
            logger.error("Login to farm failed", e)
            throw RuntimeException("Login to farm failed: ${e.message}", e)
        }

        loginSuccess = true
        Runtime.getRuntime().addShutdownHook(releaseSessionShutdownHook)
        pinger = DroidherdPinger(droidherdApi).apply {
            start()
        }

        requestEmulators(testCasesCount)

        val emulators: Collection
        emulatorsPendingTime = measureTimeMillis {
            emulators = waitRequestedEmulators()
        }
        return emulators
    }

    private fun waitRequestedEmulators(): Collection {
        logger.info("Wait for emulators to scale")
        Thread.sleep(TimeUnit.MINUTES.toMillis(1))

        val fourMinutes = TimeUnit.MINUTES.toMillis(4)
        var total = fourMinutes
        val frequency = TimeUnit.SECONDS.toMillis(15)
        var emulators: Collection = listOf()
        while (total != TimeUnit.SECONDS.toMillis(0)) {
            if (total != fourMinutes) {
                Thread.sleep(frequency)
            }
            emulators = droidherdApi.status().emulators
            if (emulators.size == totalRequiredEmulators()) {
                break
            } else {
                total -= frequency
            }
        }
        return emulators
    }

    private fun requestEmulators(testCasesCount: Int) {
        normalizeEmulators(testCasesCount)
        val requests = normalizedRequiredEmulators.map { emulator ->
            EmulatorRequest(emulator.key, emulator.value)
        }
        logger.info("Requesting emulators: {}", requests)
        val result = droidherdApi.request(
            SessionRequest(
                generateClientAttributes(),
                requests,
                emulatorParameters,
                isDebug
            )
        )
        logger.info("Request emulators result: $result")

        val providedEmulators = result.sumOf { it.quantity }
        if (providedEmulators < normalizedMinimumRequiredEmulator) {
            throw IllegalStateException("No emulators available at all. Requested minimum: $normalizedMinimumRequiredEmulator, available: $providedEmulators")
        }
    }

    private fun normalizeEmulators(testCasesCount: Int) {
        if (config.emulators.size == 1 && config.emulators.values.first() > testCasesCount) {
            normalizedRequiredEmulators = config.emulators.mapValues { testCasesCount }
            normalizedMinimumRequiredEmulator = min(config.minimumRequiredEmulators, testCasesCount)
        } else {
            normalizedRequiredEmulators = config.emulators
            normalizedMinimumRequiredEmulator = config.minimumRequiredEmulators
        }
    }

    private fun totalRequiredEmulators(): Int = normalizedRequiredEmulators.map { it.value }.sum()

    private fun connectToEmulators(emulators: Collection) {
        println("Tries to connect emulators: $emulators")
        emulators.forEach { emulator ->
            connectEmulator(emulator.id, emulator.adb)
        }
        logger.info("Connection finished")
    }

    private fun connectEmulator(id: String, ip: String) {
        val maxAttempts = 5
        val waitPeriod = Duration.ofSeconds(5)
        var attempts = 0
        do {
            logger.info("${attempts.plus(1)} attempt to connect emulator $ip")
            if (executeAdbConnectIsSuccessful(ip)) {
                val adbDevicesResult = executeAdbDevices()
                adbDevicesResult.forEach { line -> logger.info(line as String) }
                if (adbDevicesResult.contains("$ip\tdevice")) {
                    logger.info("Emulator $ip connected")
                    connectedEmulatorsIds.add(id)
                    break
                } else {
                    Thread.sleep(waitPeriod.toMillis())
                    attempts += 1
                }
            }
        } while (attempts <= maxAttempts)
    }

    private fun executeAdbConnectIsSuccessful(ip: String): Boolean {
        val builder = ProcessBuilder(adbPath, "connect", ip)
        logger.info("adb connect $ip")
        val process = builder.start()
        val returnCode = process.waitFor()
        if (returnCode != 0) {
            logger.error("adb connect command finished with $returnCode")
            return false
        }
        return true
    }

    private fun executeAdbDevices(): Array {
        val builder = ProcessBuilder(adbPath, "devices")
        builder.redirectErrorStream(true)
        val process = builder.start()
        val inputStream = process.inputStream
        val reader = BufferedReader(inputStream.reader())
        return reader.lines().toArray()
    }


    private fun generateCiAttributes(): CiAttributes? {
        val envMap = System.getenv()
        val isCiRun = "true".equals(envMap.getOrDefault("GITLAB_CI", "false"), true) ||
                "true".equals(envMap.getOrDefault("GITLAB_CI_LIKE_META", "false"), true)
        return if (isCiRun) {
            CiAttributes(
                "gitlab",
                envMap.getOrDefault("CI_COMMIT_REF_NAME", "unknown"),
                envMap.getOrDefault("CI_PROJECT_NAME", "unknown"),
                envMap.getOrDefault("CI_JOB_URL", ""),
                envMap.getOrDefault("GITLAB_USER_LOGIN", "unknown")
            )
        } else {
            null
        }
    }

    private fun generateClientAttributes(): ClientAttributes {
        return ClientAttributes().apply {
            info = "fork-${config.clientType}"
            version = implementationVersion
            ci = generateCiAttributes()
            metadata = mapOf()
        }
    }
}





© 2015 - 2025 Weber Informatics LLC | Privacy Policy