Skip to content

Commit

Permalink
Merge pull request #19 from allure-framework/feature/allure-android
Browse files Browse the repository at this point in the history
Allure Android implementation
  • Loading branch information
viclovsky authored Jul 9, 2020
2 parents 1bf318a + 5ad72f0 commit 0f8c10f
Show file tree
Hide file tree
Showing 17 changed files with 495 additions and 27 deletions.
2 changes: 2 additions & 0 deletions allure-kotlin-android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,6 @@ dependencies {
implementation(kotlin("stdlib-jdk7", Versions.kotlin))
implementation("androidx.test.ext:junit:${Versions.Android.Test.junit}")
implementation("androidx.test:runner:${Versions.Android.Test.runner}")
implementation("androidx.multidex:multidex:${Versions.Android.multiDex}")
implementation("androidx.test.uiautomator:uiautomator:${Versions.Android.Test.uiAutomator}")
}
3 changes: 1 addition & 2 deletions allure-kotlin-android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="io.qameta.allure.android" />
<manifest package="io.qameta.allure.android" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package io.qameta.allure.android

import android.os.Environment
import io.qameta.allure.kotlin.AllureLifecycle
import io.qameta.allure.kotlin.FileSystemResultsWriter
import io.qameta.allure.kotlin.util.PropertiesUtils
import java.io.File

object AllureAndroidLifecycle : AllureLifecycle(writer = FileSystemResultsWriter(obtainResultsDirectory()))

/**
* Obtains results directory as a [File] reference.
* It takes into consideration the results directory specified via **allure.results.directory** property.
*/
private fun obtainResultsDirectory(): File {
val defaultAllurePath = PropertiesUtils.resultsDirectoryPath
return File(Environment.getExternalStorageDirectory(), defaultAllurePath)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package io.qameta.allure.android

import android.util.Log
import androidx.annotation.IntRange
import androidx.test.uiautomator.UiDevice
import io.qameta.allure.android.internal.createTemporaryFile
import io.qameta.allure.android.internal.uiDevice
import io.qameta.allure.kotlin.Allure
import java.io.InputStream

private const val TAG = "AllureScreenshot"

/**
* Takes a screenshot of a device in a given [quality] & [scale], after that attaches it to current step or test run.
* Quality must be in range [0..100] or an exception will be thrown.
*
* It uses [androidx.test.uiautomator.UiDevice] to take the screenshot.
*
* @return true if screen shot is created and attached successfully, false otherwise
*/
@Suppress("unused")
fun allureScreenshot(
name: String = "screenshot",
@IntRange(from = 0, to = 100) quality: Int = 90,
scale: Float = 1.0f
): Boolean {
require(quality in (0..100)) { "quality must be 0..100" }
val uiDevice = uiDevice ?: return false.also {
Log.e(TAG, "UiAutomation is unavailable. Can't take the screenshot")
}
val inputStream = uiDevice.takeScreenshot(scale, quality) ?: return false.also {
Log.e(TAG, "Failed to take the screenshot")
}
Allure.attachment(name = name, content = inputStream, type = "image/png", fileExtension = ".png")
return true
}

private fun UiDevice.takeScreenshot(scale: Float, quality: Int): InputStream? {
val tempFile = createTemporaryFile(prefix = "screenshot")
if (!takeScreenshot(tempFile, scale, quality)) return null
return tempFile.inputStream()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package io.qameta.allure.android.internal

import android.annotation.SuppressLint
import android.os.Build
import android.util.Log
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.runner.permission.PermissionRequester
import androidx.test.uiautomator.UiDevice
import java.io.File

/**
* Gives information about the environment in which the tests are running:
* - @return true for Android device (real device or emulator)
* - @return false for emulated environment of Robolectric
*
* This is the same logic as present in [androidx.test.ext.junit.runners.AndroidJUnit4] for resolving class runner.
*/
@SuppressLint("DefaultLocale")
internal fun isDeviceTest(): Boolean =
System.getProperty("java.runtime.name")?.toLowerCase()?.contains("android") ?: false

internal fun requestExternalStoragePermissions() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return

with(PermissionRequester()) {
addPermissions("android.permission.WRITE_EXTERNAL_STORAGE")
addPermissions("android.permission.READ_EXTERNAL_STORAGE")
requestPermissions()
}
}

/**
* Retrieves [UiDevice] if it's available, otherwise null is returned.
* In Robolectric tests [UiDevice] is inaccessible and this property serves as a safe way of accessing it.
*/
internal val uiDevice: UiDevice?
get() = runCatching { UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) }
.onFailure { Log.e("UiDevice", "UiDevice unavailable") }
.getOrNull()

