diff options
| author | Albin <albin@mullvad.net> | 2023-01-10 15:58:10 +0100 |
|---|---|---|
| committer | Albin <albin@mullvad.net> | 2023-01-10 15:58:10 +0100 |
| commit | 7ea38a3881b92b17658a5be4f0e627601212db6c (patch) | |
| tree | 96c12212b3f95c7ea588099aec7cb5fdb22942ed /android/test/common/src | |
| parent | fee3b5804555b3287c9c59aecd3682f118735ba8 (diff) | |
| parent | 57008a509342f547f5e56ef781a7f2e8a18298c0 (diff) | |
| download | mullvadvpn-7ea38a3881b92b17658a5be4f0e627601212db6c.tar.xz mullvadvpn-7ea38a3881b92b17658a5be4f0e627601212db6c.zip | |
Merge branch 'add-instrumented-tests-using-mocked-api'
Diffstat (limited to 'android/test/common/src')
7 files changed, 335 insertions, 0 deletions
diff --git a/android/test/common/src/main/AndroidManifest.xml b/android/test/common/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..cc947c5679 --- /dev/null +++ b/android/test/common/src/main/AndroidManifest.xml @@ -0,0 +1 @@ +<manifest /> diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/constant/AppConstants.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/constant/AppConstants.kt new file mode 100644 index 0000000000..05b47ef99b --- /dev/null +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/constant/AppConstants.kt @@ -0,0 +1,6 @@ +package net.mullvad.mullvadvpn.test.common.constant + +const val MULLVAD_PACKAGE = "net.mullvad.mullvadvpn" +const val SETTINGS_COG_ID = "net.mullvad.mullvadvpn:id/settings" +const val TUNNEL_INFO_ID = "net.mullvad.mullvadvpn:id/tunnel_info" +const val TUNNEL_OUT_ADDRESS_ID = "net.mullvad.mullvadvpn:id/out_address" diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/constant/TimeoutConstants.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/constant/TimeoutConstants.kt new file mode 100644 index 0000000000..0da1d02aaf --- /dev/null +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/constant/TimeoutConstants.kt @@ -0,0 +1,8 @@ +package net.mullvad.mullvadvpn.test.common.constant + +const val APP_LAUNCH_TIMEOUT = 5000L +const val CONNECTION_TIMEOUT = 30000L +const val DEFAULT_INTERACTION_TIMEOUT = 3000L +const val LOGIN_TIMEOUT = 30000L +const val LOGIN_FAILURE_TIMEOUT = 60000L +const val WEB_TIMEOUT = 30000L diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/extension/UiAutomatorExtensions.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/extension/UiAutomatorExtensions.kt new file mode 100644 index 0000000000..cb953b920e --- /dev/null +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/extension/UiAutomatorExtensions.kt @@ -0,0 +1,80 @@ +package net.mullvad.mullvadvpn.test.common.extension + +import android.os.Build +import androidx.test.uiautomator.By +import androidx.test.uiautomator.BySelector +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.Until +import java.util.regex.Pattern +import net.mullvad.mullvadvpn.test.common.constant.DEFAULT_INTERACTION_TIMEOUT + +fun UiDevice.findObjectByCaseInsensitiveText(text: String): UiObject2 { + return findObjectWithTimeout(By.text(Pattern.compile(text, Pattern.CASE_INSENSITIVE))) +} + +fun UiObject2.findObjectByCaseInsensitiveText(text: String): UiObject2 { + return findObjectWithTimeout(By.text(Pattern.compile(text, Pattern.CASE_INSENSITIVE))) +} + +fun UiDevice.findObjectWithTimeout( + selector: BySelector, + timeout: Long = DEFAULT_INTERACTION_TIMEOUT +): UiObject2 { + + wait( + Until.hasObject(selector), + timeout + ) + + return try { + findObject(selector) + } catch (e: NullPointerException) { + throw IllegalArgumentException( + "No matches for selector within timeout ($timeout): $selector" + ) + } +} + +fun UiDevice.clickAllowOnNotificationPermissionPromptIfApiLevel31AndAbove( + timeout: Long = DEFAULT_INTERACTION_TIMEOUT +) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + // Skipping as notification permissions are not shown. + return + } + + val selector = By.text("Allow") + + wait( + Until.hasObject(selector), + timeout + ) + + try { + findObjectWithTimeout(selector).click() + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException( + "Failed to allow notification permission within timeout ($timeout)" + ) + } +} + +fun UiObject2.findObjectWithTimeout( + selector: BySelector, + timeout: Long = DEFAULT_INTERACTION_TIMEOUT +): UiObject2 { + + wait( + Until.hasObject(selector), + timeout + ) + + return try { + findObject(selector) + } catch (e: NullPointerException) { + throw IllegalArgumentException( + "No matches for selector within timeout ($timeout): $selector" + ) + } +} diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/interactor/AppInteractor.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/interactor/AppInteractor.kt new file mode 100644 index 0000000000..1d6e9358a8 --- /dev/null +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/interactor/AppInteractor.kt @@ -0,0 +1,89 @@ +package net.mullvad.mullvadvpn.test.common.interactor + +import android.content.Context +import android.content.Intent +import android.widget.ImageButton +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import net.mullvad.mullvadvpn.lib.endpoint.CustomApiEndpointConfiguration +import net.mullvad.mullvadvpn.lib.endpoint.putApiEndpointConfigurationExtra +import net.mullvad.mullvadvpn.test.common.constant.APP_LAUNCH_TIMEOUT +import net.mullvad.mullvadvpn.test.common.constant.CONNECTION_TIMEOUT +import net.mullvad.mullvadvpn.test.common.constant.LOGIN_TIMEOUT +import net.mullvad.mullvadvpn.test.common.constant.MULLVAD_PACKAGE +import net.mullvad.mullvadvpn.test.common.constant.SETTINGS_COG_ID +import net.mullvad.mullvadvpn.test.common.constant.TUNNEL_INFO_ID +import net.mullvad.mullvadvpn.test.common.constant.TUNNEL_OUT_ADDRESS_ID +import net.mullvad.mullvadvpn.test.common.extension.clickAllowOnNotificationPermissionPromptIfApiLevel31AndAbove +import net.mullvad.mullvadvpn.test.common.extension.findObjectWithTimeout + +class AppInteractor( + private val device: UiDevice, + private val targetContext: Context +) { + fun launch(customApiEndpointConfiguration: CustomApiEndpointConfiguration? = null) { + device.pressHome() + // Wait for launcher + device.wait( + Until.hasObject(By.pkg(device.launcherPackageName).depth(0)), + APP_LAUNCH_TIMEOUT + ) + + val intent = + targetContext.packageManager.getLaunchIntentForPackage(MULLVAD_PACKAGE)?.apply { + // Clear out any previous instances + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) + if (customApiEndpointConfiguration != null) { + putApiEndpointConfigurationExtra(customApiEndpointConfiguration) + } + } + targetContext.startActivity(intent) + device.wait( + Until.hasObject(By.pkg(MULLVAD_PACKAGE).depth(0)), + APP_LAUNCH_TIMEOUT + ) + } + + fun launchAndEnsureLoggedIn(accountToken: String) { + launch() + device.clickAllowOnNotificationPermissionPromptIfApiLevel31AndAbove() + attemptLogin(accountToken) + ensureLoggedIn() + } + + fun attemptLogin(accountToken: String) { + device.findObjectWithTimeout(By.text("Login")) + val loginObject = device.findObjectWithTimeout(By.clazz("android.widget.EditText")) + .apply { text = accountToken } + loginObject.parent.findObject(By.clazz(ImageButton::class.java)).click() + } + + fun ensureLoggedIn() { + device.findObjectWithTimeout(By.text("UNSECURED CONNECTION"), LOGIN_TIMEOUT) + } + + fun extractIpAddress(): String { + device.findObjectWithTimeout(By.res(TUNNEL_INFO_ID)).click() + return device.findObjectWithTimeout( + By.res(TUNNEL_OUT_ADDRESS_ID), + CONNECTION_TIMEOUT + ).text.extractIpAddress() + } + + fun clickSettingsCog() { + device.findObjectWithTimeout(By.res(SETTINGS_COG_ID)).click() + } + + fun clickListItemByText(text: String) { + device.findObjectWithTimeout(By.text(text)).click() + } + + fun clickActionButtonByText(text: String) { + device.findObjectWithTimeout(By.text(text)).click() + } + + private fun String.extractIpAddress(): String { + return split(" ")[1].split(" ")[0] + } +} diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/CaptureScreenshotOnFailedTestRule.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/CaptureScreenshotOnFailedTestRule.kt new file mode 100644 index 0000000000..138d09cc28 --- /dev/null +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/CaptureScreenshotOnFailedTestRule.kt @@ -0,0 +1,114 @@ +package net.mullvad.mullvadvpn.test.common.rule + +import android.content.ContentResolver +import android.content.ContentValues +import android.graphics.Bitmap +import android.os.Build +import android.os.Environment +import android.os.Environment.DIRECTORY_PICTURES +import android.provider.MediaStore +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.nio.file.Paths +import java.time.OffsetDateTime +import java.time.temporal.ChronoUnit +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +class CaptureScreenshotOnFailedTestRule(private val testTag: String) : TestWatcher() { + + override fun failed(e: Throwable?, description: Description) { + Log.d(testTag, "Capturing screenshot of failed test: " + description.methodName) + val timestamp = OffsetDateTime.now().truncatedTo(ChronoUnit.MILLIS) + val screenshotName = "$timestamp-${description.methodName}.jpeg" + captureScreenshot(testTag, screenshotName) + } + + private fun captureScreenshot(baseDir: String, filename: String) { + val contentResolver = getInstrumentation().targetContext.applicationContext.contentResolver + val contentValues = createBaseScreenshotContentValues() + + getInstrumentation().uiAutomation.takeScreenshot().apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + writeToMediaStore( + contentValues = contentValues, + contentResolver = contentResolver, + baseDir = baseDir, + filename = filename + ) + } else { + writeToExternalStorage( + contentValues = contentValues, + contentResolver = contentResolver, + baseDir = baseDir, + filename = filename + ) + } + } + } + + @RequiresApi(29) + private fun Bitmap.writeToMediaStore( + contentValues: ContentValues, + contentResolver: ContentResolver, + baseDir: String, + filename: String + ) { + contentValues.apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, filename) + put( + MediaStore.Images.Media.RELATIVE_PATH, + "$DIRECTORY_PICTURES/$baseDir" + ) + } + + val uri = + contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) + + if (uri != null) { + contentResolver.openOutputStream(uri).use { + try { + this.compress(Bitmap.CompressFormat.JPEG, 50, it) + } catch (e: IOException) { + Log.e(testTag, "Unable to store screenshot: ${e.message}") + } + } + contentResolver.update(uri, contentValues, null, null) + } else { + Log.e(testTag, "Unable to store screenshot") + } + } + + private fun Bitmap.writeToExternalStorage( + contentValues: ContentValues, + contentResolver: ContentResolver, + baseDir: String, + filename: String + ) { + val screenshotBaseDirectory = Paths.get( + Environment.getExternalStoragePublicDirectory(DIRECTORY_PICTURES).path, + baseDir, + ).toFile().apply { + if (exists().not()) { + mkdirs() + } + } + FileOutputStream(File(screenshotBaseDirectory, filename)).use { outputStream -> + try { + this.compress(Bitmap.CompressFormat.JPEG, 50, outputStream) + } catch (e: IOException) { + Log.e(testTag, "Unable to store screenshot: ${e.message}") + } + } + contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) + } + + private fun createBaseScreenshotContentValues() = ContentValues().apply { + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis()) + } +} diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/ForgetAllVpnAppsInSettingsTestRule.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/ForgetAllVpnAppsInSettingsTestRule.kt new file mode 100644 index 0000000000..eebdb291ab --- /dev/null +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/ForgetAllVpnAppsInSettingsTestRule.kt @@ -0,0 +1,37 @@ +package net.mullvad.mullvadvpn.test.common.rule + +import android.content.Intent +import android.provider.Settings +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import net.mullvad.mullvadvpn.test.common.extension.findObjectByCaseInsensitiveText +import net.mullvad.mullvadvpn.test.common.extension.findObjectWithTimeout +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +class ForgetAllVpnAppsInSettingsTestRule : TestWatcher() { + override fun starting(description: Description) { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val targetContext = InstrumentationRegistry.getInstrumentation().targetContext + targetContext.startActivity( + Intent(Settings.ACTION_VPN_SETTINGS).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + ) + val vpnSettingsButtons = + device.findObjects(By.res(SETTINGS_PACKAGE, VPN_SETTINGS_BUTTON_ID)) + vpnSettingsButtons?.forEach { button -> + button.click() + device.findObjectWithTimeout(By.text(FORGET_VPN_VPN_BUTTON_TEXT)).click() + device.findObjectByCaseInsensitiveText(FORGET_VPN_VPN_CONFIRM_BUTTON_TEXT).click() + } + } + + companion object { + private const val FORGET_VPN_VPN_BUTTON_TEXT = "Forget VPN" + private const val FORGET_VPN_VPN_CONFIRM_BUTTON_TEXT = "Forget" + private const val SETTINGS_PACKAGE = "com.android.settings" + private const val VPN_SETTINGS_BUTTON_ID = "settings_button" + } +} |
