Android Espresso Testing for Chrome Custom Tab Intents
Asked Answered
H

0

7

With the following code snippets, I've been trying to use Espresso (UiAutomator for automatically filling in the user input fields) for testing out a login auth flow using the OpenID OAuth 2.0 library where logging in occurs externally via a custom Chrome Tab intent with a successful login taking the user back to the app via the launching Activity's onActivityResult() callback to then run some logic afterwards (asserting that the screen really changed by validating that the next screen's views are being displayed in this case). But it turns out that the app isn't resumed properly after logging in which later throws a NoActivityResumedException.

And yes, I've tried using Espresso-Intents, but couldn't figure out how to tie it in this scenario since I'm going as far as testing the overall login flow within the login screen as the ActivityTestRule, particularly triggering its own intent (auth request from the library) after the login button is pressed. I feel like I'm on the right track so far, so any help would be appreciated on pointing me to the right direction!

Login screen:

class LoginActivity : AppCompatActivity() {

    companion object {
        const val RC_AUTH_LOGIN = 100
    }

    private lateinit var authService: AuthorizationService

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_login)

        initAuthService()
        initViews()
    }

    override fun onDestroy() {
        authService.dispose()
        super.onDestroy()
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)

        if (resultCode == Activity.RESULT_OK) {
            when (requestCode) {
                RC_AUTH_LOGIN -> initViewModelAndObserve(data)
                else -> // Display error message
            }
        }
    }

    private fun initAuthService() {
        authService = AuthorizationService(this)
    }

    private fun initViews() {
        start_auth_button?.setOnClickListener {
            startAuthorization()
        }
    }

    private fun initViewModelAndObserve(data: Intent?) {
        // [authState] can either be retrieved from cache or [AuthState()]
        AuthUtils.handleAuthorizationResponse(authService, data, authState) { success ->
            if (success) {
                // Run necessary API async calls and such within the ViewModel 
                // layer to observe.
                loginViewModel.loginLiveData.observe(this, Observer<Boolean> { loginSuccessful ->
                    if (loginSuccessful) {
                        // Transition to the next screen
                    } else {
                        // Display error message
                    }
                })
            } else {
                // Display error message
            }
        }
    }

    private fun startAuthorization() {
        val req = AuthUtils.getAuthRequest()
        val intent = authService.getAuthorizationRequestIntent(req)
        startActivityForResult(intent, RC_AUTH_LOGIN)
    }

}

Helper auth functions:

object AuthUtils {

    fun getAuthRequest(): AuthorizationRequest {
        val authServiceConfig = getServiceConfig()
        // [clientID], [redirectURI], and [clientSecret] dummy 
        // args.
        val req = AuthorizationRequest.Builder(
            authServiceConfig,
            clientID,
            ResponseTypeValues.CODE,
            Uri.parse(redirectURI)
        )
            .setScope("scope")
            .setPrompt("login")
            .setAdditionalParameters(mapOf("client_secret" to clientSecret,"grant_type" to "authorization_code" ))
            .build()

        return req
    }

    fun handleAuthorizationResponse(authService: AuthorizationService,
                                    data: Intent?,
                                    appAuthState: AuthState,
                                    resultCallBack: (result: Boolean) -> Unit) {

        if (data == null) {
            resultCallBack.invoke(false)
            return
        }

        val response = AuthorizationResponse.fromIntent(data)
        val error = AuthorizationException.fromIntent(data)
        appAuthState.update(response, error)
        if (error != null || response == null) {
            resultCallBack.invoke(false)
            return
        }

        val req = getTokenRequest(response)
        performTokenRequest(authService, req, appAuthState) { authState ->
            if (authState != null) {
                authState.accessToken?.let { token ->
                    // For instance, decode token here prior to caching.
                    resultCallBack.invoke(true)
                }
            } else {
                resultCallBack.invoke(false)
            }
        }
    }

