diff options
| author | David Göransson <david.goransson@mullvad.net> | 2025-05-09 15:57:25 +0200 |
|---|---|---|
| committer | David Göransson <david.goransson@mullvad.net> | 2025-05-12 14:09:31 +0200 |
| commit | 069e7a52de87e665f7fdae8a664182f23150da55 (patch) | |
| tree | 8195719809c07921056735b991c6fb797ce8adf9 /android/test/common | |
| parent | 405b0be1f551906890be84f5e8aca3539b6bcfbf (diff) | |
| download | mullvadvpn-069e7a52de87e665f7fdae8a664182f23150da55.tar.xz mullvadvpn-069e7a52de87e665f7fdae8a664182f23150da55.zip | |
Convert all test to use Page pattern
Converts all our last test to use the Page object pattern and clean up
a bunch of old MockApi and E2E test logic.
Diffstat (limited to 'android/test/common')
10 files changed, 136 insertions, 165 deletions
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 index aa993025f5..c14be06208 100644 --- 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 @@ -1,6 +1,5 @@ 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.StaleObjectException @@ -18,10 +17,6 @@ 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.hasObjectWithTimeout(selector: BySelector, timeout: Long = DEFAULT_TIMEOUT): Boolean = wait(Until.hasObject(selector), timeout) @@ -89,40 +84,6 @@ fun UiDevice.clickObjectAwaitIsChecked(selector: BySelector, timeout: Long = LON ) } -fun UiDevice.clickAgreeOnPrivacyDisclaimer() { - findObjectWithTimeout(By.text("Agree and continue")).click() -} - -// The dialog will only be shown when there's a new version code and bundled release notes. -fun UiDevice.dismissChangelogDialogIfShown() { - try { - findObjectWithTimeout(By.text("Got it!")).click() - } catch (e: IllegalArgumentException) { - // This is OK since it means the changes dialog wasn't shown. - } -} - -fun UiDevice.clickAllowOnNotificationPermissionPromptIfApiLevel33AndAbove( - timeout: Long = DEFAULT_TIMEOUT -) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { - // 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 ms)" - ) - } -} - fun UiDevice.pressBackTwice() { pressBack() pressBack() 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 index b3a35d21e3..7bf2a5911d 100644 --- 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 @@ -2,37 +2,29 @@ package net.mullvad.mullvadvpn.test.common.interactor import android.content.Context import android.content.Intent -import android.widget.Button +import android.os.Build import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.Until import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpointOverride import net.mullvad.mullvadvpn.lib.endpoint.putApiEndpointConfigurationExtra -import net.mullvad.mullvadvpn.lib.ui.tag.CONNECT_CARD_HEADER_TEST_TAG -import net.mullvad.mullvadvpn.lib.ui.tag.LOCATION_INFO_CONNECTION_IN_TEST_TAG -import net.mullvad.mullvadvpn.lib.ui.tag.LOCATION_INFO_CONNECTION_OUT_TEST_TAG -import net.mullvad.mullvadvpn.lib.ui.tag.OUT_OF_TIME_SCREEN_TITLE_TEST_TAG -import net.mullvad.mullvadvpn.lib.ui.tag.TOP_BAR_ACCOUNT_BUTTON_TEST_TAG -import net.mullvad.mullvadvpn.lib.ui.tag.TOP_BAR_SETTINGS_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.test.common.constant.DEFAULT_TIMEOUT -import net.mullvad.mullvadvpn.test.common.constant.EXTREMELY_LONG_TIMEOUT import net.mullvad.mullvadvpn.test.common.constant.LONG_TIMEOUT -import net.mullvad.mullvadvpn.test.common.constant.VERY_LONG_TIMEOUT -import net.mullvad.mullvadvpn.test.common.extension.clickAgreeOnPrivacyDisclaimer -import net.mullvad.mullvadvpn.test.common.extension.clickAllowOnNotificationPermissionPromptIfApiLevel33AndAbove -import net.mullvad.mullvadvpn.test.common.extension.dismissChangelogDialogIfShown import net.mullvad.mullvadvpn.test.common.extension.findObjectWithTimeout +import net.mullvad.mullvadvpn.test.common.page.LoginPage +import net.mullvad.mullvadvpn.test.common.page.on class AppInteractor( private val device: UiDevice, private val targetContext: Context, - private val targetPackageName: String, + private val customApiEndpointConfiguration: ApiEndpointOverride? = null, ) { - fun launch(customApiEndpointConfiguration: ApiEndpointOverride? = null) { + fun launch() { device.pressHome() // Wait for launcher device.wait(Until.hasObject(By.pkg(device.launcherPackageName).depth(0)), LONG_TIMEOUT) + val targetPackageName = targetContext.packageName val intent = targetContext.packageManager.getLaunchIntentForPackage(targetPackageName)?.apply { // Clear out any previous instances @@ -47,111 +39,41 @@ class AppInteractor( fun launchAndEnsureOnLoginPage() { launch() - device.clickAgreeOnPrivacyDisclaimer() - device.clickAllowOnNotificationPermissionPromptIfApiLevel33AndAbove() - waitForLoginPrompt() + clickAgreeOnPrivacyDisclaimer() + clickAllowOnNotificationPermissionPromptIfApiLevel33AndAbove() + on<LoginPage>() } - fun launchAndEnsureLoggedIn(accountNumber: String) { + fun launchAndLogIn(accountNumber: String) { launchAndEnsureOnLoginPage() - attemptLogin(accountNumber) - device.dismissChangelogDialogIfShown() - ensureLoggedIn() - } - - fun attemptLogin(accountNumber: String) { - val loginObject = - device.findObjectWithTimeout(By.clazz("android.widget.EditText")).apply { - text = accountNumber - } - val loginButton = loginObject.parent.findObject(By.clazz(Button::class.java)) - loginButton.wait(Until.enabled(true), DEFAULT_TIMEOUT) - loginButton.click() - } - - fun attemptCreateAccount() { - device.findObjectWithTimeout(By.text("Create account")).click() - } - - fun ensureAccountCreated(accountNumber: String? = null) { - device.findObjectWithTimeout(By.text("Congrats!"), VERY_LONG_TIMEOUT) - accountNumber?.let { device.findObjectWithTimeout(By.text(accountNumber), DEFAULT_TIMEOUT) } - } - - fun ensureAccountCreationFailed() { - device.findObjectWithTimeout(By.text("Failed to create account"), EXTREMELY_LONG_TIMEOUT) - } - - fun ensureLoggedIn() { - device.findObjectWithTimeout(By.text("DISCONNECTED"), VERY_LONG_TIMEOUT) - } - - fun ensureOutOfTime() { - device.findObjectWithTimeout(By.res(OUT_OF_TIME_SCREEN_TITLE_TEST_TAG)) - } - - fun ensureAccountScreen() { - device.findObjectWithTimeout(By.text("Account")) - } - - fun extractOutIpv4Address(): String { - device.findObjectWithTimeout(By.res(CONNECT_CARD_HEADER_TEST_TAG)).click() - return device - .findObjectWithTimeout( - // Text exist and contains IP address - By.res(LOCATION_INFO_CONNECTION_OUT_TEST_TAG).textContains("."), - VERY_LONG_TIMEOUT, - ) - .text - } - - fun extractInIpv4Address(): String { - device.findObjectWithTimeout(By.res(CONNECT_CARD_HEADER_TEST_TAG)).click() - val inString = - device - .findObjectWithTimeout( - By.res(LOCATION_INFO_CONNECTION_IN_TEST_TAG), - VERY_LONG_TIMEOUT, - ) - .text - - val extractedIpAddress = inString.split(" ")[0].split(":")[0] - return extractedIpAddress - } - - fun clickSettingsCog() { - device.findObjectWithTimeout(By.res(TOP_BAR_SETTINGS_BUTTON_TEST_TAG)).click() - } - - fun clickAccountCog() { - device.findObjectWithTimeout(By.res(TOP_BAR_ACCOUNT_BUTTON_TEST_TAG)).click() + on<LoginPage> { + enterAccountNumber(accountNumber) + clickLoginButton() + } } - fun clickListItemByText(text: String) { - device.findObjectWithTimeout(By.text(text)).click() + private fun clickAgreeOnPrivacyDisclaimer() { + device.findObjectWithTimeout(By.text("Agree and continue")).click() } - fun clickActionButtonByText(text: String) { - device.findObjectWithTimeout(By.text(text)).click() - } + private fun clickAllowOnNotificationPermissionPromptIfApiLevel33AndAbove( + timeout: Long = DEFAULT_TIMEOUT + ) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + // Skipping as notification permissions are not shown. + return + } - fun waitForLoginPrompt(timeout: Long = VERY_LONG_TIMEOUT) { - device.findObjectWithTimeout(By.text("Login"), timeout) - } + val selector = By.text("Allow") - fun attemptToRemoveDevice() { - device.findObjectWithTimeout(By.desc("Remove")).click() - clickActionButtonByText("Yes, log out device") - } + device.wait(Until.hasObject(selector), timeout) - fun dismissStorePasswordPromptIfShown() { try { - device.waitForIdle() - val selector = By.textContains("password") - device.wait(Until.hasObject(selector), DEFAULT_TIMEOUT) - device.pressBack() - } catch (e: IllegalArgumentException) { - // This is OK since it means the password prompt wasn't shown. + device.findObjectWithTimeout(selector).click() + } catch (_: IllegalArgumentException) { + throw IllegalArgumentException( + "Failed to allow notification permission within timeout ($timeout ms)" + ) } } } diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/misc/CaptureScreenRecordingsExtension.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/misc/CaptureScreenRecordingsExtension.kt index 21cc915482..de804ccb8e 100644 --- a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/misc/CaptureScreenRecordingsExtension.kt +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/misc/CaptureScreenRecordingsExtension.kt @@ -34,7 +34,7 @@ class CaptureScreenRecordingsExtension : BeforeEachCallback, AfterEachCallback { } private fun startScreenRecord(fileName: String) { - if (File(OUTPUT_DIRECTORY).exists().not()) { + if (!File(OUTPUT_DIRECTORY).exists()) { File(OUTPUT_DIRECTORY).mkdirs() } @@ -49,6 +49,7 @@ class CaptureScreenRecordingsExtension : BeforeEachCallback, AfterEachCallback { device.executeShellCommand("pkill -2 screenrecord") runBlocking { job.join() } } catch (e: Exception) { + Logger.e("Failed to stop recording", e) fail("Failed to stop screen recording") } } diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/LoginPage.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/LoginPage.kt index 173fde50fa..223ea378c6 100644 --- a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/LoginPage.kt +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/LoginPage.kt @@ -20,17 +20,27 @@ class LoginPage internal constructor() : Page() { uiDevice.findObjectWithTimeout(By.clazz("android.widget.EditText")).text = accountNumber } - fun tapLoginButton() { + fun clickLoginButton() { val accountTextField = uiDevice.findObjectWithTimeout(By.clazz("android.widget.EditText")) val loginButton = accountTextField.parent.findObject(By.clazz(Button::class.java)) loginButton.wait(Until.enabled(true), DEFAULT_TIMEOUT) loginButton.click() } + fun clickCreateAccount() { + uiDevice.findObjectWithTimeout(By.text("Create account")).click() + } + fun verifyShowingInvalidAccount() { uiDevice.findObjectWithTimeout(invalidAccountNumberSelector, EXTREMELY_LONG_TIMEOUT) } + fun assertHasAccountHistory(accountNumber: String) { + // This can be improved, if we've entered the same account number in the TextField we might + // get a false positive. + uiDevice.findObjectWithTimeout(By.text(accountNumber)) + } + override fun assertIsDisplayed() { uiDevice.findObjectWithTimeout(loginSelector) } diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/OutOfTimePage.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/OutOfTimePage.kt new file mode 100644 index 0000000000..33e11f4a2e --- /dev/null +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/OutOfTimePage.kt @@ -0,0 +1,13 @@ +package net.mullvad.mullvadvpn.test.common.page + +import androidx.test.uiautomator.By +import net.mullvad.mullvadvpn.lib.ui.tag.OUT_OF_TIME_SCREEN_TITLE_TEST_TAG +import net.mullvad.mullvadvpn.test.common.extension.findObjectWithTimeout + +class OutOfTimePage internal constructor() : Page() { + private val outOfTimeSelector = By.res(OUT_OF_TIME_SCREEN_TITLE_TEST_TAG) + + override fun assertIsDisplayed() { + uiDevice.findObjectWithTimeout(outOfTimeSelector) + } +} diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/TooManyDevicesPage.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/TooManyDevicesPage.kt new file mode 100644 index 0000000000..2f5f8c4133 --- /dev/null +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/TooManyDevicesPage.kt @@ -0,0 +1,46 @@ +package net.mullvad.mullvadvpn.test.common.page + +import androidx.test.uiautomator.By +import androidx.test.uiautomator.waitForStableInActiveWindow +import net.mullvad.mullvadvpn.test.common.extension.expectObjectToDisappearWithTimeout +import net.mullvad.mullvadvpn.test.common.extension.findObjectWithTimeout + +class TooManyDevicesPage internal constructor() : Page() { + private val tooManyDevicesSelector = By.text("Too many devices") + + override fun assertIsDisplayed() { + uiDevice.findObjectWithTimeout(tooManyDevicesSelector) + + // Assert that we have too many devices + // And that the continue with login button is disabled + uiDevice.findObjectWithTimeout( + By.text("Continue with login").hasParent(By.enabled((false))) + ) + } + + fun clickRemoveDevice(deviceName: String) { + val deviceRow = uiDevice.findObjectWithTimeout(By.text(deviceName)).parent + deviceRow.findObjectWithTimeout(By.desc("Remove")).click() + } + + fun assertReadyToLogin() { + uiDevice.findObjectWithTimeout(By.text("Super!")) + } + + fun clickContinueWithLogin() { + uiDevice.findObjectWithTimeout(By.text("Continue with login")).click() + } +} + +/** Remove a device and click confirm on the confirmation dialog. */ +fun TooManyDevicesPage.removeDeviceFlow(deviceName: String) { + clickRemoveDevice(deviceName) + + // Wait for the confirmation dialog to appear + uiDevice.waitForStableInActiveWindow() + // Confirm logout + uiDevice.findObjectWithTimeout(By.text("Yes, log out device")).click() + + // Await the device to be removed + uiDevice.expectObjectToDisappearWithTimeout(By.text(deviceName)) +} diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/WelcomePage.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/WelcomePage.kt new file mode 100644 index 0000000000..4589f82e56 --- /dev/null +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/WelcomePage.kt @@ -0,0 +1,25 @@ +package net.mullvad.mullvadvpn.test.common.page + +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import net.mullvad.mullvadvpn.test.common.constant.DEFAULT_TIMEOUT +import net.mullvad.mullvadvpn.test.common.extension.findObjectWithTimeout + +class WelcomePage internal constructor() : Page() { + private val welcomeSelector = By.text("Congrats!") + + override fun assertIsDisplayed() { + uiDevice.findObjectWithTimeout(welcomeSelector) + } +} + +fun UiDevice.dismissStorePasswordPromptIfShown() { + try { + val selector = By.textContains("password") + wait(Until.hasObject(selector), DEFAULT_TIMEOUT) + pressBack() + } catch (_: IllegalArgumentException) { + // This is OK since it means the password prompt wasn't shown. + } +} diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/WireGuardCustomPortDialog.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/WireGuardCustomPortDialog.kt index ee112a5d46..2966cdeccf 100644 --- a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/WireGuardCustomPortDialog.kt +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/page/WireGuardCustomPortDialog.kt @@ -23,8 +23,4 @@ class WireGuardCustomPortDialog internal constructor() : Page() { fun clickCancel() { uiDevice.findObjectWithTimeout(cancelSelector).click() } - - companion object { - const val TEXT_FIELD_TEST_TAG = "custom_port_dialog_input_test_tag" - } } 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 index ecabb3c60a..adb195fec8 100644 --- 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 @@ -83,7 +83,7 @@ class CaptureScreenshotOnFailedTestRule(private val testTag: String) : TestWatch ) .toFile() .apply { - if (exists().not()) { + if (!exists()) { mkdirs() } } 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 index 2a4a4dfeb1..b1cd894871 100644 --- 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 @@ -34,19 +34,16 @@ class ForgetAllVpnAppsInSettingsTestRule : BeforeTestExecutionCallback { .filter { !it.isHardcodedVpn() } .forEach { button -> button.click() - - if (device.hasObjectWithTimeout(By.text(FORGET_VPN_VPN_BUTTON_TEXT))) { - device.findObjectWithTimeout(By.text(FORGET_VPN_VPN_BUTTON_TEXT)).click() - device - .findObjectByCaseInsensitiveText(FORGET_VPN_VPN_CONFIRM_BUTTON_TEXT) - .click() + if (device.hasObjectWithTimeout(By.text(FORGET_VPN_BUTTON_TEXT))) { + device.findObjectWithTimeout(By.text(FORGET_VPN_BUTTON_TEXT)).click() + device.findObjectByCaseInsensitiveText(FORGET_VPN_CONFIRM_BUTTON_TEXT).click() } else if (device.hasObjectWithTimeout(By.text(DELETE_VPN_PROFILE_TEXT))) { device.findObjectWithTimeout(By.text(DELETE_VPN_PROFILE_TEXT)).click() device .findObjectWithTimeout(By.text(DELETE_VPN_CONFIRM_BUTTON_TEXT_REGEXP)) .click() - } else if (device.hasObjectWithTimeout(By.text(FORGET_VPN_BUTTON_TEXT))) { - device.findObjectWithTimeout(By.text(FORGET_VPN_BUTTON_TEXT)).click() + } else if (device.hasObjectWithTimeout(By.text(FORGET_BUTTON_TEXT))) { + device.findObjectWithTimeout(By.text(FORGET_BUTTON_TEXT)).click() } else { fail("Unable to find forget or delete button") } @@ -61,10 +58,10 @@ class ForgetAllVpnAppsInSettingsTestRule : BeforeTestExecutionCallback { companion object { private val HARDCODED_VPN_PROFILE_NAMES = listOf("VPN by Google") - private const val FORGET_VPN_VPN_BUTTON_TEXT = "Forget VPN" - private const val FORGET_VPN_BUTTON_TEXT = "Forget" // Legacy VPN + private const val FORGET_VPN_BUTTON_TEXT = "Forget VPN" + private const val FORGET_BUTTON_TEXT = "Forget" // Legacy VPN private const val DELETE_VPN_PROFILE_TEXT = "Delete VPN profile" - private const val FORGET_VPN_VPN_CONFIRM_BUTTON_TEXT = "Forget" + private const val FORGET_VPN_CONFIRM_BUTTON_TEXT = "Forget" // Samsung S22 shows "Delete" // Stock Android shows "DELETE" private val DELETE_VPN_CONFIRM_BUTTON_TEXT_REGEXP = Pattern.compile("DELETE|Delete") |
