diff --git a/.github/workflows/pull-request-jobs.yml b/.github/workflows/pull-request-jobs.yml index c226468..f6915ca 100644 --- a/.github/workflows/pull-request-jobs.yml +++ b/.github/workflows/pull-request-jobs.yml @@ -130,4 +130,11 @@ jobs: with: name: Emulator-Test-Results-${{ matrix.api-level }} path: ./**/build/testscreenshots/* + retention-days: 1 + - name: Upload Logcat Logs + uses: actions/upload-artifact@v2 + if: always() + with: + name: Emulator-Logcat-Logs-${{ matrix.api-level }} + path: ./**/build/logcat.txt retention-days: 1 \ No newline at end of file diff --git a/.github/workflows/screenshot-tests.yml b/.github/workflows/screenshot-tests.yml new file mode 100644 index 0000000..776ca31 --- /dev/null +++ b/.github/workflows/screenshot-tests.yml @@ -0,0 +1,54 @@ +name: Verify Screenshots can be created and pulled + +on: + workflow_dispatch: + + +env: + GITHUB_USERNAME: "fknives" + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +jobs: + run-screenshot-test-on-emulator: + runs-on: macos-latest + strategy: + matrix: + api-level: [ 21, 23, 24, 26, 28, 29, 30, 31 ] + fail-fast: false + steps: + - name: checkout + uses: actions/checkout@v3 + - name: Setup Java + uses: actions/setup-java@v2 + with: + distribution: 'adopt' + java-version: '11' + - name: Gradle cache + uses: gradle/gradle-build-action@v2 + - name: AVD cache + uses: actions/cache@v3 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-${{ matrix.api-level }} + - name: create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + arch: 'x86_64' + api-level: ${{ matrix.api-level }} + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: false + script: echo "Generated AVD snapshot for caching." + - name: Run Android Tests + uses: reactivecircus/android-emulator-runner@v2 + with: + arch: 'x86_64' + api-level: ${{ matrix.api-level }} + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: ./pullscreenshottest.sh \ No newline at end of file diff --git a/app/src/androidTest/java/org/fnives/test/showcase/rule/ScreenshotTest.kt b/app/src/androidTest/java/org/fnives/test/showcase/rule/ScreenshotTest.kt new file mode 100644 index 0000000..b124995 --- /dev/null +++ b/app/src/androidTest/java/org/fnives/test/showcase/rule/ScreenshotTest.kt @@ -0,0 +1,38 @@ +package org.fnives.test.showcase.rule + +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.fnives.test.showcase.android.testutil.activity.SafeCloseActivityRule +import org.fnives.test.showcase.android.testutil.screenshot.ScreenshotRule +import org.fnives.test.showcase.android.testutil.synchronization.MainDispatcherTestRule +import org.fnives.test.showcase.testutils.ReloadKoinModulesIfNecessaryTestRule +import org.fnives.test.showcase.ui.splash.SplashActivity +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith +import org.koin.test.KoinTest + +@Suppress("TestFunctionName") +@RunWith(AndroidJUnit4::class) +class ScreenshotTest : KoinTest { + + private lateinit var activityScenario: ActivityScenario + + private val mainDispatcherTestRule = MainDispatcherTestRule() + + @Rule + @JvmField + val ruleOrder: RuleChain = RuleChain.outerRule(mainDispatcherTestRule) + .around(ReloadKoinModulesIfNecessaryTestRule()) + .around(SafeCloseActivityRule { activityScenario }) + .around(ScreenshotRule(prefix = "screenshot-rule", takeOnSuccess = true)) + + /** GIVEN loggedInState WHEN opened after some time THEN MainActivity is started */ + @Test + fun screenshot() { + activityScenario = ActivityScenario.launch(SplashActivity::class.java) + activityScenario.moveToState(Lifecycle.State.RESUMED) + } +} diff --git a/app/verifyfiles.sh b/app/verifyfiles.sh new file mode 100755 index 0000000..f8685fd --- /dev/null +++ b/app/verifyfiles.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env sh + +# if given folder is empty throws error +if [ -d "$1" ]; then + if [ $(ls -A $1 | wc -l) ]; + then + echo "" + else + exit 1 + fi +else + exit 1 +fi diff --git a/gradlescripts/deploy.aar.gradle b/gradlescripts/deploy.aar.gradle index fcc77df..fb32765 100644 --- a/gradlescripts/deploy.aar.gradle +++ b/gradlescripts/deploy.aar.gradle @@ -23,7 +23,6 @@ afterEvaluate { from components.release groupId "$testUtilGroupId" - println("$testUtilArtifactId") version "$testUtilVersion" artifactId "$testUtilArtifactId" artifact sourcesJar diff --git a/gradlescripts/pull-screenshots.gradle b/gradlescripts/pull-screenshots.gradle index f04dacc..ae7f4fe 100644 --- a/gradlescripts/pull-screenshots.gradle +++ b/gradlescripts/pull-screenshots.gradle @@ -20,7 +20,7 @@ def findAdbFromLocal = { def sdkDir = properties.getProperty('sdk.dir') return "$sdkDir/platform-tools/adb" } else { - System.err.println("WARNING: SDK dir not found by local properties, returning static: $System.env.HOME/Library/Android/sdk/platform-tools/adb") + println("WARNING: SDK dir not found by local properties, returning static: $System.env.HOME/Library/Android/sdk/platform-tools/adb") return "$System.env.HOME/Library/Android/sdk/platform-tools/adb" } } @@ -29,47 +29,64 @@ def packageName = propertyOrNull("screenshotsPackageName") ?: "$android.defaultC def screenshotDirectory = propertyOrNull("screenshotsDirectory") ?: "test-screenshots" def savePath = propertyOrNull("screenshotsSavePath") ?: "build/testscreenshots/" def adb = propertyOrNull("adbPath") ?: findAdbFromLocal() -def internalFullPath = "/sdcard/Android/data/$packageName/files/Pictures/$screenshotDirectory/" -def deprecatedFullPath = "/sdcard/Pictures/$packageName/$screenshotDirectory/" +def contextInternalFullPath = "/data/data/$packageName/files/$screenshotDirectory/" +def contextExternalFullPath = "/sdcard/Android/data/$packageName/files/Pictures/$screenshotDirectory/" +def environmentExternalFullPath = "/sdcard/Pictures/$packageName/$screenshotDirectory/" -task pullScreenshotsInternal(type: Exec) { +task pullScreenshotsContextInternal(type: Exec) { group = 'Test-Screenshots' - description = 'Pull screenshots From internal Storage' + description = 'Pull screenshots From context.external Storage' ignoreExitValue(true) - commandLine "$adb", 'pull', "$internalFullPath", "$savePath/" + commandLine "$adb", 'pull', "$contextInternalFullPath", "$savePath/" } -task pullScreenshotsDeprecated(type: Exec) { +task pullScreenshotsContextExternal(type: Exec) { group = 'Test-Screenshots' - description = 'Pull screenshots From deprecated External Storage' + description = 'Pull screenshots From context.external Storage' ignoreExitValue(true) - commandLine "$adb", 'pull', "$deprecatedFullPath", "$savePath/" + commandLine "$adb", 'pull', "$contextExternalFullPath", "$savePath/" } -task pullScreenshots(dependsOn: [pullScreenshotsInternal, pullScreenshotsDeprecated]) { +task pullScreenshotsEnvironmentExternal(type: Exec) { + group = 'Test-Screenshots' + description = 'Pull screenshots From environment.external Storage' + + ignoreExitValue(true) + commandLine "$adb", 'pull', "$environmentExternalFullPath", "$savePath/" +} + +task pullScreenshots(dependsOn: [pullScreenshotsContextInternal, pullScreenshotsContextExternal, pullScreenshotsEnvironmentExternal]) { group = 'Test-Screenshots' description = 'Pull screenshots From Device' } -task removeScreenshotsFromDeviceInternal(type: Exec) { +task removeScreenshotsFromDeviceContextInternal(type: Exec) { group = 'Test-Screenshots' - description = 'Remove screenshots From internal Storage' + description = 'Remove screenshots From context.internal Storage' ignoreExitValue(true) - commandLine "$adb", 'shell', 'rm', '-r', "$internalFullPath" + commandLine "$adb", 'shell', 'rm', '-r', "$contextInternalFullPath" } -task removeScreenshotsFromDeviceDeprecated(type: Exec) { +task removeScreenshotsFromDeviceContextExternal(type: Exec) { group = 'Test-Screenshots' - description = 'Remove screenshots From deprecated External Storage' + description = 'Remove screenshots From context.external Storage' ignoreExitValue(true) - commandLine "$adb", 'shell', 'rm', '-r', "$deprecatedFullPath" + commandLine "$adb", 'shell', 'rm', '-r', "$contextExternalFullPath" } -task removeScreenshotsFromDevice(dependsOn: [removeScreenshotsFromDeviceInternal, removeScreenshotsFromDeviceDeprecated]) { +task removeScreenshotsFromDeviceEnvironmentExternal(type: Exec) { + group = 'Test-Screenshots' + description = 'Remove screenshots From environment.external Storage' + + ignoreExitValue(true) + commandLine "$adb", 'shell', 'rm', '-r', "$environmentExternalFullPath" +} + +task removeScreenshotsFromDevice(dependsOn: [removeScreenshotsFromDeviceContextInternal, removeScreenshotsFromDeviceContextExternal, removeScreenshotsFromDeviceEnvironmentExternal]) { group = 'Test-Screenshots' description = 'Remove screenshots From Device' } @@ -81,8 +98,22 @@ task removeLocalScreenshots(type: Delete) { delete files("$savePath") } +task saveLogcatLogs(type: Exec) { + group = 'Test-Screenshots' + description = 'Show Logcat' + + doFirst { + standardOutput = new FileOutputStream("${buildDir}/logcat.txt") + } + commandLine "$adb", 'logcat', '-d' +} + +task hasScreenshots(type: Exec) { + commandLine "sh", "./verifyfiles.sh", "$savePath" +} + afterEvaluate { - connectedDebugAndroidTest.finalizedBy pullScreenshots - pullScreenshots.finalizedBy removeScreenshotsFromDevice + connectedDebugAndroidTest.finalizedBy saveLogcatLogs + saveLogcatLogs.finalizedBy pullScreenshots clean.dependsOn(removeLocalScreenshots) } \ No newline at end of file diff --git a/pullscreenshottest.sh b/pullscreenshottest.sh new file mode 100755 index 0000000..3208173 --- /dev/null +++ b/pullscreenshottest.sh @@ -0,0 +1,3 @@ +./gradlew clean +./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=org.fnives.test.showcase.rule.ScreenshotTest +./gradlew app:hasScreenshots \ No newline at end of file diff --git a/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/screenshot/ScreenshotRule.kt b/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/screenshot/ScreenshotRule.kt index 36a7756..b27ab27 100644 --- a/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/screenshot/ScreenshotRule.kt +++ b/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/screenshot/ScreenshotRule.kt @@ -1,6 +1,7 @@ package org.fnives.test.showcase.android.testutil.screenshot import android.graphics.Bitmap +import android.util.Log import androidx.test.runner.screenshot.ScreenCapture import androidx.test.runner.screenshot.ScreenCaptureProcessor import androidx.test.runner.screenshot.Screenshot @@ -38,28 +39,30 @@ class ScreenshotRule( } } - fun takeScreenshot(prefix: String = this.prefix, baseName: String) { + fun takeScreenshot(prefix: String = this.prefix, baseName: String, capture: ScreenCapture = Screenshot.capture()) { val fileName = if (timestampSuffix) { "$prefix-$baseName-${System.currentTimeMillis()}" } else { "$prefix-$baseName" } - takeScreenshot(filename = fileName) + takeScreenshot(filename = fileName, capture = capture) } @Suppress("PrintStackTrace") - private fun takeScreenshot(filename: String) { - val capture: ScreenCapture = Screenshot.capture() + private fun takeScreenshot(filename: String, capture: ScreenCapture) { capture.name = filename capture.format = Bitmap.CompressFormat.JPEG try { capture.process(setOf(processor)) + Log.d(TAG, "Saved image: $filename") } catch (e: IOException) { + Log.d(TAG, "Couldn't save image: $e") e.printStackTrace() } } companion object { + const val TAG = "Screenshot Rule" val Description.testScreenshotName get() = "${testClass.simpleName}-$methodName" val Description.beforeTestScreenshotName get() = "$testScreenshotName-BEFORE" val Description.successTestScreenshotName get() = "$testScreenshotName-SUCCESS" diff --git a/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/screenshot/basicScreenCaptureProcessor.kt b/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/screenshot/basicScreenCaptureProcessor.kt index be6375d..9d2685f 100644 --- a/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/screenshot/basicScreenCaptureProcessor.kt +++ b/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/screenshot/basicScreenCaptureProcessor.kt @@ -2,18 +2,39 @@ package org.fnives.test.showcase.android.testutil.screenshot import android.os.Build import android.os.Environment +import android.util.Log import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.runner.screenshot.ScreenCaptureProcessor import androidx.test.runner.screenshot.basicScreenCaptureProcessor import java.io.File -fun basicScreenCaptureProcessor(subDir: String = "test-screenshots") = - basicScreenCaptureProcessor(File(getTestPicturesDir(), subDir)) +fun basicScreenCaptureProcessor(subDir: String = "test-screenshots"): ScreenCaptureProcessor { + val directory = File(getTestPicturesDir(), subDir) + Log.d(ScreenshotRule.TAG, "directory to save screenshots = ${directory.absolutePath}") + return basicScreenCaptureProcessor(File(getTestPicturesDir(), subDir)) +} +/** + * BasicScreenCaptureProcessor seems to work differently on API versions, + * based on where we have access to save and pull the images from. + * + * see example issue: https://github.com/android/android-test/issues/818 + */ @Suppress("DEPRECATION") -fun getTestPicturesDir() = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - InstrumentationRegistry.getInstrumentation().targetContext.getExternalFilesDir(Environment.DIRECTORY_PICTURES) - } else { +fun getTestPicturesDir(): File? = + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + Log.d(ScreenshotRule.TAG, "context.internal folder") + + InstrumentationRegistry.getInstrumentation().targetContext.filesDir + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val packageName = InstrumentationRegistry.getInstrumentation().targetContext.packageName - File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), packageName) + val environmentFolder = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) + val externalFolder = File(environmentFolder, packageName) + Log.d(ScreenshotRule.TAG, "environment.external folder") + + externalFolder + } else { + Log.d(ScreenshotRule.TAG, "context.external folder") + + InstrumentationRegistry.getInstrumentation().targetContext.getExternalFilesDir(Environment.DIRECTORY_PICTURES) }