Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add simple smoke test on Android #1260

Merged
merged 1 commit into from
Jun 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,35 @@ jobs:
- name: Build Android App (skipping benchmark variant)
run: |
./gradlew spotlessCheck \
:android-app:app:bundle \
:android-app:app:build \
jvmTest \
lint \
-x :android-app:app:assembleStandardBenchmark \
-x :android-app:app:bundleStandardBenchmark
:android-app:app:bundle \
:android-app:app:build \
jvmTest \
lint \
-x :android-app:app:assembleStandardBenchmark \
-x :android-app:app:bundleStandardBenchmark
- name: Run smoke tests on Gradle Managed Device
# --info used to add a repro to https://issuetracker.google.com/issues/193118030
# config cache is disabled due to https://issuetracker.google.com/issues/262270582
run: |
./gradlew api31QaDebugAndroidTest \
-Dorg.gradle.workers.max=1 \
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect" \
--info \
--no-configuration-cache
- name: Clean secrets
if: always()
run: ./release/clean-secrets.sh

- name: Sanitize output file names (https://issuetracker.google.com/issues/263305468)
run: |
sanitizefilename() {
mv "${1}" "${1//[^A-Za-z0-9._-]/_}"
}
export -f sanitizefilename
find android-app/app/build/outputs -type f -exec bash -c 'sanitizefilename "$0"' {} \;
- name: Upload build outputs
if: always()
uses: actions/upload-artifact@v3
Expand Down
21 changes: 17 additions & 4 deletions android-app/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,18 @@ android {
versionCode = (android.defaultConfig.versionCode ?: 0) + 1
}
}

testOptions {
managedDevices {
devices {
create<com.android.build.api.dsl.ManagedVirtualDevice>("api31") {
device = "Pixel 6"
apiLevel = 31
systemImageSource = "aosp"
}
}
}
}
}

androidComponents {
Expand Down Expand Up @@ -202,10 +214,11 @@ dependencies {

qaImplementation(libs.leakCanary)

testImplementation(libs.junit)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)
testImplementation(libs.androidx.test.rules)
androidTestImplementation(projects.androidApp.commonTest)
androidTestImplementation(libs.androidx.uiautomator)
androidTestImplementation(libs.junit)
androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.androidx.test.rules)
}

if (file("google-services.json").exists()) {
Expand Down
48 changes: 48 additions & 0 deletions android-app/app/src/androidTest/kotlin/app/tivi/SmokeTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Copyright 2023, Google LLC, Christopher Banes and the Tivi project contributors
// SPDX-License-Identifier: Apache-2.0

package app.tivi

import android.content.Intent
import androidx.test.core.app.ApplicationProvider
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.Until
import app.tivi.app.test.AppScenarios
import org.junit.Assert.assertNotNull
import org.junit.Test

class SmokeTest {

@Test
fun openApp() {
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())

startAppAndWait(device)

// Run through the main navigation items
AppScenarios.mainNavigationItems(device)
}
}

private fun startAppAndWait(device: UiDevice) {
device.pressHome()

// Wait for launcher
val launcherPackage = device.launcherPackageName
assertNotNull(launcherPackage)
device.wait(Until.hasObject(By.pkg(launcherPackage).depth(0)), 5_000)

// Launch the app
val context = ApplicationProvider.getApplicationContext<TiviApplication>()
val packageName = context.packageName
val intent = context.packageManager.getLaunchIntentForPackage(packageName)!!.apply {
// Clear out any previous instances
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
}
context.startActivity(intent)

// Wait for the app to appear
device.wait(Until.hasObject(By.pkg(packageName).depth(0)), 5_000)
}
2 changes: 2 additions & 0 deletions android-app/benchmark/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ dependencies {
implementation(libs.androidx.uiautomator)
implementation(libs.androidx.test.junit)
implementation(libs.kotlin.coroutines.android)

implementation(projects.androidApp.commonTest)
}

androidComponents {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,8 @@

package app.tivi.benchmark

import android.os.SystemClock
import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.test.uiautomator.By
import androidx.test.uiautomator.BySelector
import androidx.test.uiautomator.Direction
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.Until
import app.tivi.app.test.AppScenarios
import org.junit.Rule
import org.junit.Test

Expand All @@ -26,102 +20,7 @@ class BaselineProfileGenerator {
maxIterations = 8,
) {
startActivityAndWait()
device.waitForIdle()

// -------------
// Discover
// -------------
device.testDiscover() || return@collectBaselineProfile
device.navigateFromDiscoverToShowDetails()

// -------------
// Show Details
// -------------
device.testShowDetails() || return@collectBaselineProfile
device.navigateFromShowDetailsToSeasons()

// -------------
// Seasons
// -------------
device.testSeasons() || return@collectBaselineProfile
device.navigateFromSeasonsToEpisodeDetails()

// -------------
// Episode details
// -------------
device.testEpisodeDetails() || return@collectBaselineProfile
}

private fun UiDevice.testDiscover(): Boolean {
// Scroll one of the Discover Carousels
waitForObject(By.res("discover_carousel"))
.scroll(Direction.RIGHT, 1f)
waitForObject(By.res("discover_carousel"))
.scroll(Direction.LEFT, 1f)

return wait(Until.hasObject(By.res("discover_carousel_item")), 5_000)
}

private fun UiDevice.navigateFromDiscoverToShowDetails() {
// Open a show from one of the carousels
waitForObject(By.res("discover_carousel_item")).click()
waitForIdle()
}

private fun UiDevice.testShowDetails(): Boolean {
// Follow the show
waitForObject(By.res("show_details_follow_button"))
.click()

// Wait 10 seconds for a season item to show
for (i in 1..10) {
if (hasObject(By.res("show_details_season_item"))) {
return true
}

SystemClock.sleep(1000)

// Scroll to the end to show the seasons
waitForObject(By.res("show_details_lazycolumn"))
.scroll(Direction.DOWN, 1f)
}

return false
}

private fun UiDevice.navigateFromShowDetailsToSeasons() {
waitForObject(By.res("show_details_season_item")).click()
waitForIdle()
}

private fun UiDevice.testSeasons(): Boolean {
// Not much to test here at the moment
return wait(Until.hasObject(By.res("show_seasons_episode_item")), 5_000)
}

private fun UiDevice.navigateFromSeasonsToEpisodeDetails() {
waitForObject(By.res("show_seasons_episode_item")).click()
waitForIdle()
// Run through the main navigation items
AppScenarios.mainNavigationItems(device)
}

private fun UiDevice.testEpisodeDetails(): Boolean {
with(waitForObject(By.res("episode_details"))) {
// Need to 'inset' the gesture so that we don't swipe
// the notification tray down
setGestureMargin(displayWidth / 10)

// Swipe the bottom sheet 'up', then 'down'
scroll(Direction.DOWN, 0.8f)
scroll(Direction.UP, 0.8f)
}
return true
}
}