    private fun getServiceConfig(): AuthorizationServiceConfiguration {
        // Issuer URI (login URL in this case) dummy arg
        return authServiceConfig = AuthorizationServiceConfiguration(
            Uri.parse(issuerURI)
                .buildUpon()
                .appendEncodedPath("connect/authorize")
                .build(),
            Uri.parse(issuerURI)
                .buildUpon()
                .appendEncodedPath("connect/token")
                .build()
        )
    }

    private fun getTokenRequest(response: AuthorizationResponse) : TokenRequest {
        val request = getAuthRequest()
        val secret = RemoteConfig().clientSecret()

        return TokenRequest.Builder(
            request.configuration,
            request.clientId)
            .setGrantType(GrantTypeValues.AUTHORIZATION_CODE)
            .setRedirectUri(request.redirectUri)
            .setScope(request.scope)
            // this is not valid in ID server
            // .setCodeVerifier(request.codeVerifier)
            .setAuthorizationCode(response.authorizationCode)
            .setAdditionalParameters(mapOf("client_secret" to secret))
            .build()
    }

    private fun performTokenRequest(authService: AuthorizationService,
                                    req: TokenRequest,
                                    appAuthState: AuthState,
                                    resultCallBack:(result: AuthState?) -> Unit)  {

        authService
            .performTokenRequest(req) { response, error ->
                // Updates auth state based on if there's token response
                // data or not.
                if (response != null) {
                    appAuthState.update(response, error)
                    resultCallBack.invoke(appAuthState)
                } else {
                    resultCallBack.invoke(null)
                }
            }
    }

}

Espresso UI test:

@LargeTest
@RunWith(AndroidJUnit4::class)
class LoginAuthInstrumentedTest { 

    private val context = InstrumentationRegistry.getInstrumentation().targetContext

    @Rule
    @JvmField
    var activityTestRule = ActivityTestRule(LoginActivity::class.java)

    @Test
    fun loginAuthFlow_isCorrect() {
        // Performs a click action in the login screen to fire off
        // the auth service intent for an activity result.
        onView(withId(R.id.start_auth_button)).perform(click())

        // Automatically logs the user in with dummy creds within a
        // custom Chrome tab intent (via the OpenID auth library).
        val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
        val selector = UiSelector()
        val usernameInputObject = device.findObject(selector.resourceId("username"))
        usernameInputObject.click()
        usernameInputObject.text = "[email protected]"
        val passwordInputObject = device.findObject(selector.resourceId("password"))
        passwordInputObject.click()
        passwordInputObject.text = "testpassword"
        val loginBtnObject = device.findObject(selector.resourceId("cmdLogin"))
        loginBtnObject.click()

        // Upon a successful login from the auth service, the following
        // asserts that the following views are shown on the next
        // transitioned screen.
        onView(withId(R.id.main_screen_header)).check(matches(withText(context.getString(R.string.main_screen_header_text))))
        onView(withId(R.id.main_screen_subheader)).check(matches(withText(context.getString(R.string.main_screen_subheader_text))))
        onView(withId(R.id.main_screen_description)).check(matches(withText(context.getString(R.string.main_screen_description_text))))
    }

}

... but LoginActivity is not resumed as shown here in the logs (prior to a NoActivityResumedException):

D/LifecycleMonitor: Lifecycle status change: com.testapp.view.login.LoginActivity@de1a309 in: STOPPED
    running callback: androidx.test.rule.ActivityTestRule$LifecycleCallback@ff0a037
    callback completes: androidx.test.rule.ActivityTestRule$LifecycleCallback@ff0a037
D/LifecycleMonitor: Lifecycle status change: net.openid.appauth.AuthorizationManagementActivity@76192e1 in: STOPPED
    running callback: androidx.test.rule.ActivityTestRule$LifecycleCallback@ff0a037
    callback completes: androidx.test.rule.ActivityTestRule$LifecycleCallback@ff0a037