internal fun createTemporaryFile(prefix: String = "temp", suffix: String? = null): File {
val cacheDir = InstrumentationRegistry.getInstrumentation().targetContext.cacheDir
return createTempFile(prefix, suffix, cacheDir)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package io.qameta.allure.android.listeners

import android.util.Log
import io.qameta.allure.kotlin.listener.ContainerLifecycleListener
import io.qameta.allure.kotlin.listener.StepLifecycleListener
import io.qameta.allure.kotlin.listener.TestLifecycleListener
import io.qameta.allure.kotlin.model.StepResult
import io.qameta.allure.kotlin.model.TestResult
import io.qameta.allure.kotlin.model.TestResultContainer

class LogcatListener :
ContainerLifecycleListener by LogcatContainerListener(),
TestLifecycleListener by LogcatTestLifecycleListener(),
StepLifecycleListener by LogcatStepLifecycleListener()

class LogcatContainerListener : ContainerLifecycleListener {
override fun beforeContainerStart(container: TestResultContainer) {
container.log("START")
}

override fun beforeContainerUpdate(container: TestResultContainer) {
container.log("UPDATE")
}

override fun beforeContainerStop(container: TestResultContainer) {
container.log("STOP")
}

override fun beforeContainerWrite(container: TestResultContainer) {
container.log("WRITE")
}

private fun TestResultContainer.log(action: String) {
Log.d(TAG, "$action container: $uuid")
}

companion object {
private const val TAG = "LogcatContainerListener"
}
}

class LogcatTestLifecycleListener : TestLifecycleListener {

override fun beforeTestSchedule(result: TestResult) {
result.log("SCHEDULE")
}

override fun beforeTestStart(result: TestResult) {
result.log("START")
}

override fun beforeTestUpdate(result: TestResult) {
result.log("UPDATE")
}

override fun beforeTestStop(result: TestResult) {
result.log("STOP")
}

override fun beforeTestWrite(result: TestResult) {
result.log("WRITE")
}

private fun TestResult.log(action: String) {
Log.d(TAG, "$action test: $uuid ($fullName)")
}

companion object {
private const val TAG = "LogcatTestLifecycle"
}
}

class LogcatStepLifecycleListener : StepLifecycleListener {

override fun beforeStepStart(result: StepResult) {
result.log("START")
}

override fun beforeStepUpdate(result: StepResult) {
result.log("UPDATE")
}

override fun beforeStepStop(result: StepResult) {
result.log("STOP")
}

private fun StepResult.log(action: String) {
Log.d(TAG, "$action step: $name")
}

companion object {
private const val TAG = "LogcatStepLifecycle"
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package io.qameta.allure.android.rules

import android.os.Build
import android.util.Log
import androidx.test.uiautomator.UiDevice
import io.qameta.allure.android.internal.uiDevice
import io.qameta.allure.kotlin.Allure
import org.junit.rules.*
import org.junit.runner.*
import org.junit.runners.model.*

/**
* Clears logcat before each test and dumps the logcat as an attachment after test failure.
*
* Available since API 21, no effect for other versions.
*/
class LogcatRule(private val fileName: String = "logcat-dump") : TestRule {

override fun apply(base: Statement?, description: Description?): Statement = object : Statement() {
override fun evaluate() {
try {
clear()
base?.evaluate()
} catch (throwable: Throwable) {
dump()
throw throwable
}
}
}

private fun clear() {
val uiDevice = uiDevice ?: return Unit.also {
Log.e(TAG, "UiDevice is unavailable. Clearing logs failed.")
}
uiDevice.executeShellCommandSafely("logcat -c")
}

private fun dump() {
val uiDevice = uiDevice ?: return Unit.also {
Log.e(TAG, "UiDevice is unavailable. Dumping logs failed.")
}
val output = uiDevice.executeShellCommandSafely("logcat -d") ?: return
Allure.attachment(
name = fileName,
content = output,
type = "text/plain",
fileExtension = ".txt"
)
}

private fun UiDevice.executeShellCommandSafely(cmd: String): String? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return null
return executeShellCommand(cmd)
}

companion object {
private val TAG = LogcatRule::class.java.simpleName
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package io.qameta.allure.android.rules

import io.qameta.allure.android.allureScreenshot
import org.junit.rules.*
import org.junit.runner.Description
import org.junit.runners.model.*
import kotlin.Result

/**
* Makes screenshot of a device upon an end of a test case based on the specified [mode].
* It is then added as an attachment of a test case (with name [screenshotName]).
*
* By default, it will take a screenshot at the end of every test case (whether it failed or finished successfully).
*/
class ScreenshotRule(
private val mode: Mode = Mode.END,
private val screenshotName: String = "end-screenshot"
) : TestRule {

override fun apply(base: Statement?, description: Description?): Statement = object : Statement() {
override fun evaluate() {
with(runCatching { base?.evaluate() }) {
if (canTakeScreenshot(mode)) {
allureScreenshot(screenshotName)
}
getOrThrow()
}
}
}

private fun Result<*>.canTakeScreenshot(mode: Mode): Boolean =
when (mode) {
Mode.END -> true
Mode.SUCCESS -> isSuccess
Mode.FAILURE -> isFailure
}

enum class Mode {
END,
SUCCESS,
FAILURE
}

}

Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package io.qameta.allure.android.rules

import android.util.Log
import androidx.test.uiautomator.UiDevice
import io.qameta.allure.android.internal.uiDevice
import io.qameta.allure.kotlin.Allure
import org.junit.rules.*
import org.junit.runner.*
import org.junit.runners.model.*
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.concurrent.TimeUnit

/**
* Dumps window hierarchy when the test fails and adds it as an attachment to allure results.
*/
class WindowHierarchyRule(private val fileName: String = "window-hierarchy") : TestRule {

override fun apply(base: Statement?, description: Description?): Statement = object : Statement() {
override fun evaluate() {
runCatching { base?.evaluate() }
.onFailure { dumpWindowHierarchy() }
.getOrThrow()
}
}

private fun dumpWindowHierarchy() {
val uiDevice = uiDevice ?: return Unit.also {
Log.e(TAG, "UiAutomation is unavailable. Dumping window hierarchy failed.")
}

Allure.attachment(
name = fileName,
type = "text/xml",
fileExtension = ".xml",
content = uiDevice.dumpWindowHierarchy()
)
}

private fun UiDevice.dumpWindowHierarchy(): ByteArrayInputStream =
ByteArrayOutputStream()
.apply {
waitForIdle(TimeUnit.SECONDS.toMillis(5))
dumpWindowHierarchy(this)
}
.toByteArray()
.inputStream()

companion object {
private val TAG = WindowHierarchyRule::class.java.simpleName
}

}
Loading

0 comments on commit 0f8c10f

Please sign in to comment.