From 4fc315c490bcd535748f512bc77c79e2b5c1bd01 Mon Sep 17 00:00:00 2001 From: Nikita Date: Wed, 23 Aug 2023 16:02:09 +0300 Subject: [PATCH] Issue 554 automatic artifacts pulling (#558) * ISSUE-554: Pulling artifacts after tests * ISSUE-554: Added sample * ISSUE-554: Updated doc * ISSUE-554: Lint * ISSUE-554: PR comments * ISSUE-554: Fix compose test --- .../AllureSupportKaspressoBuilder.kt | 4 +- docs/Wiki/Kaspresso_Robolectric.en.md | 19 +++++ docs/Wiki/Kaspresso_configuration.ru.md | 19 +++++ .../kaspresso/device/files/FilesImpl.kt | 1 + .../video/recorder/VideoRecordingThread.kt | 6 +- .../artifactspull/ArtifactsPullRunListener.kt | 48 ++++++++++++ .../kaspresso/kaspresso/Kaspresso.kt | 16 ++++ .../kaspresso/params/ArtifactsPullParams.kt | 18 +++++ samples/kaspresso-sample/build.gradle.kts | 2 +- .../artifacts_pulling/ArtifactsPullingTest.kt | 75 +++++++++++++++++++ .../compose_tests/ComplexComposeTest.kt | 2 +- 11 files changed, 205 insertions(+), 5 deletions(-) create mode 100644 kaspresso/src/main/kotlin/com/kaspersky/kaspresso/internal/runlisteners/artifactspull/ArtifactsPullRunListener.kt create mode 100644 kaspresso/src/main/kotlin/com/kaspersky/kaspresso/params/ArtifactsPullParams.kt create mode 100644 samples/kaspresso-sample/src/androidTest/kotlin/com/kaspersky/kaspressample/artifacts_pulling/ArtifactsPullingTest.kt diff --git a/allure-support/src/main/kotlin/com/kaspersky/components/alluresupport/AllureSupportKaspressoBuilder.kt b/allure-support/src/main/kotlin/com/kaspersky/components/alluresupport/AllureSupportKaspressoBuilder.kt index 03c1df43f..cbddf0e00 100644 --- a/allure-support/src/main/kotlin/com/kaspersky/components/alluresupport/AllureSupportKaspressoBuilder.kt +++ b/allure-support/src/main/kotlin/com/kaspersky/components/alluresupport/AllureSupportKaspressoBuilder.kt @@ -71,7 +71,7 @@ fun Kaspresso.Builder.Companion.withForcedAllureSupport( val instrumentalDependencyProvider = instrumentalDependencyProviderFactory.getComponentProvider(instrumentation) forceAllureSupportFileProviders(instrumentalDependencyProvider) addRunListenersIfNeeded(instrumentalDependencyProvider) -}.apply(::addAllureSupportInterceptors) +}.apply(::postInitAllure) private fun Kaspresso.Builder.forceAllureSupportFileProviders(provider: InstrumentalDependencyProvider) { resourcesDirNameProvider = DefaultResourcesDirNameProvider() @@ -107,7 +107,7 @@ private fun Kaspresso.Builder.addRunListenersIfNeeded(provider: InstrumentalDepe } } -private fun addAllureSupportInterceptors(builder: Kaspresso.Builder): Unit = with(builder) { +private fun postInitAllure(builder: Kaspresso.Builder): Unit = with(builder) { if (!isAndroidRuntime) { return@with } diff --git a/docs/Wiki/Kaspresso_Robolectric.en.md b/docs/Wiki/Kaspresso_Robolectric.en.md index 510bf86b0..91c850af0 100644 --- a/docs/Wiki/Kaspresso_Robolectric.en.md +++ b/docs/Wiki/Kaspresso_Robolectric.en.md @@ -135,3 +135,22 @@ As of Robolectric 4.8.1, there are some limitations to sharedTest: those tests r 1. Robolectric-Espresso supports Idling resources, but [doesn't support posting delayed messages to the Looper](https://github.com/robolectric/robolectric/issues/4807#issuecomment-1075863097) 2. Robolectric-Espresso will not support [tests that start new activities](https://github.com/robolectric/robolectric/issues/5104) (i.e. activity jumping) + +#### Pulling the artifacts from the device to the host + +Depending on your test configuration, useful artifacts may remain on the device after test finish: screenshots, reports, videos, etc. +In order to pull them off the device special scripts are programmed, which are executed after the completion of the test run on CI. With Kaspresso, +you can simplify this process. To do this, you need to configure the `artifactsPullParams` variable in the Kaspresso Builder. Example: + +```kotlin +class SomeTest : TestCase( + kaspressoBuilder = Kaspresso.Builder.simple { + artifactsPullParams = ArtifactsPullParams(enabled = true, destinationPath = "artifacts/", artifactsRegex = Regex("(screenshots)|(videos)")) + } +) { + ... +} +``` + +For this mechanism to work, you need to start the ADB server before running the test. After the test is completed, the artifacts will be located by the path specified in the `destinationPath` +argument relative to the working directory from which the ADB server was launched. \ No newline at end of file diff --git a/docs/Wiki/Kaspresso_configuration.ru.md b/docs/Wiki/Kaspresso_configuration.ru.md index 3b07698d9..c0db8a60a 100644 --- a/docs/Wiki/Kaspresso_configuration.ru.md +++ b/docs/Wiki/Kaspresso_configuration.ru.md @@ -315,3 +315,22 @@ class EnricherBaseTestCase : BaseTestCase( ``` После того, как это будет сделано, описанные вами действия будут выполняться до или после блока ```run``` основной секции. + +#### Стягивание артефактов с устройства на хост + +В зависимости от вашей конфигурации тестов, после выполнения последних на устройстве могут оставаться полезные артефакты: скриншоты, отчеты, видео и т.д. +Для того, чтобы стянуть их с устройства, как правило, программируют специальные скрипты, которые выполняют после завершения тестового прогона на CI. С Kaspresso +вы можете упростить этот процесс. Для этого в Kaspresso Builder'e необходимо сконфигурировать переменную `artifactsPullParams`. Пример: + +```kotlin +class SomeTest : TestCase( + kaspressoBuilder = Kaspresso.Builder.simple { + artifactsPullParams = ArtifactsPullParams(enabled = true, destinationPath = "artifacts/", artifactsRegex = Regex("(screenshots)|(videos)")) + } +) { + ... +} +``` + +Для работы этого механизма перед выполнением теста необходимо запустить ADB server. После завершения теста артефакты будут лежать по указанному в аргументе `destinationPath` пути относительно +рабочей директории, из которой был запущен ADB server. \ No newline at end of file diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/files/FilesImpl.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/files/FilesImpl.kt index d2c9009ab..96f560e2d 100644 --- a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/files/FilesImpl.kt +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/files/FilesImpl.kt @@ -45,6 +45,7 @@ class FilesImpl( * @param serverPath a path to copy. (If empty - pulls in adbServer directory (folder with file "adbserver-desktop.jar")) */ override fun pull(devicePath: String, serverPath: String) { + adbServer.performCmd("mkdir -p $serverPath") adbServer.performAdb("pull $devicePath $serverPath") logger.i("Pull file from $devicePath to $serverPath") } diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/video/recorder/VideoRecordingThread.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/video/recorder/VideoRecordingThread.kt index 95f7aa64a..1bb61cf6f 100644 --- a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/video/recorder/VideoRecordingThread.kt +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/video/recorder/VideoRecordingThread.kt @@ -2,8 +2,10 @@ package com.kaspersky.kaspresso.device.video.recorder import android.app.Instrumentation import android.content.Context +import android.hardware.display.DisplayManager import android.media.MediaCodecList import android.os.Build +import android.view.Display import android.view.WindowManager import androidx.annotation.RequiresApi import androidx.test.uiautomator.UiDevice @@ -49,7 +51,9 @@ class VideoRecordingThread( val codecWidth = videoCapabilities.supportedHeights.upper val display = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - instrumentation.targetContext.display!! + instrumentation.targetContext + .getSystemService(DisplayManager::class.java) + .getDisplay(Display.DEFAULT_DISPLAY) } else { (instrumentation.targetContext.getSystemService(Context.WINDOW_SERVICE) as WindowManager?)?.defaultDisplay!! } diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/internal/runlisteners/artifactspull/ArtifactsPullRunListener.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/internal/runlisteners/artifactspull/ArtifactsPullRunListener.kt new file mode 100644 index 000000000..5d36b2a55 --- /dev/null +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/internal/runlisteners/artifactspull/ArtifactsPullRunListener.kt @@ -0,0 +1,48 @@ +package com.kaspersky.kaspresso.internal.runlisteners.artifactspull + +import androidx.test.platform.app.InstrumentationRegistry +import com.kaspersky.kaspresso.device.files.Files +import com.kaspersky.kaspresso.files.dirs.DefaultDirsProvider +import com.kaspersky.kaspresso.files.dirs.DirsProvider +import com.kaspersky.kaspresso.instrumental.InstrumentalDependencyProviderFactory +import com.kaspersky.kaspresso.kaspresso.Kaspresso +import com.kaspersky.kaspresso.logger.Logger +import com.kaspersky.kaspresso.params.ArtifactsPullParams +import com.kaspersky.kaspresso.runner.listener.KaspressoRunListener +import org.junit.runner.Result +import java.io.File + +class ArtifactsPullRunListener( + private val params: ArtifactsPullParams, + private val dirsProvider: DirsProvider = DefaultDirsProvider(InstrumentalDependencyProviderFactory().getComponentProvider(InstrumentationRegistry.getInstrumentation())), + private val files: Files, + private val logger: Logger +) : KaspressoRunListener { + override fun testRunFinished(result: Result) { + if (!params.enabled) return + + val rootDir = dirsProvider.provideNew(File("")) + val filesInRootDir = rootDir.listFiles() + if (filesInRootDir.isNullOrEmpty()) { + logger.d("After test artifacts pulling abort: found no files to move") + return + } + + logger.d("After test artifacts pulling started. Root dir=${rootDir.absolutePath}; artifacts regex=${params.artifactsRegex}; destination path=${params.destinationPath}") + filesInRootDir.forEach { file -> + try { + if (file.name.matches(params.artifactsRegex)) { + val fullFilePath = File(rootDir, file.name) + files.pull( + devicePath = fullFilePath.absolutePath, + serverPath = params.destinationPath + ) + } + } catch (ex: Throwable) { + logger.e("Failed to move file $file due to exception") + logger.e(ex.stackTraceToString()) + } + } + logger.d("After test artifacts pulling finished") + } +} diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/kaspresso/Kaspresso.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/kaspresso/Kaspresso.kt index 9d92cda5b..ed894816b 100644 --- a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/kaspresso/Kaspresso.kt +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/kaspresso/Kaspresso.kt @@ -110,8 +110,10 @@ import com.kaspersky.kaspresso.interceptors.watcher.view.impl.logging.LoggingAto import com.kaspersky.kaspresso.interceptors.watcher.view.impl.logging.LoggingViewActionWatcherInterceptor import com.kaspersky.kaspresso.interceptors.watcher.view.impl.logging.LoggingViewAssertionWatcherInterceptor import com.kaspersky.kaspresso.interceptors.watcher.view.impl.logging.LoggingWebAssertionWatcherInterceptor +import com.kaspersky.kaspresso.internal.runlisteners.artifactspull.ArtifactsPullRunListener import com.kaspersky.kaspresso.logger.UiTestLogger import com.kaspersky.kaspresso.logger.UiTestLoggerImpl +import com.kaspersky.kaspresso.params.ArtifactsPullParams import com.kaspersky.kaspresso.params.AutoScrollParams import com.kaspersky.kaspresso.params.ClickParams import com.kaspersky.kaspresso.params.ContinuouslyParams @@ -122,6 +124,7 @@ import com.kaspersky.kaspresso.params.ScreenshotParams import com.kaspersky.kaspresso.params.StepParams import com.kaspersky.kaspresso.params.SystemDialogsSafetyParams import com.kaspersky.kaspresso.params.VideoParams +import com.kaspersky.kaspresso.runner.listener.addUniqueListener import com.kaspersky.kaspresso.testcases.core.testcontext.BaseTestContext import io.github.kakaocup.kakao.Kakao @@ -481,6 +484,12 @@ data class Kaspresso( * If it was not specified, the default implementation is used. */ lateinit var clickParams: ClickParams + + /** + * Holds the [ArtifactsPullParams]. + * If it was not specified, the default implementation is used. + */ + lateinit var artifactsPullParams: ArtifactsPullParams /** * Holds an implementation of [DirsProvider] interface. If it was not specified, the default implementation is used. */ @@ -744,6 +753,7 @@ data class Kaspresso( if (!::videoParams.isInitialized) videoParams = VideoParams() if (!::elementLoaderParams.isInitialized) elementLoaderParams = ElementLoaderParams() if (!::clickParams.isInitialized) clickParams = ClickParams.default() + if (!::artifactsPullParams.isInitialized) artifactsPullParams = ArtifactsPullParams(enabled = false) if (!::screenshots.isInitialized) { screenshots = ScreenshotsImpl( @@ -910,6 +920,12 @@ data class Kaspresso( TestRunLoggerWatcherInterceptor(libLogger), defaultsTestRunWatcherInterceptor ) + + if (artifactsPullParams.enabled) { + instrumentalDependencyProviderFactory.getComponentProvider(instrumentation).runNotifier.addUniqueListener { + ArtifactsPullRunListener(params = artifactsPullParams, files = files, logger = libLogger) + } + } } /** diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/params/ArtifactsPullParams.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/params/ArtifactsPullParams.kt new file mode 100644 index 000000000..c49fca18e --- /dev/null +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/params/ArtifactsPullParams.kt @@ -0,0 +1,18 @@ +package com.kaspersky.kaspresso.params + +data class ArtifactsPullParams( + /** + * Relative path. Absolute one depends on the working directory from which ADB server was started + */ + val destinationPath: String = ".", + + /** + * Artifacts would be pulled if it's name fits regex + */ + val artifactsRegex: Regex = "(screenshots)|(video)|(logcat)|(view_hierarchy)".toRegex(), + + /** + * Whether Kaspresso should pull the artifacts after a test run. Needs an ADB server to work + */ + val enabled: Boolean = true +) diff --git a/samples/kaspresso-sample/build.gradle.kts b/samples/kaspresso-sample/build.gradle.kts index 2bff75a57..a41e8c880 100644 --- a/samples/kaspresso-sample/build.gradle.kts +++ b/samples/kaspresso-sample/build.gradle.kts @@ -5,7 +5,7 @@ plugins { android { defaultConfig { applicationId = "com.kaspersky.kaspressample" - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunner = "com.kaspersky.kaspresso.runner.KaspressoRunner" testInstrumentationRunnerArguments["clearPackageData"] = "true" } diff --git a/samples/kaspresso-sample/src/androidTest/kotlin/com/kaspersky/kaspressample/artifacts_pulling/ArtifactsPullingTest.kt b/samples/kaspresso-sample/src/androidTest/kotlin/com/kaspersky/kaspressample/artifacts_pulling/ArtifactsPullingTest.kt new file mode 100644 index 000000000..20348a609 --- /dev/null +++ b/samples/kaspresso-sample/src/androidTest/kotlin/com/kaspersky/kaspressample/artifacts_pulling/ArtifactsPullingTest.kt @@ -0,0 +1,75 @@ +package com.kaspersky.kaspressample.artifacts_pulling + +import android.Manifest +import androidx.test.ext.junit.rules.activityScenarioRule +import androidx.test.rule.GrantPermissionRule +import com.kaspersky.kaspressample.MainActivity +import com.kaspersky.kaspressample.R +import com.kaspersky.kaspressample.screen.MainScreen +import com.kaspersky.kaspressample.screen.SimpleScreen +import com.kaspersky.kaspressample.simple_tests.CheckEditScenario +import com.kaspersky.kaspresso.kaspresso.Kaspresso +import com.kaspersky.kaspresso.params.ArtifactsPullParams +import com.kaspersky.kaspresso.testcases.api.testcase.TestCase +import org.junit.Rule +import org.junit.Test + +/** + * After test completes screenshots directory would be pulled into provided destination path ob the host machine + */ +class ArtifactsPullingTest : TestCase(kaspressoBuilder = Kaspresso.Builder.simple { + artifactsPullParams = ArtifactsPullParams(artifactsRegex = Regex("screenshots"), destinationPath = "../../output/artifacts") +}) { + + @get:Rule + val runtimePermissionRule: GrantPermissionRule = GrantPermissionRule.grant( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.READ_EXTERNAL_STORAGE + ) + + @get:Rule + val activityRule = activityScenarioRule() + + @Test + fun test() = run { + step("Open Simple Screen") { + testLogger.i("I am testLogger") + device.screenshots.take("Additional_screenshot") + MainScreen { + simpleButton { + isVisible() + click() + } + } + } + + step("Click button_1 and check button_2") { + SimpleScreen { + button1 { + click() + } + button2 { + isVisible() + } + } + } + + step("Click button_2 and check edit") { + SimpleScreen { + button2 { + click() + } + edit { + flakySafely(timeoutMs = 7000) { isVisible() } + hasText(R.string.simple_fragment_text_edittext) + } + } + } + + step("Check all possibilities of edit") { + scenario( + CheckEditScenario() + ) + } + } +} diff --git a/samples/kaspresso-sample/src/androidTest/kotlin/com/kaspersky/kaspressample/compose_tests/ComplexComposeTest.kt b/samples/kaspresso-sample/src/androidTest/kotlin/com/kaspersky/kaspressample/compose_tests/ComplexComposeTest.kt index 0e067f913..016837819 100644 --- a/samples/kaspresso-sample/src/androidTest/kotlin/com/kaspersky/kaspressample/compose_tests/ComplexComposeTest.kt +++ b/samples/kaspresso-sample/src/androidTest/kotlin/com/kaspersky/kaspressample/compose_tests/ComplexComposeTest.kt @@ -43,7 +43,7 @@ class ComplexComposeTest : TestCase() { } step("Handle potential unexpected behavior") { - compose { + compose(timeoutMs = 60_000L) { // the first potential branch when ComplexComposeScreen.stage1Button is visible or(ComplexComposeScreen.stage1Button) { isVisible()