I/QueryController: Matched selector: UiSelector[RESOURCE_ID=username] <<==>> [android.view.accessibility.AccessibilityNodeInfo@a3d2f; boundsInParent: Rect(0, 28 - 382, 81); boundsInScreen: Rect(39, 782 - 1042, 921); packageName: com.android.chrome; className: android.widget.EditText; text: ; error: null; maxTextLength: -1; contentDescription: null; tooltipText: null; viewIdResName: username; checkable: false; checked: false; focusable: true; focused: false; selected: false; clickable: true; longClickable: false; contextClickable: false; enabled: true; password: false; scrollable: false; importantForAccessibility: false; visible: true; actions: [AccessibilityAction: ACTION_NEXT_HTML_ELEMENT - null, AccessibilityAction: ACTION_PREVIOUS_HTML_ELEMENT - null, AccessibilityAction: ACTION_NEXT_AT_MOVEMENT_GRANULARITY - null, AccessibilityAction: ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY - null, AccessibilityAction: ACTION_SHOW_ON_SCREEN - null, AccessibilityAction: ACTION_CONTEXT_CLICK - null, AccessibilityAction: ACTION_SET_TEXT - null, AccessibilityAction: ACTION_PASTE - null, AccessibilityAction: ACTION_FOCUS - null, AccessibilityAction: ACTION_ACCESSIBILITY_FOCUS - null, AccessibilityAction: ACTION_CLICK - null]]
D/InteractionController: clickAndSync(540, 851)
I/QueryController: Matched selector: UiSelector[RESOURCE_ID=username] <<==>> [android.view.accessibility.AccessibilityNodeInfo@a3d2f; boundsInParent: Rect(0, 28 - 382, 81); boundsInScreen: Rect(39, 782 - 1042, 921); packageName: com.android.chrome; className: android.widget.EditText; text: ; error: null; maxTextLength: -1; contentDescription: null; tooltipText: null; viewIdResName: username; checkable: false; checked: false; focusable: true; focused: true; selected: false; clickable: true; longClickable: false; contextClickable: false; enabled: true; password: false; scrollable: false; importantForAccessibility: false; visible: true; actions: [AccessibilityAction: ACTION_NEXT_HTML_ELEMENT - null, AccessibilityAction: ACTION_PREVIOUS_HTML_ELEMENT - null, AccessibilityAction: ACTION_NEXT_AT_MOVEMENT_GRANULARITY - null, AccessibilityAction: ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY - null, AccessibilityAction: ACTION_SHOW_ON_SCREEN - null, AccessibilityAction: ACTION_CONTEXT_CLICK - null, AccessibilityAction: ACTION_SET_TEXT - null, AccessibilityAction: ACTION_PASTE - null, AccessibilityAction: ACTION_CLEAR_FOCUS - null, AccessibilityAction: ACTION_CLEAR_ACCESSIBILITY_FOCUS - null, AccessibilityAction: ACTION_CLICK - null]]
I/QueryController: Matched selector: UiSelector[RESOURCE_ID=password] <<==>> [android.view.accessibility.AccessibilityNodeInfo@a3f1f; boundsInParent: Rect(0, 0 - 317, 8); boundsInScreen: Rect(39, 1034 - 871, 1055); packageName: com.android.chrome; className: android.widget.EditText; text: ; error: null; maxTextLength: -1; contentDescription: null; tooltipText: null; viewIdResName: password; checkable: false; checked: false; focusable: true; focused: false; selected: false; clickable: true; longClickable: false; contextClickable: false; enabled: true; password: true; scrollable: false; importantForAccessibility: false; visible: true; actions: [AccessibilityAction: ACTION_NEXT_HTML_ELEMENT - null, AccessibilityAction: ACTION_PREVIOUS_HTML_ELEMENT - null, AccessibilityAction: ACTION_NEXT_AT_MOVEMENT_GRANULARITY - null, AccessibilityAction: ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY - null, AccessibilityAction: ACTION_SHOW_ON_SCREEN - null, AccessibilityAction: ACTION_CONTEXT_CLICK - null, AccessibilityAction: ACTION_SET_TEXT - null, AccessibilityAction: ACTION_PASTE - null, AccessibilityAction: ACTION_FOCUS - null, AccessibilityAction: ACTION_ACCESSIBILITY_FOCUS - null, AccessibilityAction: ACTION_CLICK - null]]
D/InteractionController: clickAndSync(455, 1044)
I/QueryController: Matched selector: UiSelector[RESOURCE_ID=password] <<==>> [android.view.accessibility.AccessibilityNodeInfo@a3f1f; boundsInParent: Rect(0, 0 - 317, 8); boundsInScreen: Rect(39, 1034 - 871, 1055); packageName: com.android.chrome; className: android.widget.EditText; text: ; error: null; maxTextLength: -1; contentDescription: null; tooltipText: null; viewIdResName: password; checkable: false; checked: false; focusable: true; focused: false; selected: false; clickable: true; longClickable: false; contextClickable: false; enabled: true; password: true; scrollable: false; importantForAccessibility: false; visible: true; actions: [AccessibilityAction: ACTION_NEXT_HTML_ELEMENT - null, AccessibilityAction: ACTION_PREVIOUS_HTML_ELEMENT - null, AccessibilityAction: ACTION_NEXT_AT_MOVEMENT_GRANULARITY - null, AccessibilityAction: ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY - null, AccessibilityAction: ACTION_SHOW_ON_SCREEN - null, AccessibilityAction: ACTION_CONTEXT_CLICK - null, AccessibilityAction: ACTION_SET_TEXT - null, AccessibilityAction: ACTION_PASTE - null, AccessibilityAction: ACTION_FOCUS - null, AccessibilityAction: ACTION_ACCESSIBILITY_FOCUS - null, AccessibilityAction: ACTION_CLICK - null]]
I/QueryController: Matched selector: UiSelector[RESOURCE_ID=cmdSubmit] <<==>> [android.view.accessibility.AccessibilityNodeInfo@a3dab; boundsInParent: Rect(0, 131 - 382, 132); boundsInScreen: Rect(39, 1052 - 1042, 1055); packageName: com.android.chrome; className: android.widget.Button; text: Sign In; error: null; maxTextLength: -1; contentDescription: null; tooltipText: null; viewIdResName: cmdSubmit; checkable: false; checked: false; focusable: false; focused: false; selected: false; clickable: false; longClickable: false; contextClickable: false; enabled: false; password: false; scrollable: false; importantForAccessibility: false; visible: true; actions: [AccessibilityAction: ACTION_NEXT_HTML_ELEMENT - null, AccessibilityAction: ACTION_PREVIOUS_HTML_ELEMENT - null, AccessibilityAction: ACTION_NEXT_AT_MOVEMENT_GRANULARITY - null, AccessibilityAction: ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY - null, AccessibilityAction: ACTION_SHOW_ON_SCREEN - null, AccessibilityAction: ACTION_CONTEXT_CLICK - null, AccessibilityAction: ACTION_ACCESSIBILITY_FOCUS - null]]
D/InteractionController: clickAndSync(540, 1053)
V/FA: Inactivity, disconnecting from the service
W/RootViewPicker: No activity currently resumed - waiting: 10ms for one to appear.
W/RootViewPicker: No activity currently resumed - waiting: 50ms for one to appear.
W/RootViewPicker: No activity currently resumed - waiting: 100ms for one to appear.
W/RootViewPicker: No activity currently resumed - waiting: 500ms for one to appear.
W/RootViewPicker: No activity currently resumed - waiting: 2000ms for one to appear.
W/RootViewPicker: No activity currently resumed - waiting: 30000ms for one to appear.
Heurlin answered 3/9, 2019 at 15:50 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.