diff --git a/adb-server/adb-server-command-types/src/main/java/com/kaspersky/adbserver/commandtypes/AdbCommand.kt b/adb-server/adb-server-command-types/src/main/java/com/kaspersky/adbserver/commandtypes/AdbCommand.kt index 0a6d3a3ed..a402a7b3f 100644 --- a/adb-server/adb-server-command-types/src/main/java/com/kaspersky/adbserver/commandtypes/AdbCommand.kt +++ b/adb-server/adb-server-command-types/src/main/java/com/kaspersky/adbserver/commandtypes/AdbCommand.kt @@ -2,7 +2,9 @@ package com.kaspersky.adbserver.commandtypes import com.kaspersky.adbserver.common.api.Command +/** + * Command for backward compatibility with old version of adb-server + */ data class AdbCommand( - override val command: String, - override val arguments: List = emptyList() -) : Command(command, arguments) + override val body: String, +) : Command(body) diff --git a/adb-server/adb-server-command-types/src/main/java/com/kaspersky/adbserver/commandtypes/CmdCommand.kt b/adb-server/adb-server-command-types/src/main/java/com/kaspersky/adbserver/commandtypes/CmdCommand.kt index b490a1e73..e748f75ab 100644 --- a/adb-server/adb-server-command-types/src/main/java/com/kaspersky/adbserver/commandtypes/CmdCommand.kt +++ b/adb-server/adb-server-command-types/src/main/java/com/kaspersky/adbserver/commandtypes/CmdCommand.kt @@ -1,8 +1,8 @@ package com.kaspersky.adbserver.commandtypes -import com.kaspersky.adbserver.common.api.Command +import com.kaspersky.adbserver.common.api.ComplexCommand data class CmdCommand( - override val command: String, + override val body: String, override val arguments: List = emptyList() -) : Command(command, arguments) +) : ComplexCommand(body, arguments) diff --git a/adb-server/adb-server-command-types/src/main/java/com/kaspersky/adbserver/commandtypes/ComplexAdbCommand.kt b/adb-server/adb-server-command-types/src/main/java/com/kaspersky/adbserver/commandtypes/ComplexAdbCommand.kt new file mode 100644 index 000000000..658b1137f --- /dev/null +++ b/adb-server/adb-server-command-types/src/main/java/com/kaspersky/adbserver/commandtypes/ComplexAdbCommand.kt @@ -0,0 +1,8 @@ +package com.kaspersky.adbserver.commandtypes + +import com.kaspersky.adbserver.common.api.ComplexCommand + +data class ComplexAdbCommand( + override val body: String, + override val arguments: List = emptyList() +) : ComplexCommand(body, arguments) diff --git a/adb-server/adb-server-common/src/main/java/com/kaspersky/adbserver/common/api/Command.kt b/adb-server/adb-server-common/src/main/java/com/kaspersky/adbserver/common/api/Command.kt index d70103224..96e4b738d 100644 --- a/adb-server/adb-server-common/src/main/java/com/kaspersky/adbserver/common/api/Command.kt +++ b/adb-server/adb-server-common/src/main/java/com/kaspersky/adbserver/common/api/Command.kt @@ -5,4 +5,4 @@ import java.io.Serializable /** * Command to execute by AdbServer */ -abstract class Command(open val command: String, open val arguments: List = emptyList()) : Serializable +abstract class Command(open val body: String) : Serializable diff --git a/adb-server/adb-server-common/src/main/java/com/kaspersky/adbserver/common/api/ComplexCommand.kt b/adb-server/adb-server-common/src/main/java/com/kaspersky/adbserver/common/api/ComplexCommand.kt new file mode 100644 index 000000000..e9e4bce29 --- /dev/null +++ b/adb-server/adb-server-common/src/main/java/com/kaspersky/adbserver/common/api/ComplexCommand.kt @@ -0,0 +1,3 @@ +package com.kaspersky.adbserver.common.api + +abstract class ComplexCommand(override val body: String, open val arguments: List = emptyList()) : Command(body) diff --git a/adb-server/adb-server-device/src/main/java/com/kaspersky/adbserver/device/AdbTerminal.kt b/adb-server/adb-server-device/src/main/java/com/kaspersky/adbserver/device/AdbTerminal.kt index 8f6a86901..8bffa11b0 100644 --- a/adb-server/adb-server-device/src/main/java/com/kaspersky/adbserver/device/AdbTerminal.kt +++ b/adb-server/adb-server-device/src/main/java/com/kaspersky/adbserver/device/AdbTerminal.kt @@ -2,6 +2,7 @@ package com.kaspersky.adbserver.device import com.kaspersky.adbserver.commandtypes.AdbCommand import com.kaspersky.adbserver.commandtypes.CmdCommand +import com.kaspersky.adbserver.commandtypes.ComplexAdbCommand import com.kaspersky.adbserver.common.api.CommandResult import com.kaspersky.adbserver.common.log.LoggerFactory import com.kaspersky.adbserver.common.log.logger.LogLevel @@ -35,7 +36,7 @@ object AdbTerminal { * Please first of all call [connect] method to establish a connection */ fun executeAdb(command: String, arguments: List): CommandResult = device?.fulfill( - AdbCommand(command, arguments) + ComplexAdbCommand(command, arguments) ) ?: throw IllegalStateException("Please first of all call [connect] method to establish a connection") /** diff --git a/adb-server/adbserver-desktop/src/main/java/com/kaspersky/adbserver/desktop/CommandExecutorImpl.kt b/adb-server/adbserver-desktop/src/main/java/com/kaspersky/adbserver/desktop/CommandExecutorImpl.kt index 7aceb84e8..bf3d8d0e8 100644 --- a/adb-server/adbserver-desktop/src/main/java/com/kaspersky/adbserver/desktop/CommandExecutorImpl.kt +++ b/adb-server/adbserver-desktop/src/main/java/com/kaspersky/adbserver/desktop/CommandExecutorImpl.kt @@ -5,6 +5,7 @@ import com.kaspersky.adbserver.common.api.CommandExecutor import com.kaspersky.adbserver.common.api.CommandResult import com.kaspersky.adbserver.commandtypes.AdbCommand import com.kaspersky.adbserver.commandtypes.CmdCommand +import com.kaspersky.adbserver.commandtypes.ComplexAdbCommand import com.kaspersky.adbserver.common.log.logger.Logger import java.lang.UnsupportedOperationException @@ -16,16 +17,24 @@ internal class CommandExecutorImpl( private val adbPath: String ) : CommandExecutor { + private fun getSimpleAdbCommand(command: Command): String = "$adbPath ${adbServerPort?.let { "-P $adbServerPort " } ?: ""}-s $deviceName ${command.body}" + override fun execute(command: Command): CommandResult { return when (command) { - is CmdCommand -> cmdCommandPerformer.perform(command.command, command.arguments) + is CmdCommand -> cmdCommandPerformer.perform(command.body, command.arguments) is AdbCommand -> { + val adbCommand = getSimpleAdbCommand(command) + logger.d("The created adbCommand=$adbCommand") + cmdCommandPerformer.perform(adbCommand, emptyList()) + } + + is ComplexAdbCommand -> { val adbCommand: String val adbArguments: List if (command.arguments.isEmpty()) { - adbCommand = "$adbPath ${adbServerPort?.let { "-P $adbServerPort " } ?: ""}-s $deviceName ${command.command}" + adbCommand = getSimpleAdbCommand(command) adbArguments = emptyList() } else { adbCommand = adbPath @@ -36,7 +45,7 @@ internal class CommandExecutorImpl( } add("-s") add(deviceName) - add(command.command) + add(command.body) addAll(command.arguments) } } diff --git a/allure-support/build.gradle.kts b/allure-support/build.gradle.kts index 7fb3ac15e..2eb3ce657 100644 --- a/allure-support/build.gradle.kts +++ b/allure-support/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { api(libs.bundles.allure) implementation(projects.kaspresso) + implementation(projects.adbServer.adbServerCommon) implementation(libs.kotlinStdlib) implementation(libs.truth) 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 6ee0272cb..1fb546909 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 @@ -1,5 +1,6 @@ package com.kaspersky.components.alluresupport +import com.kaspersky.adbserver.common.log.logger.LogLevel import com.kaspersky.components.alluresupport.files.dirs.AllureDirsProvider import com.kaspersky.components.alluresupport.files.resources.impl.AllureResourceFilesProvider import com.kaspersky.components.alluresupport.files.resources.impl.DefaultAllureResourcesRootDirsProvider @@ -10,8 +11,14 @@ import com.kaspersky.components.alluresupport.interceptors.testrun.DumpViewsTest import com.kaspersky.components.alluresupport.interceptors.testrun.HackyVideoRecordingTestInterceptor import com.kaspersky.components.alluresupport.interceptors.testrun.ScreenshotTestInterceptor import com.kaspersky.components.alluresupport.interceptors.testrun.VideoRecordingTestInterceptor +import com.kaspersky.components.alluresupport.interceptors.testrun.VisualTestLateFailInterceptor import com.kaspersky.components.alluresupport.results.AllureResultsHack import com.kaspersky.components.alluresupport.runlisteners.AllureRunListener +import com.kaspersky.components.alluresupport.visual.AllureScreenshotsComparator +import com.kaspersky.components.alluresupport.visual.AllureVisualTestWatcher +import com.kaspersky.kaspresso.BuildConfig +import com.kaspersky.kaspresso.device.files.FilesImpl +import com.kaspersky.kaspresso.device.server.AdbServerImpl import com.kaspersky.kaspresso.files.dirs.DefaultDirsProvider import com.kaspersky.kaspresso.files.resources.impl.DefaultResourceFileNamesProvider import com.kaspersky.kaspresso.files.resources.impl.DefaultResourceFilesProvider @@ -19,8 +26,11 @@ import com.kaspersky.kaspresso.files.resources.impl.DefaultResourcesDirNameProvi import com.kaspersky.kaspresso.files.resources.impl.DefaultResourcesDirsProvider import com.kaspersky.kaspresso.instrumental.InstrumentalDependencyProvider import com.kaspersky.kaspresso.kaspresso.Kaspresso +import com.kaspersky.kaspresso.logger.UiTestLoggerImpl import com.kaspersky.kaspresso.runner.listener.addUniqueListener import com.kaspersky.kaspresso.runner.listener.getUniqueListener +import com.kaspersky.kaspresso.visual.VisualTestParams +import com.kaspersky.kaspresso.visual.VisualTestType /** * Kaspresso Builder that includes all appropriate interceptors to support rich Allure reports. @@ -64,6 +74,7 @@ fun Kaspresso.Builder.addAllureSupport(): Kaspresso.Builder = apply { */ fun Kaspresso.Builder.Companion.withForcedAllureSupport( shouldRecordVideo: Boolean = true, + visualTestParams: VisualTestParams = VisualTestParams(testType = VisualTestType.valueOf(BuildConfig.VISUAL_TEST_TYPE)), customize: Kaspresso.Builder.() -> Unit = {} ): Kaspresso.Builder = simple { if (!isAndroidRuntime) { @@ -72,6 +83,7 @@ fun Kaspresso.Builder.Companion.withForcedAllureSupport( customize.invoke(this) val instrumentalDependencyProvider = instrumentalDependencyProviderFactory.getComponentProvider(instrumentation) forceAllureSupportFileProviders(instrumentalDependencyProvider) + initVisualTestParams(visualTestParams) addRunListenersIfNeeded(instrumentalDependencyProvider) }.apply { postInitAllure(shouldRecordVideo, builder = this) @@ -98,6 +110,23 @@ private fun Kaspresso.Builder.forceAllureSupportFileProviders(provider: Instrume resourceFilesProvider = allureResourcesFilesProvider } +private fun Kaspresso.Builder.initVisualTestParams(visualParams: VisualTestParams) { + visualTestParams = visualParams + testLogger = UiTestLoggerImpl(Kaspresso.DEFAULT_TEST_LOGGER_TAG) + libLogger = UiTestLoggerImpl(Kaspresso.DEFAULT_LIB_LOGGER_TAG) + + screenshotsComparator = AllureScreenshotsComparator( + visualTestParams, + testLogger, + resourcesRootDirsProvider, + resourcesDirsProvider, + resourceFileNamesProvider, + ) + adbServer = AdbServerImpl(LogLevel.WARN, libLogger) + files = FilesImpl(libLogger, adbServer) + visualTestWatcher = AllureVisualTestWatcher(visualTestParams, testLogger, (dirsProvider as AllureDirsProvider), resourcesRootDirsProvider, files) +} + private fun Kaspresso.Builder.addRunListenersIfNeeded(provider: InstrumentalDependencyProvider) { provider.runNotifier.apply { addUniqueListener(::AllureRunListener) @@ -105,7 +134,8 @@ private fun Kaspresso.Builder.addRunListenersIfNeeded(provider: InstrumentalDepe AllureResultsHack( uiDevice = provider.uiDevice, resourcesRootDirsProvider = resourcesRootDirsProvider as DefaultAllureResourcesRootDirsProvider, - dirsProvider = dirsProvider as AllureDirsProvider + dirsProvider = dirsProvider as AllureDirsProvider, + visualTestParams = visualTestParams, ) } } @@ -126,6 +156,7 @@ private fun postInitAllure(shouldRecordVideo: Boolean, builder: Kaspresso.Builde DumpLogcatTestInterceptor(logcatDumper), ScreenshotTestInterceptor(screenshots), DumpViewsTestInterceptor(viewHierarchyDumper), + VisualTestLateFailInterceptor(), ) ) if (shouldRecordVideo) { diff --git a/allure-support/src/main/kotlin/com/kaspersky/components/alluresupport/interceptors/testrun/VisualTestLateFailInterceptor.kt b/allure-support/src/main/kotlin/com/kaspersky/components/alluresupport/interceptors/testrun/VisualTestLateFailInterceptor.kt new file mode 100644 index 000000000..df1b74f25 --- /dev/null +++ b/allure-support/src/main/kotlin/com/kaspersky/components/alluresupport/interceptors/testrun/VisualTestLateFailInterceptor.kt @@ -0,0 +1,16 @@ +package com.kaspersky.components.alluresupport.interceptors.testrun + +import com.kaspersky.components.alluresupport.results.AllureVisualTestFlag +import com.kaspersky.kaspresso.device.screenshots.ScreenshotsImpl +import com.kaspersky.kaspresso.interceptors.watcher.testcase.TestRunWatcherInterceptor +import com.kaspersky.kaspresso.testcases.models.info.TestInfo + +class VisualTestLateFailInterceptor : TestRunWatcherInterceptor { + override fun onAfterSectionStarted(testInfo: TestInfo) { + if (AllureVisualTestFlag.shouldFailLate.get()) { + // Wrap with assertion error so test would be marked as FAILED instead of BROKEN + // See https://github.com/allure-framework/allure-kotlin allure-kotlin-commons/src/main/kotlin/io/qameta/allure/kotlin/util/ResultsUtils.kt + throw AssertionError(ScreenshotsImpl.ScreenshotDoesntMatchException("There were failed screenshot comparisons. Check the allure report")) + } + } +} diff --git a/allure-support/src/main/kotlin/com/kaspersky/components/alluresupport/results/AllureResultsHack.kt b/allure-support/src/main/kotlin/com/kaspersky/components/alluresupport/results/AllureResultsHack.kt index 216f5f0ec..75546d0e6 100644 --- a/allure-support/src/main/kotlin/com/kaspersky/components/alluresupport/results/AllureResultsHack.kt +++ b/allure-support/src/main/kotlin/com/kaspersky/components/alluresupport/results/AllureResultsHack.kt @@ -6,14 +6,17 @@ import com.kaspersky.components.alluresupport.files.dirs.AllureDirsProvider import com.kaspersky.components.alluresupport.files.resources.AllureResourcesRootDirsProvider import com.kaspersky.components.alluresupport.files.resources.impl.AllureResourceFilesProvider import com.kaspersky.kaspresso.runner.listener.KaspressoRunListener +import com.kaspersky.kaspresso.visual.VisualTestParams +import com.kaspersky.kaspresso.visual.VisualTestType import io.qameta.allure.kotlin.Allure import org.junit.runner.Result import java.io.File class AllureResultsHack( private val uiDevice: UiDevice, + private val visualTestParams: VisualTestParams, resourcesRootDirsProvider: AllureResourcesRootDirsProvider, - dirsProvider: AllureDirsProvider, + private val dirsProvider: AllureDirsProvider, ) : KaspressoRunListener { private val allureResultsSourceDir: File = @@ -48,6 +51,13 @@ class AllureResultsHack( allureResultsSourceDir.deleteRecursively() stubVideosDir.deleteRecursively() + + if (visualTestParams.testType == VisualTestType.Record) { + val rootDir = dirsProvider.provideNew(File("")).absolutePath + val newScreenshotsDir = File(rootDir, File(visualTestParams.hostScreenshotsDir).name) + val targetScreenshotsDir = dirsProvider.provideNewOnSdCard(File(visualTestParams.hostScreenshotsDir)) + newScreenshotsDir.copyRecursively(targetScreenshotsDir) + } } data class VideoBinding( diff --git a/allure-support/src/main/kotlin/com/kaspersky/components/alluresupport/results/AllureVisualTestFlag.kt b/allure-support/src/main/kotlin/com/kaspersky/components/alluresupport/results/AllureVisualTestFlag.kt new file mode 100644 index 000000000..f56973276 --- /dev/null +++ b/allure-support/src/main/kotlin/com/kaspersky/components/alluresupport/results/AllureVisualTestFlag.kt @@ -0,0 +1,11 @@ +package com.kaspersky.components.alluresupport.results + +import java.util.concurrent.atomic.AtomicBoolean + +// TODO(Nikita Evdokimov) - Certainly there should be a better way +/** + * @see com.kaspersky.components.alluresupport.interceptors.testrun.VisualTestLateFailInterceptor + */ +object AllureVisualTestFlag { + val shouldFailLate = AtomicBoolean(false) +} diff --git a/allure-support/src/main/kotlin/com/kaspersky/components/alluresupport/visual/AllureScreenshotsComparator.kt b/allure-support/src/main/kotlin/com/kaspersky/components/alluresupport/visual/AllureScreenshotsComparator.kt new file mode 100644 index 000000000..49cbc1ce8 --- /dev/null +++ b/allure-support/src/main/kotlin/com/kaspersky/components/alluresupport/visual/AllureScreenshotsComparator.kt @@ -0,0 +1,35 @@ +package com.kaspersky.components.alluresupport.visual + +import android.graphics.Bitmap +import com.kaspersky.components.alluresupport.files.attachScreenshotToAllureReport +import com.kaspersky.kaspresso.files.resources.ResourceFileNamesProvider +import com.kaspersky.kaspresso.files.resources.ResourcesDirsProvider +import com.kaspersky.kaspresso.files.resources.ResourcesRootDirsProvider +import com.kaspersky.kaspresso.internal.visual.DefaultScreenshotsComparator +import com.kaspersky.kaspresso.logger.Logger +import com.kaspersky.kaspresso.visual.VisualTestParams +import java.io.File + +class AllureScreenshotsComparator( + visualTestParams: VisualTestParams, + logger: Logger, + resourcesRootDirsProvider: ResourcesRootDirsProvider, + resourcesDirsProvider: ResourcesDirsProvider, + resourceFileNamesProvider: ResourceFileNamesProvider, +) : DefaultScreenshotsComparator(visualTestParams, logger, resourcesRootDirsProvider, resourcesDirsProvider, resourceFileNamesProvider) { + override fun compare(originalScreenshot: File, newScreenshot: File): Boolean { + val doScreenshotsMatch = super.compare(originalScreenshot, newScreenshot) + if (!doScreenshotsMatch) { + originalScreenshot.attachScreenshotToAllureReport() + newScreenshot.attachScreenshotToAllureReport() + } + + return doScreenshotsMatch + } + + override fun processScreenshotDiff(original: Bitmap, diffPixels: IntArray, diffName: String): File { + return super.processScreenshotDiff(original, diffPixels, diffName).also { + it.attachScreenshotToAllureReport() + } + } +} diff --git a/allure-support/src/main/kotlin/com/kaspersky/components/alluresupport/visual/AllureVisualTestCase.kt b/allure-support/src/main/kotlin/com/kaspersky/components/alluresupport/visual/AllureVisualTestCase.kt new file mode 100644 index 000000000..5930d0449 --- /dev/null +++ b/allure-support/src/main/kotlin/com/kaspersky/components/alluresupport/visual/AllureVisualTestCase.kt @@ -0,0 +1,35 @@ +package com.kaspersky.components.alluresupport.visual + +import com.kaspersky.components.alluresupport.results.AllureVisualTestFlag +import com.kaspersky.components.alluresupport.withForcedAllureSupport +import com.kaspersky.kaspresso.device.screenshots.ScreenshotsImpl +import com.kaspersky.kaspresso.kaspresso.Kaspresso +import com.kaspersky.kaspresso.testcases.api.testcase.VisualTestCase +import io.qameta.allure.kotlin.Allure +import io.qameta.allure.kotlin.model.Status +import io.qameta.allure.kotlin.model.StatusDetails + +abstract class AllureVisualTestCase( + private val failEarly: Boolean = false, + kaspressoBuilder: Kaspresso.Builder = Kaspresso.Builder.withForcedAllureSupport() +) : VisualTestCase(kaspressoBuilder = kaspressoBuilder) { + + override fun assertScreenshot(tag: String, isFullWindow: Boolean) { + try { + device.screenshots.assert(tag, isFullWindow) + } catch (ex: ScreenshotsImpl.ScreenshotDoesntMatchException) { + if (failEarly) { + // Wrap with assertion error so test would be marked as FAILED instead of BROKEN + // See https://github.com/allure-framework/allure-kotlin allure-kotlin-commons/src/main/kotlin/io/qameta/allure/kotlin/util/ResultsUtils.kt + throw AssertionError(ex) + } + + Allure.lifecycle.updateStep { + it.status = Status.FAILED + it.statusDetails = StatusDetails(known = true, muted = true, message = ex.message, trace = ex.stackTraceToString()) + } + Allure.lifecycle.stopStep() + AllureVisualTestFlag.shouldFailLate.set(true) + } + } +} diff --git a/allure-support/src/main/kotlin/com/kaspersky/components/alluresupport/visual/AllureVisualTestWatcher.kt b/allure-support/src/main/kotlin/com/kaspersky/components/alluresupport/visual/AllureVisualTestWatcher.kt new file mode 100644 index 000000000..c123f9b84 --- /dev/null +++ b/allure-support/src/main/kotlin/com/kaspersky/components/alluresupport/visual/AllureVisualTestWatcher.kt @@ -0,0 +1,48 @@ +package com.kaspersky.components.alluresupport.visual + +import com.kaspersky.components.alluresupport.files.dirs.AllureDirsProvider +import com.kaspersky.kaspresso.device.files.Files +import com.kaspersky.kaspresso.files.resources.ResourcesRootDirsProvider +import com.kaspersky.kaspresso.logger.Logger +import com.kaspersky.kaspresso.visual.VisualTestParams +import com.kaspersky.kaspresso.visual.VisualTestType +import com.kaspersky.kaspresso.visual.VisualTestWatcher +import java.io.File + +class AllureVisualTestWatcher( + private val params: VisualTestParams, + private val logger: Logger, + private val dirsProvider: AllureDirsProvider, + resourcesRootDirsProvider: ResourcesRootDirsProvider, + private val files: Files, +) : VisualTestWatcher { + + private val diffDir = dirsProvider.provideNew(resourcesRootDirsProvider.screenshotsDiffRootDir) + private val originalScreenshotsTargetDir: File + get() { + val rootDir = dirsProvider.provideNewOnSdCard(File("")).absolutePath + return File(rootDir, File(params.hostScreenshotsDir).name) + } + + override fun prepare() { + logger.i("Visual test run started. Parameters: $params") + + if (params.testType == VisualTestType.Compare) { + logger.i("Pushing the screenshots unto the device...") + dirsProvider.provideCleared(diffDir) + + // Allure stores all files in the app's private directory. We can't "adb push" directly there, + // so we have to do this in 2 steps + dirsProvider.provideCleared(originalScreenshotsTargetDir) + val tmp = dirsProvider.provideNewOnSdCard(File("")) + files.push(params.hostScreenshotsDir, tmp.absolutePath) + val target = dirsProvider.provideNew(File("")).resolve(params.hostScreenshotsDir) + File(tmp, params.hostScreenshotsDir).copyRecursively(target, overwrite = true) + logger.i("Done pushing the screenshots unto the device") + } + } + + override fun cleanUp() { + // Do nothing + } +} diff --git a/artifacts/adbserver-desktop.jar b/artifacts/adbserver-desktop.jar index a38166f8a..edf3fe708 100644 Binary files a/artifacts/adbserver-desktop.jar and b/artifacts/adbserver-desktop.jar differ diff --git a/build-logic/android/src/main/kotlin/convention.android-base.gradle.kts b/build-logic/android/src/main/kotlin/convention.android-base.gradle.kts index 7324d8314..106977280 100644 --- a/build-logic/android/src/main/kotlin/convention.android-base.gradle.kts +++ b/build-logic/android/src/main/kotlin/convention.android-base.gradle.kts @@ -32,5 +32,6 @@ configure { resValues = false shaders = false viewBinding = false + buildConfig = true } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 45e4fc994..08e00c3ab 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -53,6 +53,7 @@ kakaoExtClicks = { module = "io.github.kakaocup:kakao-ext-clicks", version.ref = junit = "junit:junit:4.13.2" junitJupiter = "org.junit.jupiter:junit-jupiter:5.9.0" truth = "com.google.truth:truth:1.3.0" +mockk = "io.mockk:mockk:1.13.12" androidXTestCore = { module = "androidx.test:core", version.ref = "androidXTest" } androidXTestRules = { module = "androidx.test:rules", version.ref = "androidXTest" } diff --git a/kaspresso/build.gradle.kts b/kaspresso/build.gradle.kts index a52cfc7ec..371dbde8c 100644 --- a/kaspresso/build.gradle.kts +++ b/kaspresso/build.gradle.kts @@ -7,6 +7,11 @@ plugins { android { namespace = "com.kaspersky.kaspresso" + + defaultConfig { + buildConfigField("String", "VISUAL_TEST_TYPE", System.getenv("VISUAL_TEST_TYPE") + ?: findProperty("kaspresso.visualTestType")?.toString() ?: "\"Record\"") // [Record, Compare] + } } publish { @@ -30,4 +35,5 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.truth) + testImplementation(libs.mockk) } diff --git a/kaspresso/gradle.properties b/kaspresso/gradle.properties index 82b88875a..de6cebcd4 100644 --- a/kaspresso/gradle.properties +++ b/kaspresso/gradle.properties @@ -1,4 +1,4 @@ publish.artifactGroup=com.kaspersky.android-components publish.artifactName=kaspresso publish.publicationName=Kaspresso -publish.bintrayRepo=Kaspresso \ No newline at end of file +publish.bintrayRepo=Kaspresso diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/activities/metadata/ActivityMetadata.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/activities/metadata/ActivityMetadata.kt index b854fa528..c61ce50c9 100644 --- a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/activities/metadata/ActivityMetadata.kt +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/activities/metadata/ActivityMetadata.kt @@ -51,10 +51,9 @@ internal class ActivityMetadata( private fun getLocalizedStrings(decorView: View): List { return TreeIterables.depthFirstViewTraversal(decorView) .filter { it.visibility == View.VISIBLE } - .filter { it is TextView || it is CollapsingToolbarLayout } - .map { v -> - if (v is TextView) { - LocalizedString( + .mapNotNull { v -> + when (v) { + is TextView -> LocalizedString( v.text.toString(), getEntryName(decorView.resources, v), v.left, @@ -62,17 +61,17 @@ internal class ActivityMetadata( v.width, v.height ) - } else { - LocalizedString( - (v as CollapsingToolbarLayout).title.toString(), + is CollapsingToolbarLayout -> LocalizedString( + v.title.toString(), getEntryNameFromLayout(decorView.resources, v), v.left, v.top, v.width, v.height ) + else -> null } - }.toMutableList() + } } private fun getEntryName(resources: Resources, v: TextView): String { 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 96f560e2d..7309a3175 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 @@ -8,7 +8,7 @@ import com.kaspersky.kaspresso.logger.UiTestLogger */ class FilesImpl( private val logger: UiTestLogger, - private val adbServer: AdbServer + private val adbServer: AdbServer, ) : Files { /** @@ -20,7 +20,7 @@ class FilesImpl( * @param devicePath a path to copy. */ override fun push(serverPath: String, devicePath: String) { - adbServer.performAdb("push $serverPath $devicePath") + adbServer.performAdb("push", listOf(serverPath, devicePath)) logger.i("Push file from $serverPath to $devicePath") } @@ -32,7 +32,7 @@ class FilesImpl( * @param path a path to remove */ override fun remove(path: String) { - adbServer.performShell("rm -f $path") + adbServer.performShell("rm", listOf("-rf", path)) logger.i("Remove file from $path") } @@ -45,8 +45,8 @@ 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") + adbServer.performCmd("mkdir", listOf("-p", serverPath)) + adbServer.performAdb("pull", listOf(devicePath, serverPath)) logger.i("Pull file from $devicePath to $serverPath") } } diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/languages/LanguageImpl.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/languages/LanguageImpl.kt index 557961ade..76c9a9ad6 100644 --- a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/languages/LanguageImpl.kt +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/languages/LanguageImpl.kt @@ -16,7 +16,7 @@ import java.util.Locale /** * The implementation of [Language] */ -internal class LanguageImpl( +open class LanguageImpl( private val logger: UiTestLogger, private val instrumentation: Instrumentation, private val systemLanguage: SystemLanguage, diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/permissions/Permissions.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/permissions/Permissions.kt index 9862b14f0..8caef3a24 100644 --- a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/permissions/Permissions.kt +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/permissions/Permissions.kt @@ -7,8 +7,11 @@ interface Permissions { /** * Passes the permission-requesting permissions dialog and allows permissions. + * + * @param button - the button which would be pressed. Changing the default value may be useful in android 11+ cases + * @see (https://developer.android.com/about/versions/11/privacy/location and https://developer.android.com/about/versions/14/changes/partial-photo-video-access) */ - fun allowViaDialog() + fun allowViaDialog(button: Button = Button.ALLOW) /** * Passes the permission-requesting permissions dialog and denies permissions. diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/permissions/PermissionsImpl.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/permissions/PermissionsImpl.kt index 684c96539..d0447ac9f 100644 --- a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/permissions/PermissionsImpl.kt +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/permissions/PermissionsImpl.kt @@ -42,12 +42,12 @@ class PermissionsImpl( /** * Waits for 1 sec, passes the permission-requesting permissions dialog and allows permissions. */ - override fun allowViaDialog() { + override fun allowViaDialog(button: Permissions.Button) { wait( timeoutMs = DIALOG_TIMEOUT_MS, logger = logger ) { - handlePermissionRequest(Permissions.Button.ALLOW) + handlePermissionRequest(button) } logger.i("Allow permission via dialog") } diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/screenshots/Screenshots.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/screenshots/Screenshots.kt index 92b851bbd..a8cc5478c 100644 --- a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/screenshots/Screenshots.kt +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/screenshots/Screenshots.kt @@ -42,4 +42,8 @@ interface Screenshots { * @param tag a unique tag to further identify the screenshot. Must match [a-zA-Z0-9_-]+. */ fun takeFullWindowAndApply(tag: String, block: File.() -> Unit) + + fun assert(tag: String, isFullWindow: Boolean) + + fun assertAndApply(tag: String, isFullWindow: Boolean, block: File.() -> Unit) } diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/screenshots/ScreenshotsImpl.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/screenshots/ScreenshotsImpl.kt index 9926b69b7..a33cba435 100644 --- a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/screenshots/ScreenshotsImpl.kt +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/screenshots/ScreenshotsImpl.kt @@ -2,8 +2,15 @@ package com.kaspersky.kaspresso.device.screenshots import android.util.Log import com.kaspersky.kaspresso.device.screenshots.screenshotmaker.ScreenshotMaker +import com.kaspersky.kaspresso.files.dirs.DirsProvider +import com.kaspersky.kaspresso.files.extensions.FileExtension +import com.kaspersky.kaspresso.files.resources.ResourceFileNamesProvider import com.kaspersky.kaspresso.files.resources.ResourceFilesProvider +import com.kaspersky.kaspresso.files.resources.ResourcesDirsProvider import com.kaspersky.kaspresso.logger.UiTestLogger +import com.kaspersky.kaspresso.visual.ScreenshotsComparator +import com.kaspersky.kaspresso.visual.VisualTestParams +import com.kaspersky.kaspresso.visual.VisualTestType import java.io.File /** @@ -13,8 +20,19 @@ class ScreenshotsImpl( private val logger: UiTestLogger, private val resourceFilesProvider: ResourceFilesProvider, private val screenshotMaker: ScreenshotMaker, + private val screenshotsComparator: ScreenshotsComparator, + private val visualTestParams: VisualTestParams, + private val dirsProvider: DirsProvider, + private val resourceFileNamesProvider: ResourceFileNamesProvider, + private val resourcesDirsProvider: ResourcesDirsProvider, ) : Screenshots { + private val originalScreenshotsDir: File + get() { + val rootDir = dirsProvider.provideNew(File("")).absolutePath + return File(rootDir, File(visualTestParams.hostScreenshotsDir).name) + } + /** * Takes a screenshot if it is possible, otherwise logs the error. * By default a screenshot name looks like /screenshotRootDir////[tag].png @@ -24,17 +42,54 @@ class ScreenshotsImpl( * * @param tag a unique tag to further identify the screenshot. Must match [a-zA-Z0-9_-]+. */ - override fun take(tag: String): Unit = doTakeAndApply(tag, false, null) + override fun take(tag: String): Unit = doTakeAndApply(tag, isFull = false, block = null) + + override fun takeFullWindow(tag: String) = doTakeAndApply(tag, isFull = true, block = null) + + override fun takeAndApply(tag: String, block: File.() -> Unit): Unit = doTakeAndApply(tag, isFull = false, block = block) - override fun takeFullWindow(tag: String) = doTakeAndApply(tag, true, null) + override fun takeFullWindowAndApply(tag: String, block: File.() -> Unit) = doTakeAndApply(tag, isFull = true, block = block) - override fun takeAndApply(tag: String, block: File.() -> Unit): Unit = doTakeAndApply(tag, false, block) + override fun assert(tag: String, isFullWindow: Boolean) = assertImpl(tag, isFullWindow, block = null) - override fun takeFullWindowAndApply(tag: String, block: File.() -> Unit) = doTakeAndApply(tag, true, block) + override fun assertAndApply(tag: String, isFullWindow: Boolean, block: File.() -> Unit) = assertImpl(tag, isFullWindow, block) + + private fun assertImpl(tag: String, isFullWindow: Boolean, block: (File.() -> Unit)?) { + logger.i("Assert screenshot with tag: $tag") + lateinit var screenshot: File + val targetPath = if (visualTestParams.testType == VisualTestType.Compare) { + null + } else { + originalScreenshotsDir.mkdirs() + resourcesDirsProvider.provide(originalScreenshotsDir).resolve(resourceFileNamesProvider.getFileName(tag, FileExtension.PNG.toString())) + } + doTakeAndApply(tag = tag, isFull = isFullWindow, targetPath) { screenshot = this } + + if (visualTestParams.testType == VisualTestType.Compare) { + screenshot.compare() + } - private fun doTakeAndApply(tag: String, isFull: Boolean, block: (File.() -> Unit)?) { + block?.invoke(screenshot) + } + + private fun File.compare() { + logger.i("Comparing screenshot ${this.absolutePath}") + val originalScreenshot = resourcesDirsProvider.provide(originalScreenshotsDir, provideCleared = false) + .resolve(resourceFileNamesProvider.getFileName(nameWithoutExtension, FileExtension.PNG.toString())) + assert(originalScreenshot.exists()) { + "Tried to assert screenshot $absolutePath, but failed to find matching " + + "original screenshot by the path: ${originalScreenshot.absolutePath}/$name" + } + val doesMatch = screenshotsComparator.compare(originalScreenshot, this) + logger.i("Does screenshot $name matches the original one: $doesMatch") + if (!doesMatch) { + throw ScreenshotDoesntMatchException("Screenshot $name doesn't match the original one") + } + } + + private fun doTakeAndApply(tag: String, isFull: Boolean, targetPath: File? = null, block: (File.() -> Unit)?) { try { - val screenshotFile: File = resourceFilesProvider.provideScreenshotFile(tag) + val screenshotFile = targetPath ?: resourceFilesProvider.provideScreenshotFile(tag) if (isFull) { screenshotMaker.takeFullWindowScreenshot(screenshotFile) } else { @@ -46,4 +101,6 @@ class ScreenshotsImpl( logger.e("An error while making screenshot occurred: ${Log.getStackTraceString(e)}") } } + + class ScreenshotDoesntMatchException(message: String) : Exception(message) } diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/screenshots/screenshotmaker/InternalScreenshotMaker.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/screenshots/screenshotmaker/InternalScreenshotMaker.kt index f6c4b9abc..64d664bdc 100644 --- a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/screenshots/screenshotmaker/InternalScreenshotMaker.kt +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/screenshots/screenshotmaker/InternalScreenshotMaker.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.graphics.Bitmap import android.graphics.Canvas import android.os.Looper +import android.util.Log import android.view.View import com.kaspersky.kaspresso.device.activities.Activities import com.kaspersky.kaspresso.params.ScreenshotParams @@ -82,7 +83,7 @@ class InternalScreenshotMaker( private fun drawBitmap(view: View): Bitmap { view.layout(0, 0, view.measuredWidth, view.measuredHeight) val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888) - val bitmapHolder = Canvas(bitmap!!) + val bitmapHolder = Canvas(bitmap) view.draw(bitmapHolder) return bitmap } @@ -97,6 +98,8 @@ class InternalScreenshotMaker( activity.runOnUiThread { try { activity.drawToBitmap(bitmap) + } catch (e: Exception) { + Log.e("InternalScreenshotMaker", "Unable to get screenshot ${file.absolutePath}") } finally { latch.countDown() } diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/video/VideosImpl.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/video/VideosImpl.kt index bdf99a2f9..0f7263c79 100644 --- a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/video/VideosImpl.kt +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/video/VideosImpl.kt @@ -12,7 +12,11 @@ class VideosImpl( ) : Videos { override fun record(tag: String) { - val videoFile: File = resourceFilesProvider.provideVideoFile(tag) + val sanitizedVideoName = tag.replace("[/:\"*?<>|]+".toRegex(), "_") + if (tag != sanitizedVideoName) { + logger.d("Can't record video with name $tag since it contains one of the following special characters [/:\"*?<>|]. Changing the name to $sanitizedVideoName") + } + val videoFile: File = resourceFilesProvider.provideVideoFile(sanitizedVideoName) videoRecorder.start(videoFile) } diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/docloc/SystemLanguage.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/docloc/SystemLanguage.kt index 8eb4aaff9..6cbb768ef 100644 --- a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/docloc/SystemLanguage.kt +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/docloc/SystemLanguage.kt @@ -10,7 +10,7 @@ import com.kaspersky.kaspresso.device.permissions.HackPermissions import com.kaspersky.kaspresso.logger.UiTestLogger import java.util.Locale -internal class SystemLanguage( +open class SystemLanguage( private val context: Context, private val logger: UiTestLogger, private val hackPermissions: HackPermissions @@ -24,7 +24,7 @@ internal class SystemLanguage( * @throws Throwable if something went wrong */ @SuppressLint("PrivateApi", "DiscouragedPrivateApi") - fun switch(locale: Locale) { + open fun switch(locale: Locale) { logger.i("SystemLanguage: Installing new system language=$locale started") grantPermissionsIfNeed() try { diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/failure/FailureLoggingProviderImpl.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/failure/FailureLoggingProviderImpl.kt index c5f54ea21..cccce9578 100644 --- a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/failure/FailureLoggingProviderImpl.kt +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/failure/FailureLoggingProviderImpl.kt @@ -66,7 +66,7 @@ class FailureLoggingProviderImpl( error?.let { " because of ${error.javaClass.simpleName}" } ) - error?.let { throw it.describedWith(viewMatcher) } + error?.let { throw describeError(it, viewMatcher) } } /** @@ -76,28 +76,27 @@ class FailureLoggingProviderImpl( * * @return transformed [error]. */ - private fun Throwable.describedWith(viewMatcher: Matcher?): Throwable { - val newError = when { - this is PerformException -> { + private fun describeError(originalError: Throwable, viewMatcher: Matcher?): Throwable { + return when { + originalError is PerformException -> { PerformException.Builder() - .from(this) + .from(originalError) .apply { viewMatcher?.let { withViewDescription(it.toString()) } } .build() + .apply { addSuppressed(originalError) } } - this is AssertionError -> { - AssertionFailedError(message).initCause(this) + originalError is AssertionError -> { + AssertionFailedError(originalError.message) + .initCause(originalError) + .apply { addSuppressed(originalError) } } - isWebViewException(this) -> { + isWebViewException(originalError) -> { val message = StringBuilder("Failed to interact with web view! Usually it means that desired element is not found or JavaScript is disabled in web view") viewMatcher?.let { message.append("\nView description: ${it.describe()}") } - RuntimeException(message.toString()) + RuntimeException(message.toString()).apply { addSuppressed(originalError) } } - else -> this + else -> originalError.apply { addSuppressed(RuntimeException()) } } - newError.stackTrace = Thread.currentThread().stackTrace - newError.addSuppressed(this) - - return newError } private fun isWebViewException(throwable: Throwable): Boolean { diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/files/resources/ResourcesDirsProvider.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/files/resources/ResourcesDirsProvider.kt index 14bf08f34..68318907c 100644 --- a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/files/resources/ResourcesDirsProvider.kt +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/files/resources/ResourcesDirsProvider.kt @@ -6,5 +6,5 @@ import java.io.File * Provides directories for resources */ interface ResourcesDirsProvider { - fun provide(dest: File, subDir: String? = null): File + fun provide(dest: File, subDir: String? = null, provideCleared: Boolean = true): File } diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/files/resources/ResourcesRootDirsProvider.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/files/resources/ResourcesRootDirsProvider.kt index 5ac81ec06..afb8957ab 100644 --- a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/files/resources/ResourcesRootDirsProvider.kt +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/files/resources/ResourcesRootDirsProvider.kt @@ -8,6 +8,8 @@ import java.io.File interface ResourcesRootDirsProvider { val logcatRootDir: File val screenshotsRootDir: File + val originalScreenshotsRootDir: File + val screenshotsDiffRootDir: File val videoRootDir: File val viewHierarchy: File } diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/files/resources/impl/DefaultResourcesDirsProvider.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/files/resources/impl/DefaultResourcesDirsProvider.kt index 72006706f..0f2cd1737 100644 --- a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/files/resources/impl/DefaultResourcesDirsProvider.kt +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/files/resources/impl/DefaultResourcesDirsProvider.kt @@ -5,6 +5,7 @@ import com.kaspersky.kaspresso.files.extensions.findTestMethod import com.kaspersky.kaspresso.files.models.TestMethod import com.kaspersky.kaspresso.files.resources.ResourcesDirNameProvider import com.kaspersky.kaspresso.files.resources.ResourcesDirsProvider +import com.kaspersky.kaspresso.internal.extensions.other.createDirIfNeeded import java.io.File class DefaultResourcesDirsProvider( @@ -13,10 +14,14 @@ class DefaultResourcesDirsProvider( private val testThread: Thread = Thread.currentThread() ) : ResourcesDirsProvider { - override fun provide(dest: File, subDir: String?): File { + override fun provide(dest: File, subDir: String?, provideCleared: Boolean): File { val rootDir: File = dirsProvider.provideNew(dest) val resourcesDest: File = resolveResourcesDirDest(rootDir, subDir) - return dirsProvider.provideCleared(resourcesDest) + return if (provideCleared) { + dirsProvider.provideCleared(resourcesDest) + } else { + resourcesDest.createDirIfNeeded() + } } private fun resolveResourcesDirDest(rootDir: File, subDir: String? = null): File { diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/files/resources/impl/DefaultResourcesRootDirsProvider.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/files/resources/impl/DefaultResourcesRootDirsProvider.kt index fdab929f1..b5281a001 100644 --- a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/files/resources/impl/DefaultResourcesRootDirsProvider.kt +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/files/resources/impl/DefaultResourcesRootDirsProvider.kt @@ -6,6 +6,8 @@ import java.io.File class DefaultResourcesRootDirsProvider : ResourcesRootDirsProvider { override val logcatRootDir = File("logcat") override val screenshotsRootDir = File("screenshots") + override val originalScreenshotsRootDir = File("original_screenshots") + override val screenshotsDiffRootDir = File("screenshot_diffs") override val videoRootDir = File("video") override val viewHierarchy = File("view_hierarchy") } diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/flakysafety/scalpel/FlakySafeInterceptorScalpel.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/flakysafety/scalpel/FlakySafeInterceptorScalpel.kt index bbfe65deb..e30034a68 100644 --- a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/flakysafety/scalpel/FlakySafeInterceptorScalpel.kt +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/flakysafety/scalpel/FlakySafeInterceptorScalpel.kt @@ -12,26 +12,27 @@ import com.kaspersky.kaspresso.interceptors.behaviorkautomator.impl.flakysafety. import com.kaspersky.kaspresso.interceptors.behaviorkautomator.impl.flakysafety.FlakySafeObjectBehaviorInterceptor import com.kaspersky.kaspresso.interceptors.tolibrary.KakaoLibraryInjector import com.kaspersky.kaspresso.kaspresso.Kaspresso +import java.util.concurrent.atomic.AtomicInteger /** * The special class that removes all interceptors related to FlakySafety from kakao settings * and restore them by demand */ internal class FlakySafeInterceptorScalpel( - private val kaspresso: Kaspresso -) { - + private val kaspresso: Kaspresso, private val scalpelSwitcher: ScalpelSwitcher = ScalpelSwitcher() +) { + private val entriesCount = AtomicInteger() fun scalpFromLibs() { - scalpelSwitcher.attemptTakeScalp( + if (entriesCount.getAndIncrement() == 0) {scalpelSwitcher.attemptTakeScalp( actionToDetermineScalp = { determineScalpExistingInKaspresso() }, actionToTakeScalp = { scalpKakaoInterceptors() scalpKautomatorInterceptors() kaspresso.externalFlakySafetyScalperNotifier.scalpFlakySafety() } - ) + )} } private fun determineScalpExistingInKaspresso() = @@ -87,26 +88,29 @@ internal class FlakySafeInterceptorScalpel( } fun restoreScalpToLibs() { - scalpelSwitcher.attemptRestoreScalp { - KakaoLibraryInjector.injectKaspressoInKakao( - kaspresso.viewBehaviorInterceptors, - kaspresso.dataBehaviorInterceptors, - kaspresso.webBehaviorInterceptors, - kaspresso.viewActionWatcherInterceptors, - kaspresso.viewAssertionWatcherInterceptors, - kaspresso.atomWatcherInterceptors, - kaspresso.webAssertionWatcherInterceptors, - kaspresso.params.clickParams - ) + val nestingDepth = entriesCount.decrementAndGet() + if (nestingDepth <= 0) { // prevent restoring the interceptors in case if a "flakySafely" block is nested in an another "flakySafely" + scalpelSwitcher.attemptRestoreScalp { + KakaoLibraryInjector.injectKaspressoInKakao( + kaspresso.viewBehaviorInterceptors, + kaspresso.dataBehaviorInterceptors, + kaspresso.webBehaviorInterceptors, + kaspresso.viewActionWatcherInterceptors, + kaspresso.viewAssertionWatcherInterceptors, + kaspresso.atomWatcherInterceptors, + kaspresso.webAssertionWatcherInterceptors, + kaspresso.params.clickParams + ) - KakaoLibraryInjector.injectKaspressoInKautomator( - kaspresso.objectBehaviorInterceptors, - kaspresso.deviceBehaviorInterceptors, - kaspresso.objectWatcherInterceptors, - kaspresso.deviceWatcherInterceptors - ) + injectKaspressoInKautomator( + kaspresso.objectBehaviorInterceptors, + kaspresso.deviceBehaviorInterceptors, + kaspresso.objectWatcherInterceptors, + kaspresso.deviceWatcherInterceptors + ) - kaspresso.externalFlakySafetyScalperNotifier.restoreFlakySafety() + kaspresso.externalFlakySafetyScalperNotifier.restoreFlakySafety() + } } } } diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/internal/exceptions/RootViewWithoutFocusWrapperException.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/internal/exceptions/RootViewWithoutFocusWrapperException.kt new file mode 100644 index 000000000..a2dec6c4f --- /dev/null +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/internal/exceptions/RootViewWithoutFocusWrapperException.kt @@ -0,0 +1,12 @@ +package com.kaspersky.kaspresso.internal.exceptions + +import java.lang.RuntimeException + +/** + * A wrapper for a RootViewWithoutFocusException. It's needed because RootViewWithoutFocusException is a private class, + * so we have to use reflection to catch it and provide a user a sane way to control it. + * + * @see com.kaspersky.kaspresso.params.FlakySafetyParams.Companion.getDefaultAllowedExceptions + * @see com.kaspersky.kaspresso.internal.extensions.other.ThrowableExtKt.isAllowed + */ +class RootViewWithoutFocusWrapperException : RuntimeException() diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/internal/extensions/other/ThrowableExt.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/internal/extensions/other/ThrowableExt.kt index 2776f03a7..ffbc4d739 100644 --- a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/internal/extensions/other/ThrowableExt.kt +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/internal/extensions/other/ThrowableExt.kt @@ -1,6 +1,7 @@ package com.kaspersky.kaspresso.internal.extensions.other import com.kaspersky.kaspresso.internal.exceptions.KaspressoError +import com.kaspersky.kaspresso.internal.exceptions.RootViewWithoutFocusWrapperException import io.reactivex.exceptions.ExtCompositeException internal inline fun invokeSafely( @@ -44,15 +45,16 @@ internal fun List.throwAll() { * @return true if the given throwable is contained by [allowed] set, false otherwise. */ internal fun T.isAllowed(allowed: Set>): Boolean { - return when (this) { - is ExtCompositeException -> { - exceptions.find { e: Throwable -> - allowed.find { it.isAssignableFrom(e.javaClass) } != null - } != null - } - else -> { - allowed.find { it.isAssignableFrom(javaClass) } != null - } + return when { + javaClass.simpleName == "RootViewWithoutFocusException" -> allowed.find { + it.isAssignableFrom(RootViewWithoutFocusWrapperException::class.java) // RootViewWithoutFocusException class is private, so we cannot access it directly + } != null + + this is ExtCompositeException -> exceptions.find { + e: Throwable -> allowed.find { it.isAssignableFrom(e.javaClass) } != null + } != null + + else -> allowed.find { it.isAssignableFrom(javaClass) } != null } } diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/internal/visual/DefaultScreenshotsComparator.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/internal/visual/DefaultScreenshotsComparator.kt new file mode 100644 index 000000000..375cc7c1f --- /dev/null +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/internal/visual/DefaultScreenshotsComparator.kt @@ -0,0 +1,107 @@ +package com.kaspersky.kaspresso.internal.visual + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Color +import com.kaspersky.kaspresso.files.extensions.FileExtension +import com.kaspersky.kaspresso.files.resources.ResourceFileNamesProvider +import com.kaspersky.kaspresso.files.resources.ResourcesDirsProvider +import com.kaspersky.kaspresso.files.resources.ResourcesRootDirsProvider +import com.kaspersky.kaspresso.logger.Logger +import com.kaspersky.kaspresso.visual.ScreenshotsComparator +import com.kaspersky.kaspresso.visual.VisualTestParams +import java.io.File +import java.io.FileOutputStream +import kotlin.math.abs + +open class DefaultScreenshotsComparator( + private val visualTestParams: VisualTestParams, + private val logger: Logger, + private val resourcesRootDirsProvider: ResourcesRootDirsProvider, + private val resourcesDirsProvider: ResourcesDirsProvider, + private val resourceFileNamesProvider: ResourceFileNamesProvider, +) : ScreenshotsComparator { + + @Suppress("MagicNumber") + override fun compare(originalScreenshot: File, newScreenshot: File): Boolean { + val decodeOptions = BitmapFactory.Options().apply { inMutable = true } + val screenshot = BitmapFactory.decodeFile(newScreenshot.absolutePath, decodeOptions) + val original = BitmapFactory.decodeFile(originalScreenshot.absolutePath, decodeOptions) + + if (original.sameAs(screenshot)) { + return true + } + + val width: Int = original.width + val height: Int = original.height + val pixelsCount = width * height + val screenshotPixels = IntArray(pixelsCount) + val originalPixels = IntArray(pixelsCount) + val diffPixels = IntArray(pixelsCount) + + screenshot.getPixels(screenshotPixels, 0, width, 0, 0, width, height) + original.getPixels(originalPixels, 0, width, 0, 0, width, height) + + var totalDelta = 0 + for (pixelIndex in 0 until pixelsCount) { + val areColorsCorrect = checkColors(screenshotPixels[pixelIndex], originalPixels[pixelIndex]) + if (areColorsCorrect) { + diffPixels[pixelIndex] = Color.BLACK + } else { + totalDelta++ + diffPixels[pixelIndex] = Color.WHITE + } + } + + val diffValue = totalDelta * 100.0f / (width * height) + logger.i("${originalScreenshot.absolutePath} diff is $diffValue") + if (diffValue > visualTestParams.tolerance) { + val name = originalScreenshot.name + processScreenshotDiff(original, diffPixels, "diff_$name") + return false + } + + return true + } + + private fun checkColors(rgb1: Int, rgb2: Int): Boolean { + val colorTolerance = visualTestParams.colorTolerance + val r1 = Color.red(rgb1) + val g1 = Color.green(rgb1) + val b1 = Color.blue(rgb1) + val r2 = Color.red(rgb2) + val g2 = Color.green(rgb2) + val b2 = Color.blue(rgb2) + return abs(r1 - r2) <= colorTolerance && + abs(g1 - g2) <= colorTolerance && + abs(b1 - b2) <= colorTolerance + } + + protected open fun processScreenshotDiff(original: Bitmap, diffPixels: IntArray, diffName: String): File { + val width = original.width + val height = original.height + val diffBitmap = Bitmap.createBitmap(width, height, original.config) + diffBitmap.setPixels(diffPixels, 0, width, 0, 0, width, height) + val scaledBitmap = Bitmap.createScaledBitmap( + diffBitmap, + width, + height, + false, + ) + + val screenshotDiff = resourcesDirsProvider.provide(resourcesRootDirsProvider.screenshotsDiffRootDir) + .resolve(resourceFileNamesProvider.getFileName(diffName, FileExtension.PNG.toString())) + + scaledBitmap.compress( + Bitmap.CompressFormat.PNG, + QUALITY, + FileOutputStream(screenshotDiff), + ) + + return screenshotDiff + } + + companion object { + private const val QUALITY = 100 + } +} diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/internal/visual/DefaultVisualTestWatcher.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/internal/visual/DefaultVisualTestWatcher.kt new file mode 100644 index 000000000..3cbd6dfdb --- /dev/null +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/internal/visual/DefaultVisualTestWatcher.kt @@ -0,0 +1,57 @@ +package com.kaspersky.kaspresso.internal.visual + +import com.kaspersky.kaspresso.device.files.Files +import com.kaspersky.kaspresso.files.dirs.DirsProvider +import com.kaspersky.kaspresso.files.resources.ResourcesRootDirsProvider +import com.kaspersky.kaspresso.internal.exceptions.AdbServerException +import com.kaspersky.kaspresso.logger.Logger +import com.kaspersky.kaspresso.visual.VisualTestParams +import com.kaspersky.kaspresso.visual.VisualTestType +import com.kaspersky.kaspresso.visual.VisualTestWatcher +import java.io.File + +internal class DefaultVisualTestWatcher( + private val params: VisualTestParams, + private val logger: Logger, + private val dirsProvider: DirsProvider, + resourcesRootDirsProvider: ResourcesRootDirsProvider, + private val files: Files, +) : VisualTestWatcher { + private val diffDir = dirsProvider.provideNew(resourcesRootDirsProvider.screenshotsDiffRootDir) + private val originalScreenshotsTargetDir: File + get() { + val rootDir = dirsProvider.provideNew(File("")).absolutePath + return File(rootDir, File(params.hostScreenshotsDir).name) + } + private val newScreenshotsDir = dirsProvider.provideNew(resourcesRootDirsProvider.screenshotsRootDir) + + override fun prepare() { + logger.i("Visual test run started. Parameters: $params") + + if (params.testType == VisualTestType.Compare) { + logger.i("Pushing the screenshots unto the device...") + dirsProvider.provideCleared(diffDir) + + dirsProvider.provideCleared(originalScreenshotsTargetDir) + try { + files.push(params.hostScreenshotsDir, dirsProvider.provideNew(File("")).absolutePath) + } catch (ex: AdbServerException) { + throw RuntimeException("Failed to push screenshots. Please, check that they exist by the path: ${params.hostScreenshotsDir} (relatively to the ADB server executable", ex) + } + logger.i("Done pushing the screenshots unto the device") + } + } + + override fun cleanUp() { + logger.i("Visual test finished") + + if (params.testType == VisualTestType.Compare) { + logger.i("Pulling diff files from the device...") + files.pull(diffDir.absolutePath, ".") + logger.i("Done pulling diff files from the device") + logger.i("Pulling new screenshot files from the device...") + files.pull(newScreenshotsDir.absolutePath, ".") + logger.i("Done pulling new screenshot files from the device...") + } + } +} 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 9f3f0b11a..d571ec532 100644 --- a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/kaspresso/Kaspresso.kt +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/kaspresso/Kaspresso.kt @@ -9,6 +9,7 @@ import com.kaspersky.adbserver.common.log.logger.LogLevel import com.kaspersky.components.kautomator.KautomatorConfigurator import com.kaspersky.components.kautomator.intercept.interaction.UiDeviceInteraction import com.kaspersky.components.kautomator.intercept.interaction.UiObjectInteraction +import com.kaspersky.kaspresso.BuildConfig import com.kaspersky.kaspresso.device.Device import com.kaspersky.kaspresso.device.accessibility.Accessibility import com.kaspersky.kaspresso.device.accessibility.AccessibilityImpl @@ -115,6 +116,8 @@ import com.kaspersky.kaspresso.interceptors.watcher.view.impl.logging.LoggingVie 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.internal.visual.DefaultScreenshotsComparator +import com.kaspersky.kaspresso.internal.visual.DefaultVisualTestWatcher import com.kaspersky.kaspresso.logger.UiTestLogger import com.kaspersky.kaspresso.logger.UiTestLoggerImpl import com.kaspersky.kaspresso.params.ArtifactsPullParams @@ -130,6 +133,10 @@ 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 com.kaspersky.kaspresso.visual.ScreenshotsComparator +import com.kaspersky.kaspresso.visual.VisualTestParams +import com.kaspersky.kaspresso.visual.VisualTestType +import com.kaspersky.kaspresso.visual.VisualTestWatcher import io.github.kakaocup.kakao.Kakao /** @@ -156,7 +163,8 @@ data class Kaspresso( internal val stepWatcherInterceptors: List, internal val testRunWatcherInterceptors: List, internal val resourceFilesProvider: ResourceFilesProvider, - internal val externalFlakySafetyScalperNotifier: ExternalFlakySafetyScalperNotifier + internal val visualTestWatcher: VisualTestWatcher, + internal val externalFlakySafetyScalperNotifier: ExternalFlakySafetyScalperNotifier, ) { companion object { @@ -429,6 +437,11 @@ data class Kaspresso( */ lateinit var exploit: Exploit + /** + * Holds an implementation of [SystemLanguage] interface. If it was not specified, the default implementation is used. + */ + lateinit var systemLanguage: SystemLanguage + /** * Holds an implementation of [Language] interface. If it was not specified, the default implementation is used. */ @@ -495,6 +508,17 @@ data class Kaspresso( * If it was not specified, the default implementation is used. */ lateinit var artifactsPullParams: ArtifactsPullParams + + /** + * Holds the [VisualTestParams]. + * If it was not specified, the default implementation is used. + */ + lateinit var visualTestParams: VisualTestParams + + lateinit var screenshotsComparator: ScreenshotsComparator + + lateinit var visualTestWatcher: VisualTestWatcher + /** * Holds an implementation of [DirsProvider] interface. If it was not specified, the default implementation is used. */ @@ -753,10 +777,10 @@ data class Kaspresso( instrumentalDependencyProviderFactory.getComponentProvider(instrumentation), adbServer ) - if (!::language.isInitialized) { - val systemLanguage = SystemLanguage(instrumentation.targetContext, testLogger, hackPermissions) - language = LanguageImpl(libLogger, instrumentation, systemLanguage) - } + + if (!::systemLanguage.isInitialized) systemLanguage = SystemLanguage(instrumentation.targetContext, testLogger, hackPermissions) + if (!::language.isInitialized) language = LanguageImpl(libLogger, instrumentation, systemLanguage) + if (!::logcat.isInitialized) logcat = LogcatImpl(libLogger, adbServer) if (!::flakySafetyParams.isInitialized) flakySafetyParams = FlakySafetyParams.default() @@ -769,6 +793,15 @@ data class Kaspresso( if (!::elementLoaderParams.isInitialized) elementLoaderParams = ElementLoaderParams() if (!::clickParams.isInitialized) clickParams = ClickParams.default() if (!::artifactsPullParams.isInitialized) artifactsPullParams = ArtifactsPullParams(enabled = false) + if (!::visualTestParams.isInitialized) visualTestParams = VisualTestParams(testType = VisualTestType.valueOf(BuildConfig.VISUAL_TEST_TYPE)) + if (!::screenshotsComparator.isInitialized) screenshotsComparator = DefaultScreenshotsComparator( + visualTestParams, + testLogger, + resourcesRootDirsProvider, + resourcesDirsProvider, + resourceFileNamesProvider + ) + if (!::visualTestWatcher.isInitialized) visualTestWatcher = DefaultVisualTestWatcher(visualTestParams, libLogger, dirsProvider, resourcesRootDirsProvider, files) if (!::screenshots.isInitialized) { screenshots = ScreenshotsImpl( @@ -780,7 +813,12 @@ data class Kaspresso( instrumentalDependencyProviderFactory.getComponentProvider(instrumentation), screenshotParams ) - ) + ), + visualTestParams = visualTestParams, + screenshotsComparator = screenshotsComparator, + dirsProvider = dirsProvider, + resourceFileNamesProvider = resourceFileNamesProvider, + resourcesDirsProvider = resourcesDirsProvider, ) } @@ -993,7 +1031,8 @@ data class Kaspresso( videoParams = videoParams, elementLoaderParams = elementLoaderParams, systemDialogsSafetyParams = systemDialogsSafetyParams, - clickParams = clickParams + clickParams = clickParams, + visualTestParams = visualTestParams, ), viewActionWatcherInterceptors = viewActionWatcherInterceptors, @@ -1013,7 +1052,8 @@ data class Kaspresso( stepWatcherInterceptors = stepWatcherInterceptors, testRunWatcherInterceptors = testRunWatcherInterceptors, - externalFlakySafetyScalperNotifier = externalFlakySafetyScalperNotifier + externalFlakySafetyScalperNotifier = externalFlakySafetyScalperNotifier, + visualTestWatcher = visualTestWatcher, ) configurator.waitForIdleTimeout = kautomatorWaitForIdleSettings.waitForIdleTimeout diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/params/FlakySafetyParams.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/params/FlakySafetyParams.kt index 7bc601572..3ffa286d6 100644 --- a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/params/FlakySafetyParams.kt +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/params/FlakySafetyParams.kt @@ -4,6 +4,7 @@ import androidx.test.espresso.NoMatchingViewException import androidx.test.espresso.PerformException import androidx.test.uiautomator.StaleObjectException import com.kaspersky.components.kautomator.intercept.exception.UnfoundedUiObjectException +import com.kaspersky.kaspresso.internal.exceptions.RootViewWithoutFocusWrapperException import junit.framework.AssertionFailedError /** @@ -29,7 +30,8 @@ class FlakySafetyParams( AssertionFailedError::class.java, UnfoundedUiObjectException::class.java, StaleObjectException::class.java, - IllegalStateException::class.java + IllegalStateException::class.java, + RootViewWithoutFocusWrapperException::class.java, ) fun default() = FlakySafetyParams( diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/params/Params.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/params/Params.kt index c9d9eb6e9..40c64c3ac 100644 --- a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/params/Params.kt +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/params/Params.kt @@ -1,5 +1,7 @@ package com.kaspersky.kaspresso.params +import com.kaspersky.kaspresso.visual.VisualTestParams + /** * The facade class for all Kaspresso parameters. */ @@ -12,5 +14,6 @@ data class Params( val videoParams: VideoParams, val elementLoaderParams: ElementLoaderParams, val systemDialogsSafetyParams: SystemDialogsSafetyParams, - val clickParams: ClickParams + val clickParams: ClickParams, + val visualTestParams: VisualTestParams, ) diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/params/SystemDialogsSafetyParams.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/params/SystemDialogsSafetyParams.kt index e04cc6ea7..6ba974678 100644 --- a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/params/SystemDialogsSafetyParams.kt +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/params/SystemDialogsSafetyParams.kt @@ -1,9 +1,13 @@ package com.kaspersky.kaspresso.params data class SystemDialogsSafetyParams( - val shouldIgnoreKeyboard: Boolean + val shouldIgnoreKeyboard: Boolean, + val shouldIgnorePermissionDialogs: Boolean ) { companion object { - fun default() = SystemDialogsSafetyParams(shouldIgnoreKeyboard = false) + fun default() = SystemDialogsSafetyParams( + shouldIgnoreKeyboard = false, + shouldIgnorePermissionDialogs = false + ) } } diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/systemsafety/SystemDialogSafetyProviderImpl.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/systemsafety/SystemDialogSafetyProviderImpl.kt index 52cbdec5c..394bd3ed8 100644 --- a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/systemsafety/SystemDialogSafetyProviderImpl.kt +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/systemsafety/SystemDialogSafetyProviderImpl.kt @@ -32,9 +32,9 @@ class SystemDialogSafetyProviderImpl( private val attemptsToSuppress: List<(UiDevice, AdbServer) -> Unit> = listOf( { _, adbServer -> - adbServer.performShell("input keyevent KEYCODE_BACK") - adbServer.performShell("input keyevent KEYCODE_ENTER") - adbServer.performShell("input keyevent KEYCODE_ENTER") + adbServer.performShell("input", listOf("keyevent", "KEYCODE_BACK")) + adbServer.performShell("input", listOf("keyevent", "KEYCODE_ENTER")) + adbServer.performShell("input", listOf("keyevent", "KEYCODE_ENTER")) }, { uiDevice, _ -> uiDevice.wait(Until.findObject(By.res("android:id/button1")), DEFAULT_TIMEOUT).click() }, { uiDevice, _ -> uiDevice.wait(Until.findObject(By.res("android:id/closeButton")), DEFAULT_TIMEOUT).click() }, @@ -111,7 +111,12 @@ class SystemDialogSafetyProviderImpl( */ private fun isAndroidSystemDetected(): Boolean { with(uiDevice) { - var isSystemDialogVisible = SystemDialogSafetyPattern.values().any { isVisible(By.pkg(it.pattern).clazz(FrameLayout::class.java)) } + var isSystemDialogVisible = if (systemDialogsSafetyParams.shouldIgnorePermissionDialogs) { + SystemDialogSafetyPattern.values().filter { it != SystemDialogSafetyPattern.PERMISSION_API30 && it != SystemDialogSafetyPattern.PERMISSION_API23 } + .any { isVisible(By.pkg(it.pattern).clazz(FrameLayout::class.java)) } + } else { + SystemDialogSafetyPattern.values().any { isVisible(By.pkg(it.pattern).clazz(FrameLayout::class.java)) } + } if (systemDialogsSafetyParams.shouldIgnoreKeyboard) { val isKeyboardVisible = isVisible(By.pkg(Pattern.compile("\\S*google.android.inputmethod\\S*")).clazz(FrameLayout::class.java)) diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/testcases/api/testcase/DocLocScreenshotTestCase.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/testcases/api/testcase/DocLocScreenshotTestCase.kt index 2d1a75c88..7a2e4a311 100644 --- a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/testcases/api/testcase/DocLocScreenshotTestCase.kt +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/testcases/api/testcase/DocLocScreenshotTestCase.kt @@ -99,6 +99,8 @@ abstract class DocLocScreenshotTestCase( resourcesRootDirsProvider = object : ResourcesRootDirsProvider { override val logcatRootDir: File = File("logcat") override val screenshotsRootDir = screenshotsDirectory + override val originalScreenshotsRootDir = File("original_screenshots") + override val screenshotsDiffRootDir: File = File("screenshot_diffs") override val videoRootDir: File = File("video") override val viewHierarchy: File = File("view_hierarchy") }, diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/testcases/api/testcase/VisualTestCase.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/testcases/api/testcase/VisualTestCase.kt new file mode 100644 index 000000000..5bd1ddfce --- /dev/null +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/testcases/api/testcase/VisualTestCase.kt @@ -0,0 +1,26 @@ +package com.kaspersky.kaspresso.testcases.api.testcase + +import com.kaspersky.kaspresso.kaspresso.Kaspresso +import com.kaspersky.kaspresso.testcases.core.testcontext.BaseTestContext +import com.kaspersky.kaspresso.testcases.core.testcontext.TestContext + +abstract class VisualTestCase( + kaspressoBuilder: Kaspresso.Builder = Kaspresso.Builder.simple(), +) : TestCase(kaspressoBuilder) { + + open fun runScreenshotTest( + before: (BaseTestContext.() -> Unit)? = null, + after: (BaseTestContext.() -> Unit)? = null, + test: TestContext.() -> Unit, + ) = before { + kaspresso.visualTestWatcher.prepare() + before?.invoke(this) + }.after { + kaspresso.visualTestWatcher.cleanUp() + after?.invoke(this) + }.run(test) + + open fun assertScreenshot(tag: String, isFullWindow: Boolean = false) { + device.screenshots.assert(tag, isFullWindow) + } +} diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/visual/ScreenshotsComparator.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/visual/ScreenshotsComparator.kt new file mode 100644 index 000000000..53e775853 --- /dev/null +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/visual/ScreenshotsComparator.kt @@ -0,0 +1,7 @@ +package com.kaspersky.kaspresso.visual + +import java.io.File + +interface ScreenshotsComparator { + fun compare(originalScreenshot: File, newScreenshot: File): Boolean +} diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/visual/VisualTestParams.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/visual/VisualTestParams.kt new file mode 100644 index 000000000..f2bc93e3d --- /dev/null +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/visual/VisualTestParams.kt @@ -0,0 +1,27 @@ +package com.kaspersky.kaspresso.visual + +/** + * @see com.kaspersky.kaspresso.internal.visual.DefaultScreenshotsComparator + */ +data class VisualTestParams( + /** + * Controls whether to take the new reference screenshots and save them or use the old ones and compare them to the ones being taken during the test + */ + val testType: VisualTestType = VisualTestType.Record, + /** + * The path with the reference screenshots. Used to save the new reference screenshots if testType is set to the VisualTestType.Record + * or to push screenshot files if testType is set to VisualTestType.Compare + */ + val hostScreenshotsDir: String = "original_screenshots", + /** + * The color threshold to mark the single pixel different from the other one. + * @see com.kaspersky.kaspresso.internal.visual.DefaultScreenshotsComparator.checkColors + */ + val colorTolerance: Int = 1, + /** + * Controls the threshold of the screenshots difference. The value is in percents. Screenshots with difference less than this value + * are "acceptable" and don't fail the test + * @see com.kaspersky.kaspresso.internal.visual.DefaultScreenshotsComparator.compare + */ + val tolerance: Float = 0.3f, +) diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/visual/VisualTestType.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/visual/VisualTestType.kt new file mode 100644 index 000000000..3da2e5b7d --- /dev/null +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/visual/VisualTestType.kt @@ -0,0 +1,5 @@ +package com.kaspersky.kaspresso.visual + +enum class VisualTestType { + Record, Compare +} diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/visual/VisualTestWatcher.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/visual/VisualTestWatcher.kt new file mode 100644 index 000000000..39cb6e750 --- /dev/null +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/visual/VisualTestWatcher.kt @@ -0,0 +1,6 @@ +package com.kaspersky.kaspresso.visual + +interface VisualTestWatcher { + fun prepare() + fun cleanUp() +} diff --git a/kaspresso/src/test/java/com/kaspersky/kaspresso/flakysafety/scalpel/FlakySafeInterceptorScalpelTest.kt b/kaspresso/src/test/java/com/kaspersky/kaspresso/flakysafety/scalpel/FlakySafeInterceptorScalpelTest.kt new file mode 100644 index 000000000..a50a23ca0 --- /dev/null +++ b/kaspresso/src/test/java/com/kaspersky/kaspresso/flakysafety/scalpel/FlakySafeInterceptorScalpelTest.kt @@ -0,0 +1,36 @@ +package com.kaspersky.kaspresso.flakysafety.scalpel + +import com.kaspersky.kaspresso.kaspresso.Kaspresso +import io.mockk.mockk +import io.mockk.verify +import org.junit.Test + +class FlakySafeInterceptorScalpelTest { + + private val kaspresso = mockk(relaxed = true) + private val scalpelSwitcher = mockk(relaxed = true) + private val scalpel = FlakySafeInterceptorScalpel(kaspresso, scalpelSwitcher) + @Test + fun `GIVEN nested flaky safety WHEN trying to scalp flaky safety interceptors THEN should not scalp interceptors`() { + scalpel.scalpFromLibs() + scalpel.scalpFromLibs() + scalpel.scalpFromLibs() + + verify(exactly = 1) { scalpelSwitcher.attemptTakeScalp(any(), any()) } + } + + @Test + fun `GIVEN nested flaky safety WHEN trying to restore flaky safety interceptors THEN should not restore interceptors`() { + scalpel.scalpFromLibs() + scalpel.scalpFromLibs() + scalpel.scalpFromLibs() + + scalpel.restoreScalpToLibs() + scalpel.restoreScalpToLibs() + + verify(exactly = 0) { scalpelSwitcher.attemptRestoreScalp(any()) } + + scalpel.restoreScalpToLibs() + verify { scalpelSwitcher.attemptRestoreScalp(any()) } + } +} diff --git a/samples/kaspresso-allure-support-sample/src/androidTest/kotlin/com/kaspersky/kaspresso/alluresupport/sample/AllureVisualTest.kt b/samples/kaspresso-allure-support-sample/src/androidTest/kotlin/com/kaspersky/kaspresso/alluresupport/sample/AllureVisualTest.kt new file mode 100644 index 000000000..41339aabc --- /dev/null +++ b/samples/kaspresso-allure-support-sample/src/androidTest/kotlin/com/kaspersky/kaspresso/alluresupport/sample/AllureVisualTest.kt @@ -0,0 +1,36 @@ +package com.kaspersky.kaspresso.alluresupport.sample + +import android.Manifest +import androidx.test.ext.junit.rules.activityScenarioRule +import androidx.test.rule.GrantPermissionRule +import com.kaspersky.components.alluresupport.visual.AllureVisualTestCase +import com.kaspersky.kaspresso.alluresupport.sample.screen.MainScreen +import org.junit.Rule +import org.junit.Test + +class AllureVisualTest : AllureVisualTestCase(failEarly = false) { + @get:Rule + val activityRule = activityScenarioRule() + + @get:Rule + val runtimePermissionRule: GrantPermissionRule = GrantPermissionRule.grant( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.READ_EXTERNAL_STORAGE + ) + + @Test + fun counter() = runScreenshotTest { + step("Assert screenshot") { + assertScreenshot("some_tag") + } + + step("Usual checks") { + MainScreen { + incrementButton { isDisplayed() } + decrementButton { isDisplayed() } + clearButton { isDisplayed() } + valueText { isDisplayed() } + } + } + } +} diff --git a/samples/kaspresso-allure-support-sample/src/main/AndroidManifest.xml b/samples/kaspresso-allure-support-sample/src/main/AndroidManifest.xml index 3d542e133..8caf983ce 100644 --- a/samples/kaspresso-allure-support-sample/src/main/AndroidManifest.xml +++ b/samples/kaspresso-allure-support-sample/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + rootDir.resolve("") else -> rootDir.resolve(subDir).resolve("") } - return dirsProvider.provideCleared(resultsDir) + return if (provideCleared) { + dirsProvider.provideCleared(resultsDir) + } else { + resultsDir.createDirIfNeeded() + } } } diff --git a/samples/kaspresso-sample/src/androidTest/kotlin/com/kaspersky/kaspressample/simple_tests/CustomizedSimpleTest.kt b/samples/kaspresso-sample/src/androidTest/kotlin/com/kaspersky/kaspressample/simple_tests/CustomizedSimpleTest.kt index 4599507e2..d657a25af 100644 --- a/samples/kaspresso-sample/src/androidTest/kotlin/com/kaspersky/kaspressample/simple_tests/CustomizedSimpleTest.kt +++ b/samples/kaspresso-sample/src/androidTest/kotlin/com/kaspersky/kaspressample/simple_tests/CustomizedSimpleTest.kt @@ -44,6 +44,8 @@ class CustomizedSimpleTest : TestCase( resourcesRootDirsProvider = object : ResourcesRootDirsProvider { override val logcatRootDir = File("custom_logcat") override val screenshotsRootDir = File("custom_screenshots") + override val originalScreenshotsRootDir = File("custom_original_screenshots") + override val screenshotsDiffRootDir = File("custom_screenshot_diffs") override val videoRootDir = File("custom_video") override val viewHierarchy = File("custom_view_hierarchy") } @@ -52,7 +54,7 @@ class CustomizedSimpleTest : TestCase( dirsProvider = dirsProvider, resourcesDirNameProvider = resourcesDirNameProvider ) { - override fun provide(dest: File, subDir: String?): File = + override fun provide(dest: File, subDir: String?, provideCleared: Boolean): File = dirsProvider.provideCleared(dirsProvider.provideNew(dest)) } diff --git a/samples/kaspresso-sample/src/androidTest/kotlin/com/kaspersky/kaspressample/visual/VisualTestSample.kt b/samples/kaspresso-sample/src/androidTest/kotlin/com/kaspersky/kaspressample/visual/VisualTestSample.kt new file mode 100644 index 000000000..de5117910 --- /dev/null +++ b/samples/kaspresso-sample/src/androidTest/kotlin/com/kaspersky/kaspressample/visual/VisualTestSample.kt @@ -0,0 +1,35 @@ +package com.kaspersky.kaspressample.visual + +import android.Manifest +import androidx.test.ext.junit.rules.activityScenarioRule +import androidx.test.rule.GrantPermissionRule +import com.kaspersky.kaspressample.MainActivity +import com.kaspersky.kaspressample.screen.MainScreen +import com.kaspersky.kaspresso.testcases.api.testcase.VisualTestCase +import org.junit.Rule +import org.junit.Test + +class VisualTestSample : VisualTestCase() { + @get:Rule + val runtimePermissionRule: GrantPermissionRule = GrantPermissionRule.grant( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.READ_MEDIA_IMAGES, + ) + + @get:Rule + val activityRule = activityScenarioRule() + + @Test + fun test() = runScreenshotTest { + step("Open Simple Screen") { + MainScreen { + simpleButton { + isVisible() + click() + assertScreenshot("some_tag") + } + } + } + } +}