private fun UiDevice.waitForObject(selector: BySelector, timeout: Long = 5_000): UiObject2 {
if (wait(Until.hasObject(selector), timeout)) {
return findObject(selector)
}

error("Object with selector [$selector] not found")
}
17 changes: 17 additions & 0 deletions android-app/common-test/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright 2023, Christopher Banes and the Tivi project contributors
// SPDX-License-Identifier: Apache-2.0


plugins {
id("app.tivi.android.library")
id("app.tivi.kotlin.android")
}

android {
namespace = "app.tivi.app.test"
}

dependencies {
implementation(projects.core.base)
api(libs.androidx.uiautomator)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Copyright 2023, Google LLC, Christopher Banes and the Tivi project contributors
// SPDX-License-Identifier: Apache-2.0

package app.tivi.app.test

import android.os.SystemClock
import androidx.test.uiautomator.By
import androidx.test.uiautomator.BySelector
import androidx.test.uiautomator.Direction
import androidx.test.uiautomator.SearchCondition
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.Until
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds

object AppScenarios {
fun mainNavigationItems(device: UiDevice) {
device.waitForIdle()

// -------------
// Discover
// -------------
device.testDiscover() || return
device.navigateFromDiscoverToShowDetails()

// -------------
// Show Details
// -------------
device.testShowDetails() || return
device.navigateFromShowDetailsToSeasons()

// -------------
// Seasons
// -------------
device.testSeasons() || return
device.navigateFromSeasonsToEpisodeDetails()

// -------------
// Episode details
// -------------
device.testEpisodeDetails() || return
}
}

private fun UiDevice.testDiscover(): Boolean {
// Might need to wait a while for the app to load
waitForObject(By.res("discover_carousel"), 30.seconds).run {
// Scroll one of the Discover Carousels
scroll(Direction.RIGHT, 1f)
scroll(Direction.LEFT, 1f)
}

return wait(Until.hasObject(By.res("discover_carousel_item")), 5.seconds)
}

private fun UiDevice.navigateFromDiscoverToShowDetails() {
// Open a show from one of the carousels
waitForObject(By.res("discover_carousel_item")).click()
waitForIdle()
}

private fun UiDevice.testShowDetails(): Boolean {
// Follow the show
waitForObject(By.res("show_details_follow_button")).click()

// Keep scrolling to the end of the LazyColumn, waiting for a season item
repeat(20) {
if (hasObject(By.res("show_details_season_item"))) {
return true
}

SystemClock.sleep(1.seconds.inWholeMilliseconds)

// Scroll to the end to show the seasons
waitForObject(By.res("show_details_lazycolumn"))
.scroll(Direction.DOWN, 1f)
}

return false
}

private fun UiDevice.navigateFromShowDetailsToSeasons() {
waitForObject(By.res("show_details_season_item")).click()
waitForIdle()
}

private fun UiDevice.testSeasons(): Boolean {
// Not much to test here at the moment
return wait(Until.hasObject(By.res("show_seasons_episode_item")), 5.seconds)
}

private fun UiDevice.navigateFromSeasonsToEpisodeDetails() {
waitForObject(By.res("show_seasons_episode_item")).click()
waitForIdle()
}

private fun UiDevice.testEpisodeDetails(): Boolean {
waitForObject(By.res("episode_details")).run {
// Need to 'inset' the gesture so that we don't swipe
// the notification tray down
setGestureMargin(displayWidth / 10)

// Swipe the bottom sheet 'up', then 'down'
scroll(Direction.DOWN, 0.8f)
scroll(Direction.UP, 0.8f)
}
return true
}

fun UiDevice.waitForObject(selector: BySelector, timeout: Duration = 5.seconds): UiObject2 {
if (wait(Until.hasObject(selector), timeout)) {
return findObject(selector)
}
error("Object with selector [$selector] not found")
}

fun <R> UiDevice.wait(condition: SearchCondition<R>, timeout: Duration): R {
return wait(condition, timeout.inWholeMilliseconds)
}
Loading