diff --git a/auth/README.md b/auth/README.md index c4864b180..2ebd1369d 100644 --- a/auth/README.md +++ b/auth/README.md @@ -69,6 +69,7 @@ Equivalent FirebaseUI libraries are available for [iOS](https://github.com/fireb - [Email Link Sign-In](#email-link-sign-in) - [Password Validation Rules](#password-validation-rules) - [Credential Manager Integration](#credential-manager-integration) + - [Automated Testing (Firebase Test Lab & Robo)](#automated-testing-firebase-test-lab--robo) - [Sign Out & Account Deletion](#sign-out--account-deletion) 10. [Localization](#localization) 11. [Error Handling](#error-handling) @@ -1847,6 +1848,92 @@ val configuration = authUIConfiguration { } ``` +### Automated Testing (Firebase Test Lab & Robo) + +Every input and button on the auth screens carries a stable, public test tag, and FirebaseUI exposes those tags as Android resource ids automatically — no setup required in your app. This is what lets [Firebase Test Lab's Robo test](https://firebase.google.com/docs/test-lab/android/robo-ux-test) and the Google Play Console's pre-launch report drive a real sign-in during automated testing, instead of typing into the wrong field or getting stuck on a screen it can't navigate. + +**Why this matters:** a crawler that can't tell which field is the password will happily type a username into it, then hammer "sign in" and "forgot password" until your test account is buried in reset emails. Every field and button below resolves to one unambiguous resource id, so a crawler — or your own instrumented test — can target it directly. + +**Tag reference.** Tags are grouped by screen; import `com.firebase.ui.auth.ui.FirebaseAuthTestTags`. + +| Screen | Constant | Resource id | +|---|---|---| +| Sign in | `SignIn.EMAIL_FIELD` | `fui_sign_in_email_field` | +| | `SignIn.PASSWORD_FIELD` | `fui_sign_in_password_field` | +| | `SignIn.SIGN_IN_BUTTON` | `fui_sign_in_sign_in_button` | +| | `SignIn.SIGN_UP_BUTTON` | `fui_sign_in_sign_up_button` | +| | `SignIn.FORGOT_PASSWORD_BUTTON` | `fui_sign_in_forgot_password_button` | +| | `SignIn.EMAIL_LINK_BUTTON` | `fui_sign_in_email_link_button` | +| Sign up | `SignUp.NAME_FIELD` | `fui_sign_up_name_field` | +| | `SignUp.EMAIL_FIELD` | `fui_sign_up_email_field` | +| | `SignUp.PASSWORD_FIELD` | `fui_sign_up_password_field` | +| | `SignUp.CONFIRM_PASSWORD_FIELD` | `fui_sign_up_confirm_password_field` | +| | `SignUp.SIGN_UP_BUTTON` | `fui_sign_up_sign_up_button` | +| | `SignUp.SIGN_IN_BUTTON` | `fui_sign_up_sign_in_button` | +| Password recovery | `ResetPassword.EMAIL_FIELD` | `fui_reset_password_email_field` | +| | `ResetPassword.SEND_BUTTON` | `fui_reset_password_send_button` | +| | `ResetPassword.SIGN_IN_BUTTON` | `fui_reset_password_sign_in_button` | +| | `ResetPassword.DISMISS_BUTTON` | `fui_reset_password_dismiss_button` | +| Email link sign-in | `EmailLink.EMAIL_FIELD` | `fui_email_link_email_field` | +| | `EmailLink.SEND_LINK_BUTTON` | `fui_email_link_send_link_button` | +| | `EmailLink.PASSWORD_SIGN_IN_BUTTON` | `fui_email_link_password_sign_in_button` | +| | `EmailLink.DISMISS_BUTTON` | `fui_email_link_dismiss_button` | +| Phone number entry | `PhoneNumber.PHONE_NUMBER_FIELD` | `fui_phone_number_phone_number_field` | +| | `PhoneNumber.COUNTRY_SELECTOR_BUTTON` | `fui_phone_number_country_selector_button` | +| | `PhoneNumber.SEND_CODE_BUTTON` | `fui_phone_number_send_code_button` | +| SMS verification | `VerificationCode.CODE_FIELD` | `fui_verification_code_code_field` | +| | `VerificationCode.VERIFY_BUTTON` | `fui_verification_code_verify_button` | +| | `VerificationCode.RESEND_CODE_BUTTON` | `fui_verification_code_resend_code_button` | +| | `VerificationCode.CHANGE_PHONE_NUMBER_BUTTON` | `fui_verification_code_change_phone_number_button` | +| MFA sign-in challenge | `MfaChallenge.CODE_FIELD` | `fui_mfa_challenge_code_field` | +| | `MfaChallenge.VERIFY_BUTTON` | `fui_mfa_challenge_verify_button` | +| Re-authentication | `Reauth.PASSWORD_FIELD` | `fui_reauth_password_field` | +| | `Reauth.VERIFY_BUTTON` | `fui_reauth_verify_button` | +| | `Reauth.DISMISS_BUTTON` | `fui_reauth_dismiss_button` | +| Method picker | `MethodPicker.PROVIDER_LIST` | `fui_method_picker_provider_list` | +| | `MethodPicker.CONTINUE_AS_BUTTON` | `fui_method_picker_continue_as_button` | +| Country selector | `CountrySelector.COUNTRY_LIST` | `fui_country_selector_country_list` | + +`VerificationCode.CODE_FIELD` and `MfaChallenge.CODE_FIELD` each name the whole six-digit input rather than an individual digit box: the field accepts a complete code in a single `ACTION_SET_TEXT`/`performTextInput` call and distributes it across the digit boxes, so one Robo directive or one `performTextInput("123456")` types the entire code. + +**In your own instrumented tests**, target these the same way you'd target any other tag: + +```kotlin +composeTestRule + .onNodeWithTag(FirebaseAuthTestTags.SignIn.EMAIL_FIELD) + .performTextInput("test@example.com") + +composeTestRule + .onNodeWithTag(FirebaseAuthTestTags.SignIn.PASSWORD_FIELD) + .performTextInput("correcthorsebatterystaple") + +composeTestRule + .onNodeWithTag(FirebaseAuthTestTags.SignIn.SIGN_IN_BUTTON) + .performClick() +``` + +Or with UiAutomator, by resource name: + +```kotlin +device.findObject(By.res("fui_sign_in_email_field")).text = "test@example.com" +``` + +**With Firebase Test Lab.** Pass the resource ids as [Robo directives](https://firebase.google.com/docs/test-lab/android/command-line#robo-test-with-a-script) so the crawler fills real values instead of guessing: + +```bash +gcloud firebase test android run \ + --type=robo \ + --app=app-debug.apk \ + --robo-directives=fui_sign_in_email_field=test@example.com,fui_sign_in_password_field=correcthorsebatterystaple \ + --device model=MediumPhone.arm,version=34 +``` + +This is exactly the mechanism a **Play Console pre-launch report** uses, under **Test and release → Testing → Pre-launch report → Settings → Test account credentials**; the resource ids above are what you enter there for the username and password fields. + +Verified with a real Firebase Test Lab Robo run against the sign-in screen (August 2026): the crawler resolved `fui_sign_in_email_field` and `fui_sign_in_password_field` as `android.widget.EditText` nodes, typed the directive values into both, and submitted via `fui_sign_in_sign_in_button` — along the way also navigating by resource id through sign-up, password recovery, and phone entry, confirming the tagging works generally rather than only where a directive points. Robo's crawling behavior is Google's, not ours, and can change independently of this library; treat this as a snapshot of current behavior rather than a permanent guarantee. + +Renaming or removing a tag, or changing the resource id it resolves to, is a breaking change to FirebaseUI's public API — not an internal detail — so a value documented here will not change without a major version bump. + ### Sign Out & Account Deletion **Sign Out:** diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/AuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/AuthUIStringProvider.kt index 9af38935b..fffd7f269 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/AuthUIStringProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/AuthUIStringProvider.kt @@ -419,6 +419,13 @@ interface AuthUIStringProvider { /** Label for verification code input fields. */ val verificationCodeLabel: String + /** + * Content description for a single verification code digit, announced positionally + * (e.g. "Verification code digit 3 of 6"). + */ + fun verificationCodeDigitDescription(position: Int, total: Int): String = + "Verification code digit $position of $total" + /** Generic identity verified confirmation message. */ val identityVerifiedMessage: String diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt index aec4a83cc..8cdf31c5f 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt @@ -382,6 +382,13 @@ class DefaultAuthUIStringProvider( override val verificationCodeLabel: String get() = localizedContext.getString(R.string.fui_verification_code_label) + override fun verificationCodeDigitDescription(position: Int, total: Int): String = + localizedContext.getString( + R.string.fui_verification_code_digit_description, + position, + total + ) + override val identityVerifiedMessage: String get() = localizedContext.getString(R.string.fui_identity_verified_message) diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/FirebaseAuthTestTags.kt b/auth/src/main/java/com/firebase/ui/auth/ui/FirebaseAuthTestTags.kt new file mode 100644 index 000000000..81c5cd819 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/FirebaseAuthTestTags.kt @@ -0,0 +1,285 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui + +/** + * Stable Compose test tags applied by the FirebaseUI Auth screens. These are public API — renaming + * or removing a constant is a breaking change, not an internal refactor. + */ +object FirebaseAuthTestTags { + + /** Tags on the auth method picker screen. */ + object MethodPicker { + + /** The scrollable list of provider buttons. */ + const val PROVIDER_LIST = "fui_method_picker_provider_list" + + /** + * The "Continue as ..." button, shown when a previous sign-in preference is available. + */ + const val CONTINUE_AS_BUTTON = "fui_method_picker_continue_as_button" + } + + /** Tags on the phone number country selector bottom sheet. */ + object CountrySelector { + + /** The scrollable country list. */ + const val COUNTRY_LIST = "fui_country_selector_country_list" + } + + /** Tags on the email/password sign-in screen. */ + object SignIn { + + /** The email address input. */ + const val EMAIL_FIELD = "fui_sign_in_email_field" + + /** The password input. */ + const val PASSWORD_FIELD = "fui_sign_in_password_field" + + /** The button that submits the entered credentials. */ + const val SIGN_IN_BUTTON = "fui_sign_in_sign_in_button" + + /** The button that navigates to the sign-up screen. */ + const val SIGN_UP_BUTTON = "fui_sign_in_sign_up_button" + + /** The "trouble signing in" button that navigates to password recovery. */ + const val FORGOT_PASSWORD_BUTTON = "fui_sign_in_forgot_password_button" + + /** The button that switches to email link sign-in. */ + const val EMAIL_LINK_BUTTON = "fui_sign_in_email_link_button" + + /** The toggle that shows or hides the entered password. */ + const val PASSWORD_VISIBILITY_TOGGLE = "fui_sign_in_password_visibility_toggle" + + /** The top app bar's back navigation button. */ + const val BACK_BUTTON = "fui_sign_in_back_button" + } + + /** Tags on the email/password sign-up screen. */ + object SignUp { + + /** The display name input, shown when the provider requires a name. */ + const val NAME_FIELD = "fui_sign_up_name_field" + + /** The email address input. */ + const val EMAIL_FIELD = "fui_sign_up_email_field" + + /** The password input. */ + const val PASSWORD_FIELD = "fui_sign_up_password_field" + + /** The password confirmation input. */ + const val CONFIRM_PASSWORD_FIELD = "fui_sign_up_confirm_password_field" + + /** The button that submits the new account. */ + const val SIGN_UP_BUTTON = "fui_sign_up_sign_up_button" + + /** The button that navigates back to the sign-in screen. */ + const val SIGN_IN_BUTTON = "fui_sign_up_sign_in_button" + + /** The toggle that shows or hides the entered password. */ + const val PASSWORD_VISIBILITY_TOGGLE = "fui_sign_up_password_visibility_toggle" + + /** The toggle that shows or hides the entered password confirmation. */ + const val CONFIRM_PASSWORD_VISIBILITY_TOGGLE = + "fui_sign_up_confirm_password_visibility_toggle" + + /** The top app bar's back navigation button. */ + const val BACK_BUTTON = "fui_sign_up_back_button" + } + + /** Tags on the password recovery screen. */ + object ResetPassword { + + /** The email address input. */ + const val EMAIL_FIELD = "fui_reset_password_email_field" + + /** The button that sends the password reset link. */ + const val SEND_BUTTON = "fui_reset_password_send_button" + + /** The button that navigates back to the sign-in screen. */ + const val SIGN_IN_BUTTON = "fui_reset_password_sign_in_button" + + /** The dismiss button of the "reset link sent" dialog. */ + const val DISMISS_BUTTON = "fui_reset_password_dismiss_button" + + /** The top app bar's back navigation button. */ + const val BACK_BUTTON = "fui_reset_password_back_button" + } + + /** Tags on the email link ("magic link") sign-in screen. */ + object EmailLink { + + /** The email address input. */ + const val EMAIL_FIELD = "fui_email_link_email_field" + + /** The button that sends the sign-in link. */ + const val SEND_LINK_BUTTON = "fui_email_link_send_link_button" + + /** The button that switches back to password sign-in. */ + const val PASSWORD_SIGN_IN_BUTTON = "fui_email_link_password_sign_in_button" + + /** The dismiss button of the "sign-in link sent" dialog. */ + const val DISMISS_BUTTON = "fui_email_link_dismiss_button" + + /** The "trouble signing in" button that navigates to password recovery. */ + const val FORGOT_PASSWORD_BUTTON = "fui_email_link_forgot_password_button" + + /** The top app bar's back navigation button. */ + const val BACK_BUTTON = "fui_email_link_back_button" + } + + /** Tags on the phone number entry screen. */ + object PhoneNumber { + + /** The phone number input. */ + const val PHONE_NUMBER_FIELD = "fui_phone_number_phone_number_field" + + /** The control that opens the country selector bottom sheet. */ + const val COUNTRY_SELECTOR_BUTTON = "fui_phone_number_country_selector_button" + + /** The button that requests an SMS verification code. */ + const val SEND_CODE_BUTTON = "fui_phone_number_send_code_button" + + /** The top app bar's back navigation button. */ + const val BACK_BUTTON = "fui_phone_number_back_button" + } + + /** Tags on the SMS verification code screen. */ + object VerificationCode { + + /** + * The verification code input group (one box per digit). The group itself is the + * editable node, so a single action enters the whole code. + */ + const val CODE_FIELD = "fui_verification_code_code_field" + + /** The button that submits the entered code. */ + const val VERIFY_BUTTON = "fui_verification_code_verify_button" + + /** The button that requests a new code. */ + const val RESEND_CODE_BUTTON = "fui_verification_code_resend_code_button" + + /** The button that returns to phone number entry. */ + const val CHANGE_PHONE_NUMBER_BUTTON = "fui_verification_code_change_phone_number_button" + + /** The top app bar's back navigation button. */ + const val BACK_BUTTON = "fui_verification_code_back_button" + } + + /** + * Tags on the multi-factor sign-in challenge screen (the second factor requested during + * sign-in, distinct from MFA enrollment). + */ + object MfaChallenge { + + /** The verification code input, for both the SMS and TOTP factors. */ + const val CODE_FIELD = "fui_mfa_challenge_code_field" + + /** The button that submits the entered code. */ + const val VERIFY_BUTTON = "fui_mfa_challenge_verify_button" + + /** The button that requests a new code. SMS factor only. */ + const val RESEND_CODE_BUTTON = "fui_mfa_challenge_resend_code_button" + + /** + * The button that cancels the challenge and returns to sign-in. Shared by the SMS and + * TOTP variants, which never compose at the same time. + */ + const val CANCEL_BUTTON = "fui_mfa_challenge_cancel_button" + } + + /** + * Tags on the re-authentication dialog. Kept separate from [SignIn] because the dialog and + * the flow behind it can be composed at the same time. + */ + object Reauth { + + /** The password input. */ + const val PASSWORD_FIELD = "fui_reauth_password_field" + + /** The button that submits the password. */ + const val VERIFY_BUTTON = "fui_reauth_verify_button" + + /** The button that dismisses the dialog without re-authenticating. */ + const val DISMISS_BUTTON = "fui_reauth_dismiss_button" + } + + /** Tags on the error recovery dialog. Only one instance is ever composed at a time. */ + object ErrorRecovery { + + /** The recovery/retry action; its label varies with the error being recovered from. */ + const val RETRY_BUTTON = "fui_error_recovery_retry_button" + + /** The button that dismisses the dialog without recovering. */ + const val DISMISS_BUTTON = "fui_error_recovery_dismiss_button" + } + + /** + * Tags on the terms-of-service and privacy-policy links shown at the bottom of several + * screens. Component-scoped since call sites never compose more than one instance at once. + */ + object TermsAndPrivacy { + + /** The link that opens the terms-of-service URL. */ + const val TOS_LINK = "fui_terms_and_privacy_tos_link" + + /** The link that opens the privacy-policy URL. */ + const val PRIVACY_LINK = "fui_terms_and_privacy_privacy_link" + } + + /** + * Tags on the multi-factor enrollment flow. Enroll/remove buttons are keyed per-factor since + * more than one can be shown at once. + */ + object MfaEnrollment { + + /** The button that starts SMS enrollment, shown on the factor-selection step. */ + const val ENROLL_SMS_BUTTON = "fui_mfa_enrollment_enroll_sms_button" + + /** The button that starts TOTP enrollment, shown on the factor-selection step. */ + const val ENROLL_TOTP_BUTTON = "fui_mfa_enrollment_enroll_totp_button" + + /** The button that removes an already-enrolled SMS factor. */ + const val REMOVE_SMS_BUTTON = "fui_mfa_enrollment_remove_sms_button" + + /** The button that removes an already-enrolled TOTP factor. */ + const val REMOVE_TOTP_BUTTON = "fui_mfa_enrollment_remove_totp_button" + + /** The button that skips enrollment, shown on the factor-selection step when optional. */ + const val SKIP_BUTTON = "fui_mfa_enrollment_skip_button" + + /** The button that returns from the TOTP secret/QR step to factor selection. */ + const val CONFIGURE_TOTP_BACK_BUTTON = "fui_mfa_enrollment_configure_totp_back_button" + + /** The button that advances from the TOTP secret/QR step to code verification. */ + const val CONFIGURE_TOTP_CONTINUE_BUTTON = + "fui_mfa_enrollment_configure_totp_continue_button" + + /** + * The input for the code generated by the user's authenticator app, on the TOTP + * verification step. + */ + const val VERIFY_TOTP_CODE_FIELD = "fui_mfa_enrollment_verify_totp_code_field" + + /** The button that returns from TOTP code verification to the secret/QR step. */ + const val VERIFY_TOTP_BACK_BUTTON = "fui_mfa_enrollment_verify_totp_back_button" + + /** The button that submits the entered TOTP code to complete verification. */ + const val VERIFY_TOTP_BUTTON = "fui_mfa_enrollment_verify_totp_button" + + /** The button confirming the user has saved their recovery codes, completing enrollment. */ + const val RECOVERY_CODES_SAVED_BUTTON = "fui_mfa_enrollment_recovery_codes_saved_button" + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/TestTagsAsResourceIds.kt b/auth/src/main/java/com/firebase/ui/auth/ui/TestTagsAsResourceIds.kt new file mode 100644 index 000000000..d4eb3fb6f --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/TestTagsAsResourceIds.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui + +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId + +/** + * Publishes [FirebaseAuthTestTags] as resource ids for Robo/`By.res()`. Apply per semantics + * owner (each dialog/sheet/Scaffold), even ones untagged today, to avoid silent regressions. + */ +internal fun Modifier.exposeTestTagsAsResourceIds(): Modifier = + semantics { testTagsAsResourceId = true } diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthProviderButton.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthProviderButton.kt index 9a3f5e1a2..c0209dbc5 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthProviderButton.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthProviderButton.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape @@ -66,7 +67,8 @@ import com.firebase.ui.auth.configuration.theme.ProviderStyleDefaults * ) * ``` * - * @param modifier A modifier for the button + * @param modifier Applied to the button itself; the content row always fills available width, so + * constrain sizing from the parent layout instead. * @param provider The provider to represent. * @param onClick A callback when the button is clicked * @param enabled If the button is enabled. Defaults to true. @@ -118,7 +120,7 @@ fun AuthProviderButton( enabled = enabled, ) { Row( - modifier = modifier, + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start ) { diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthTextField.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthTextField.kt index 253a6e260..07be8b07d 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthTextField.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthTextField.kt @@ -86,6 +86,8 @@ import com.firebase.ui.auth.configuration.validators.PasswordValidator * @param visualTransformation Visual transformation for the input (e.g., password). * @param leadingIcon An optional icon to display at the start of the field. * @param trailingIcon An optional icon to display at the start of the field. + * @param visibilityToggleModifier A modifier for the password visibility toggle button, separate + * from [modifier] which targets the field itself — e.g. to apply a test tag to the toggle. */ @Composable fun AuthTextField( @@ -103,6 +105,7 @@ fun AuthTextField( visualTransformation: VisualTransformation = VisualTransformation.None, leadingIcon: @Composable (() -> Unit)? = null, trailingIcon: @Composable (() -> Unit)? = null, + visibilityToggleModifier: Modifier = Modifier, ) { var passwordVisible by remember { mutableStateOf(false) } @@ -167,6 +170,7 @@ fun AuthTextField( trailingIcon = trailingIcon ?: { if (isSecureTextField) { IconButton( + modifier = visibilityToggleModifier, onClick = { passwordVisible = !passwordVisible } diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/CountrySelector.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/CountrySelector.kt index 425aa32bc..1893e4ca9 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/components/CountrySelector.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/CountrySelector.kt @@ -57,6 +57,8 @@ import androidx.compose.ui.unit.dp import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider import com.firebase.ui.auth.data.ALL_COUNTRIES import com.firebase.ui.auth.data.CountryData +import com.firebase.ui.auth.ui.FirebaseAuthTestTags +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds import com.firebase.ui.auth.util.CountryUtils import kotlinx.coroutines.launch @@ -64,6 +66,7 @@ import kotlinx.coroutines.launch * A country selector component that displays the selected country's flag and dial code with a dropdown icon. * Designed to be used as a leadingIcon in a TextField. * + * @param modifier A modifier for the clickable row that opens the country list. * @param selectedCountry The currently selected country. * @param onCountrySelected Callback when a country is selected. * @param enabled Whether the selector is enabled. @@ -72,6 +75,7 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun CountrySelector( + modifier: Modifier = Modifier, selectedCountry: CountryData, onCountrySelected: (CountryData) -> Unit, enabled: Boolean = true, @@ -104,7 +108,7 @@ fun CountrySelector( // Clickable row showing flag, dial code and dropdown icon Row( - modifier = Modifier + modifier = modifier .fillMaxHeight() .clickable(enabled = enabled) { showBottomSheet = true @@ -134,6 +138,7 @@ fun CountrySelector( if (showBottomSheet) { ModalBottomSheet( + modifier = Modifier.exposeTestTagsAsResourceIds(), onDismissRequest = { showBottomSheet = false searchQuery = "" @@ -165,7 +170,7 @@ fun CountrySelector( modifier = Modifier .fillMaxWidth() .height(500.dp) - .testTag("CountrySelector LazyColumn") + .testTag(FirebaseAuthTestTags.CountrySelector.COUNTRY_LIST) ) { items(filteredCountries) { country -> Button( diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt index a3e216ebe..7bccb40e5 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt @@ -22,9 +22,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.window.DialogProperties import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.ui.FirebaseAuthTestTags +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds import com.google.firebase.auth.EmailAuthProvider import com.google.firebase.auth.FacebookAuthProvider import com.google.firebase.auth.GithubAuthProvider @@ -99,6 +102,7 @@ fun ErrorRecoveryDialog( confirmButton = { if (isRecoverable(error)) { TextButton( + modifier = Modifier.testTag(FirebaseAuthTestTags.ErrorRecovery.RETRY_BUTTON), onClick = { onRecover?.invoke(error) ?: onRetry(error) } @@ -111,14 +115,17 @@ fun ErrorRecoveryDialog( } }, dismissButton = { - TextButton(onClick = onDismiss) { + TextButton( + modifier = Modifier.testTag(FirebaseAuthTestTags.ErrorRecovery.DISMISS_BUTTON), + onClick = onDismiss + ) { Text( text = stringProvider.dismissAction, style = MaterialTheme.typography.labelLarge ) } }, - modifier = modifier, + modifier = modifier.exposeTestTagsAsResourceIds(), properties = properties ) } diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/ReauthenticationDialog.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/ReauthenticationDialog.kt index 4622de957..9921c4347 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/components/ReauthenticationDialog.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/ReauthenticationDialog.kt @@ -41,12 +41,15 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.ui.FirebaseAuthTestTags +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds import com.google.firebase.auth.EmailAuthProvider import com.google.firebase.auth.FirebaseUser import kotlinx.coroutines.launch @@ -75,6 +78,7 @@ fun ReauthenticationDialog( } AlertDialog( + modifier = Modifier.exposeTestTagsAsResourceIds(), onDismissRequest = { if (!isLoading) onDismiss() }, title = { val view = LocalView.current @@ -139,6 +143,7 @@ fun ReauthenticationDialog( modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester) + .testTag(FirebaseAuthTestTags.Reauth.PASSWORD_FIELD) ) if (isLoading) { @@ -166,7 +171,8 @@ fun ReauthenticationDialog( ) } }, - enabled = password.isNotBlank() && !isLoading + enabled = password.isNotBlank() && !isLoading, + modifier = Modifier.testTag(FirebaseAuthTestTags.Reauth.VERIFY_BUTTON) ) { Text(stringProvider.verifyAction) } @@ -174,7 +180,8 @@ fun ReauthenticationDialog( dismissButton = { TextButton( onClick = onDismiss, - enabled = !isLoading + enabled = !isLoading, + modifier = Modifier.testTag(FirebaseAuthTestTags.Reauth.DISMISS_BUTTON) ) { Text(stringProvider.dismissAction) } diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/TermsAndPrivacyForm.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/TermsAndPrivacyForm.kt index 5cf33cd5a..4717416b1 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/components/TermsAndPrivacyForm.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/TermsAndPrivacyForm.kt @@ -24,11 +24,14 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import com.firebase.ui.auth.R +import com.firebase.ui.auth.ui.FirebaseAuthTestTags +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds @Composable fun TermsAndPrivacyForm( @@ -38,9 +41,12 @@ fun TermsAndPrivacyForm( ) { val uriHandler = LocalUriHandler.current Row( - modifier = modifier, + // Flagged here too (a no-op if an ancestor already is) so tags stay exposed for any + // future caller without a flagged ancestor. + modifier = modifier.exposeTestTagsAsResourceIds(), ) { TextButton( + modifier = Modifier.testTag(FirebaseAuthTestTags.TermsAndPrivacy.TOS_LINK), onClick = { tosUrl?.let { uriHandler.openUri(it) @@ -57,6 +63,7 @@ fun TermsAndPrivacyForm( } Spacer(modifier = Modifier.width(24.dp)) TextButton( + modifier = Modifier.testTag(FirebaseAuthTestTags.TermsAndPrivacy.PRIVACY_LINK), onClick = { ppUrl?.let { uriHandler.openUri(it) diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/VerificationCodeInputField.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/VerificationCodeInputField.kt index 58137835c..e0388bc15 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/components/VerificationCodeInputField.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/VerificationCodeInputField.kt @@ -54,12 +54,34 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.editableText +import androidx.compose.ui.semantics.insertTextAtCursor +import androidx.compose.ui.semantics.isEditable +import androidx.compose.ui.semantics.maxTextLength +import androidx.compose.ui.semantics.requestFocus import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.setText +import androidx.compose.ui.text.AnnotatedString import androidx.core.text.isDigitsOnly +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider import com.firebase.ui.auth.configuration.theme.AuthUITheme import com.firebase.ui.auth.configuration.validators.FieldValidator +/** + * A row of [codeLength] single-character boxes that together hold one verification code. The + * group itself declares the text-input semantics so Robo/UiAutomator can `ACTION_SET_TEXT` the + * whole code in one action, since the individual boxes can't be addressed that way (issue #2050). + * + * @param modifier Applied to the group; a testTag here is the handle for resource-id lookups. + * @param codeLength How many digits the code has, and therefore how many boxes are drawn. + * @param validator Optional validator run against the code on every change; when supplied it drives + * the error state instead of [isError]. + * @param isError Whether to draw the boxes in their error state. Ignored when [validator] is set. + * @param errorMessage Message shown beneath the boxes. Ignored when [validator] is set. + * @param onCodeComplete Called with the code once every box is filled. + * @param onCodeChange Called with the code so far on every change. + */ @Composable fun VerificationCodeInputField( modifier: Modifier = Modifier, @@ -74,6 +96,7 @@ fun VerificationCodeInputField( val focusedIndex = remember { mutableStateOf(null) } val focusRequesters = remember { (1..codeLength).map { FocusRequester() } } val keyboardManager = LocalSoftwareKeyboardController.current + val stringProvider = LocalAuthUIStringProvider.current // Derive validation state val currentCodeString = remember { mutableStateOf("") } @@ -127,8 +150,69 @@ fun VerificationCodeInputField( errorMessage } + // The digits of [text], or null when this field cannot hold them. "Digit" follows + // Character.isDigit, so Arabic-Indic/fullwidth digits are accepted and normalised to ASCII. + fun digitsOf(text: String, availableSlots: Int): List? = when { + text.isEmpty() -> emptyList() + text.length > availableSlots -> null + !text.isDigitsOnly() -> null + else -> text.map { it.digitToInt() } + } + + // Index the visible cursor should sit at once [filled] boxes are occupied from the start. + fun cursorAfter(filled: Int): Int = filled.coerceIn(0, codeLength - 1) + Column( - modifier = modifier, + modifier = modifier + // Applied after [modifier] so a caller-supplied testTag lands on the same node + // these text-input actions are declared on. + .semantics { + // TODO: give this group a proper label via AuthUIStringProvider (tracked + // separately). Must be contentDescription, not text — text breaks Robo detection. + isEditable = true + maxTextLength = codeLength + // Digits before the first empty box, not every digit entered — boxes fill + // non-contiguously, so compacting would misreport a gap-filled state. + editableText = AnnotatedString( + code.value.takeWhile { it != null }.joinToString("") + ) + + setText { newCode -> + val digits = digitsOf(newCode.text, codeLength) ?: return@setText false + code.value = List(codeLength) { index -> digits.getOrNull(index) } + focusedIndex.value = cursorAfter(digits.size) + true + } + + insertTextAtCursor { inserted -> + // Inserting nothing succeeds before the full-code guard is reached, because + // inserting nothing into a full code is a no-op and a no-op is not a failure. + if (inserted.text.isEmpty()) return@insertTextAtCursor true + + val firstEmpty = code.value.indexOfFirst { it == null } + if (firstEmpty < 0) return@insertTextAtCursor false + + val digits = digitsOf(inserted.text, codeLength - firstEmpty) + ?: return@insertTextAtCursor false + + code.value = code.value.toMutableList().also { updated -> + digits.forEachIndexed { offset, digit -> + updated[firstEmpty + offset] = digit + } + } + focusedIndex.value = cursorAfter(firstEmpty + digits.size) + true + } + + // Moves focus via the index the widget watches, not a FocusRequester, so this + // can't throw if invoked before the boxes are attached. + requestFocus { + focusedIndex.value = + code.value.indexOfFirst { it == null }.takeIf { it >= 0 } + ?: (codeLength - 1) + true + } + }, horizontalAlignment = Alignment.CenterHorizontally ) { Row( @@ -142,6 +226,10 @@ fun VerificationCodeInputField( .aspectRatio(1f), number = number, isError = showError, + digitContentDescription = stringProvider.verificationCodeDigitDescription( + position = index + 1, + total = codeLength + ), focusRequester = focusRequesters[index], onFocusChanged = { isFocused -> if (isFocused) { @@ -191,6 +279,7 @@ private fun SingleDigitField( modifier: Modifier = Modifier, number: Int?, isError: Boolean = false, + digitContentDescription: String, focusRequester: FocusRequester, onFocusChanged: (Boolean) -> Unit, onNumberChanged: (Int?) -> Unit, @@ -253,7 +342,7 @@ private fun SingleDigitField( .fillMaxSize() .wrapContentSize() .semantics { - contentDescription = "Verification code digit" + contentDescription = digitContentDescription } .focusRequester(focusRequester) .onFocusChanged { diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt b/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt index 26c2feaed..cf0328c2a 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt @@ -43,7 +43,9 @@ import com.firebase.ui.auth.configuration.auth_provider.AuthProvider import com.firebase.ui.auth.configuration.auth_provider.Provider import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider import com.firebase.ui.auth.configuration.theme.AuthUIAsset +import com.firebase.ui.auth.ui.FirebaseAuthTestTags import com.firebase.ui.auth.ui.components.AuthProviderButton +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds import com.firebase.ui.auth.util.SignInPreferenceManager /** @@ -117,7 +119,7 @@ fun AuthMethodPicker( termsConfiguration.accepted Column( - modifier = modifier + modifier = modifier.exposeTestTagsAsResourceIds() ) { logo?.let { Image( @@ -144,7 +146,7 @@ fun AuthMethodPicker( modifier = Modifier .widthIn(max = 400.dp) .padding(horizontal = 24.dp) - .testTag("AuthMethodPicker LazyColumn"), + .testTag(FirebaseAuthTestTags.MethodPicker.PROVIDER_LIST), horizontalAlignment = Alignment.CenterHorizontally, ) { // Show "Continue as..." button if last sign-in preference exists @@ -239,7 +241,7 @@ private fun ContinueAsButton( AuthProviderButton( modifier = Modifier .fillMaxWidth() - .testTag("ContinueAsButton"), + .testTag(FirebaseAuthTestTags.MethodPicker.CONTINUE_AS_BUTTON), onClick = onClick, enabled = enabled, provider = provider, diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt index 14e0965f7..f4d2bac5e 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt @@ -81,6 +81,7 @@ import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController import com.firebase.ui.auth.ui.components.rememberTopLevelDialogController import com.firebase.ui.auth.mfa.MfaChallengeContentState import com.firebase.ui.auth.mfa.MfaEnrollmentContentState +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds import com.firebase.ui.auth.ui.method_picker.AuthMethodPicker import com.firebase.ui.auth.ui.method_picker.MethodPickerTermsConfiguration import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState @@ -103,6 +104,8 @@ import kotlinx.coroutines.tasks.await * flows, error handling, and multi-factor enrollment/challenge flows. Back navigation is driven by * the Jetpack Navigation stack so presses behave like native Android navigation. * + * @param modifier Applied once to the root [Surface]; it does not reach dialogs/sheets, which are + * separate semantics owners the library flags for test-tag exposure on its own. * @param authenticatedContent Optional slot that allows callers to render the authenticated * state themselves. When provided, it receives the current [AuthState] alongside an * [AuthSuccessUiContext] containing common callbacks (sign out, manage MFA, reload user). @@ -202,8 +205,9 @@ fun FirebaseAuthScreen( LocalAuthUITheme provides (configuration.theme ?: LocalAuthUITheme.current) ) { Surface( - modifier = Modifier + modifier = modifier .fillMaxSize() + .exposeTestTagsAsResourceIds() ) { NavHost( navController = navController, @@ -223,13 +227,15 @@ fun FirebaseAuthScreen( ) { composable(AuthRoute.MethodPicker.route) { if (customMethodPickerLayout != null) { - Box(modifier = modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize()) { customMethodPickerLayout(configuration.providers, onProviderSelected) } } else { - Scaffold { innerPadding -> + // Redundant under the flagged Surface above, but kept uniform per + // exposeTestTagsAsResourceIds. + Scaffold(modifier = Modifier.exposeTestTagsAsResourceIds()) { innerPadding -> AuthMethodPicker( - modifier = modifier + modifier = Modifier .padding(innerPadding), providers = configuration.providers, logo = logoAsset, @@ -711,6 +717,7 @@ fun FirebaseAuthScreen( val reauthConfig = pendingReauthConfig.value if (reauthConfig != null) { ModalBottomSheet( + modifier = Modifier.exposeTestTagsAsResourceIds(), onDismissRequest = { pendingReauthOperation.value = null pendingReauthConfig.value = null @@ -844,7 +851,9 @@ private fun AuthSuccessContent( TooltipAnchorPosition.Above ), tooltip = { - PlainTooltip { + // The tooltip is its own semantics owner (a popup), so it's flagged even + // though nothing inside it is tagged yet. + PlainTooltip(modifier = Modifier.exposeTestTagsAsResourceIds()) { Text(stringProvider.mfaDisabledTooltip) } }, @@ -929,6 +938,7 @@ private fun ProfileCompletionContent( @Composable private fun LoadingDialog(message: String) { AlertDialog( + modifier = Modifier.exposeTestTagsAsResourceIds(), onDismissRequest = {}, confirmButton = {}, containerColor = Color.Transparent, @@ -987,7 +997,9 @@ private fun ReauthSheetContent( customMethodPickerLayout(reauthConfig.providers, onProviderSelected) } } else { - Scaffold { innerPadding -> + // Same reasoning as FirebaseAuthScreen's Scaffold: flagged even though the + // enclosing ModalBottomSheet already covers this subtree. + Scaffold(modifier = Modifier.exposeTestTagsAsResourceIds()) { innerPadding -> AuthMethodPicker( modifier = Modifier.padding(innerPadding), providers = reauthConfig.providers, diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeDefaults.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeDefaults.kt index 0780348ee..0711674d7 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeDefaults.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeDefaults.kt @@ -36,6 +36,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -45,7 +46,9 @@ import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvi import com.firebase.ui.auth.configuration.theme.AuthUITheme import com.firebase.ui.auth.configuration.validators.VerificationCodeValidator import com.firebase.ui.auth.mfa.MfaChallengeContentState +import com.firebase.ui.auth.ui.FirebaseAuthTestTags import com.firebase.ui.auth.ui.components.VerificationCodeInputField +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds @Composable internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) { @@ -55,7 +58,7 @@ internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) { VerificationCodeValidator(stringProvider) } - Scaffold { innerPadding -> + Scaffold(modifier = Modifier.exposeTestTagsAsResourceIds()) { innerPadding -> Column( modifier = Modifier .fillMaxWidth() @@ -97,7 +100,9 @@ internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) { Spacer(modifier = Modifier.height(8.dp)) VerificationCodeInputField( - modifier = Modifier.align(Alignment.CenterHorizontally), + modifier = Modifier + .align(Alignment.CenterHorizontally) + .testTag(FirebaseAuthTestTags.MfaChallenge.CODE_FIELD), codeLength = 6, validator = verificationCodeValidator, isError = state.error != null, @@ -114,6 +119,9 @@ internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) { verticalAlignment = Alignment.CenterVertically ) { TextButton( + modifier = Modifier.testTag( + FirebaseAuthTestTags.MfaChallenge.RESEND_CODE_BUTTON + ), onClick = { state.onResendCodeClick?.invoke() }, enabled = state.onResendCodeClick != null && !state.isLoading && state.resendTimer == 0 ) { @@ -130,6 +138,7 @@ internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) { } TextButton( + modifier = Modifier.testTag(FirebaseAuthTestTags.MfaChallenge.CANCEL_BUTTON), onClick = state.onCancelClick, enabled = !state.isLoading ) { @@ -140,7 +149,9 @@ internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) { OutlinedButton( onClick = state.onCancelClick, enabled = !state.isLoading, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .testTag(FirebaseAuthTestTags.MfaChallenge.CANCEL_BUTTON) ) { Text(stringProvider.dismissAction) } @@ -149,7 +160,9 @@ internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) { Button( onClick = state.onVerifyClick, enabled = state.isValid && !state.isLoading, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .testTag(FirebaseAuthTestTags.MfaChallenge.VERIFY_BUTTON) ) { if (state.isLoading) { CircularProgressIndicator( diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentDefaults.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentDefaults.kt index 1cbb45949..ec97f3360 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentDefaults.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentDefaults.kt @@ -46,6 +46,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -57,8 +58,10 @@ import com.firebase.ui.auth.configuration.theme.AuthUITheme import com.firebase.ui.auth.mfa.MfaEnrollmentContentState import com.firebase.ui.auth.mfa.MfaEnrollmentStep import com.firebase.ui.auth.mfa.toMfaErrorMessage +import com.firebase.ui.auth.ui.FirebaseAuthTestTags import com.firebase.ui.auth.ui.components.QrCodeImage import com.firebase.ui.auth.ui.components.ReauthenticationDialog +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds import com.firebase.ui.auth.ui.screens.phone.EnterPhoneNumberUI import com.firebase.ui.auth.ui.screens.phone.EnterVerificationCodeUI import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException @@ -130,6 +133,8 @@ internal fun DefaultMfaEnrollmentContent( ) } + // Each step flags itself with exposeTestTagsAsResourceIds() because this screen is public and + // can be called standalone, with no flagged ancestor of ours above it. Box(modifier = Modifier.fillMaxSize()) { when (state.step) { MfaEnrollmentStep.SelectFactor -> { @@ -252,6 +257,7 @@ private fun SelectFactorUI( val factorsToEnroll = availableFactors.filter { it !in enrolledFactorIds } Scaffold( + modifier = Modifier.exposeTestTagsAsResourceIds(), topBar = { TopAppBar( title = { Text(stringProvider.mfaManageFactorsTitle) }, @@ -311,11 +317,20 @@ private fun SelectFactorUI( modifier = Modifier.fillMaxWidth() ) + // Keyed per factor because a user enrolled in neither factor sees both buttons + // at once, and a shared tag would collide. factorsToEnroll.forEach { factor -> Button( onClick = { onFactorSelected(factor) }, enabled = !isLoading, - modifier = Modifier.fillMaxWidth() + modifier = when (factor) { + MfaFactor.Sms -> Modifier + .fillMaxWidth() + .testTag(FirebaseAuthTestTags.MfaEnrollment.ENROLL_SMS_BUTTON) + MfaFactor.Totp -> Modifier + .fillMaxWidth() + .testTag(FirebaseAuthTestTags.MfaEnrollment.ENROLL_TOTP_BUTTON) + } ) { when (factor) { MfaFactor.Sms -> Text(stringProvider.mfaStepConfigureSmsTitle) @@ -335,6 +350,7 @@ private fun SelectFactorUI( onSkipClick?.let { TextButton( + modifier = Modifier.testTag(FirebaseAuthTestTags.MfaEnrollment.SKIP_BUTTON), onClick = it, enabled = !isLoading ) { @@ -394,9 +410,18 @@ private fun EnrolledFactorItem( color = MaterialTheme.colorScheme.onSurfaceVariant ) } + // Same collision risk as the enroll buttons above (one item per enrolled factor), + // resolved the same way: key the tag off the factor type. OutlinedButton( onClick = onRemove, enabled = enabled, + modifier = when (factorInfo) { + is PhoneMultiFactorInfo -> + Modifier.testTag(FirebaseAuthTestTags.MfaEnrollment.REMOVE_SMS_BUTTON) + is TotpMultiFactorInfo -> + Modifier.testTag(FirebaseAuthTestTags.MfaEnrollment.REMOVE_TOTP_BUTTON) + else -> Modifier + }, colors = ButtonDefaults.outlinedButtonColors( contentColor = MaterialTheme.colorScheme.error ) @@ -418,7 +443,7 @@ private fun ConfigureTotpUI( error: String?, stringProvider: AuthUIStringProvider ) { - Scaffold { innerPadding -> + Scaffold(modifier = Modifier.exposeTestTagsAsResourceIds()) { innerPadding -> Column( modifier = Modifier .fillMaxSize() @@ -479,7 +504,9 @@ private fun ConfigureTotpUI( TextButton( onClick = onBackClick, enabled = !isLoading, - modifier = Modifier.weight(1f) + modifier = Modifier + .weight(1f) + .testTag(FirebaseAuthTestTags.MfaEnrollment.CONFIGURE_TOTP_BACK_BUTTON) ) { Text(stringProvider.backAction) } @@ -487,7 +514,9 @@ private fun ConfigureTotpUI( Button( onClick = onContinueClick, enabled = !isLoading && isValid, - modifier = Modifier.weight(1f) + modifier = Modifier + .weight(1f) + .testTag(FirebaseAuthTestTags.MfaEnrollment.CONFIGURE_TOTP_CONTINUE_BUTTON) ) { Text(stringProvider.continueText) } @@ -507,7 +536,7 @@ private fun VerifyTotpUI( error: String?, stringProvider: AuthUIStringProvider ) { - Scaffold { innerPadding -> + Scaffold(modifier = Modifier.exposeTestTagsAsResourceIds()) { innerPadding -> Column( modifier = Modifier .fillMaxSize() @@ -545,7 +574,9 @@ private fun VerifyTotpUI( label = { Text(stringProvider.verificationCodeLabel) }, enabled = !isLoading, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .testTag(FirebaseAuthTestTags.MfaEnrollment.VERIFY_TOTP_CODE_FIELD) ) Row( @@ -555,7 +586,9 @@ private fun VerifyTotpUI( OutlinedButton( onClick = onBackClick, enabled = !isLoading, - modifier = Modifier.weight(1f) + modifier = Modifier + .weight(1f) + .testTag(FirebaseAuthTestTags.MfaEnrollment.VERIFY_TOTP_BACK_BUTTON) ) { Text(stringProvider.backAction) } @@ -563,7 +596,9 @@ private fun VerifyTotpUI( Button( onClick = onVerifyClick, enabled = !isLoading && isValid, - modifier = Modifier.weight(1f) + modifier = Modifier + .weight(1f) + .testTag(FirebaseAuthTestTags.MfaEnrollment.VERIFY_TOTP_BUTTON) ) { Text(stringProvider.verifyAction) } @@ -580,7 +615,7 @@ private fun ShowRecoveryCodesUI( error: String?, stringProvider: AuthUIStringProvider ) { - Scaffold { innerPadding -> + Scaffold(modifier = Modifier.exposeTestTagsAsResourceIds()) { innerPadding -> Column( modifier = Modifier .fillMaxSize() @@ -631,7 +666,9 @@ private fun ShowRecoveryCodesUI( Button( onClick = onDoneClick, enabled = !isLoading, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .testTag(FirebaseAuthTestTags.MfaEnrollment.RECOVERY_CODES_SAVED_BUTTON) ) { Text(stringProvider.recoveryCodesSavedAction) } diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt index 7d1de8a23..ceee8456e 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt @@ -43,6 +43,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -52,8 +53,10 @@ import com.firebase.ui.auth.configuration.auth_provider.AuthProvider import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider import com.firebase.ui.auth.configuration.theme.AuthUITheme import com.firebase.ui.auth.configuration.validators.EmailValidator +import com.firebase.ui.auth.ui.FirebaseAuthTestTags import com.firebase.ui.auth.ui.components.AuthTextField import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -83,6 +86,7 @@ fun ResetPasswordUI( if (isDialogVisible.value) { AlertDialog( + modifier = Modifier.exposeTestTagsAsResourceIds(), title = { Text( text = stringProvider.recoverPasswordLinkSentDialogTitle, @@ -98,6 +102,8 @@ fun ResetPasswordUI( }, confirmButton = { TextButton( + modifier = Modifier + .testTag(FirebaseAuthTestTags.ResetPassword.DISMISS_BUTTON), onClick = { onGoToSignIn() isDialogVisible.value = false @@ -113,7 +119,7 @@ fun ResetPasswordUI( } Scaffold( - modifier = modifier, + modifier = modifier.exposeTestTagsAsResourceIds(), topBar = { TopAppBar( title = { @@ -121,7 +127,12 @@ fun ResetPasswordUI( }, navigationIcon = { if (onNavigateBack != null) { - IconButton(onClick = onNavigateBack) { + IconButton( + onClick = onNavigateBack, + modifier = Modifier.testTag( + FirebaseAuthTestTags.ResetPassword.BACK_BUTTON + ) + ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringProvider.backAction @@ -140,6 +151,7 @@ fun ResetPasswordUI( .verticalScroll(rememberScrollState()), ) { AuthTextField( + modifier = Modifier.testTag(FirebaseAuthTestTags.ResetPassword.EMAIL_FIELD), value = email, validator = emailValidator, enabled = !isLoading, @@ -156,6 +168,8 @@ fun ResetPasswordUI( .align(Alignment.End), ) { Button( + modifier = Modifier + .testTag(FirebaseAuthTestTags.ResetPassword.SIGN_IN_BUTTON), onClick = { onGoToSignIn() }, @@ -165,6 +179,8 @@ fun ResetPasswordUI( } Spacer(modifier = Modifier.width(16.dp)) Button( + modifier = Modifier + .testTag(FirebaseAuthTestTags.ResetPassword.SEND_BUTTON), onClick = { onSendResetLink() }, diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt index f2ec55fa3..e059f00c3 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt @@ -45,6 +45,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign @@ -57,8 +58,10 @@ import com.firebase.ui.auth.configuration.auth_provider.AuthProvider import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider import com.firebase.ui.auth.configuration.theme.AuthUITheme import com.firebase.ui.auth.configuration.validators.EmailValidator +import com.firebase.ui.auth.ui.FirebaseAuthTestTags import com.firebase.ui.auth.ui.components.AuthTextField import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds import com.google.firebase.auth.actionCodeSettings @OptIn(ExperimentalMaterial3Api::class) @@ -91,6 +94,7 @@ fun SignInEmailLinkUI( if (isDialogVisible.value) { AlertDialog( + modifier = Modifier.exposeTestTagsAsResourceIds(), title = { Text( text = stringProvider.emailSignInLinkSentDialogTitle, @@ -106,6 +110,8 @@ fun SignInEmailLinkUI( }, confirmButton = { TextButton( + modifier = Modifier + .testTag(FirebaseAuthTestTags.EmailLink.DISMISS_BUTTON), onClick = { isDialogVisible.value = false } @@ -121,7 +127,7 @@ fun SignInEmailLinkUI( } Scaffold( - modifier = modifier, + modifier = modifier.exposeTestTagsAsResourceIds(), topBar = { TopAppBar( title = { @@ -132,7 +138,10 @@ fun SignInEmailLinkUI( }, navigationIcon = { if (onNavigateBack != null) { - IconButton(onClick = onNavigateBack) { + IconButton( + onClick = onNavigateBack, + modifier = Modifier.testTag(FirebaseAuthTestTags.EmailLink.BACK_BUTTON) + ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringProvider.backAction @@ -151,6 +160,7 @@ fun SignInEmailLinkUI( .verticalScroll(rememberScrollState()), ) { AuthTextField( + modifier = Modifier.testTag(FirebaseAuthTestTags.EmailLink.EMAIL_FIELD), value = email, validator = emailValidator, enabled = !isLoading, @@ -164,7 +174,8 @@ fun SignInEmailLinkUI( Spacer(modifier = Modifier.height(16.dp)) TextButton( modifier = Modifier - .align(Alignment.Start), + .align(Alignment.Start) + .testTag(FirebaseAuthTestTags.EmailLink.FORGOT_PASSWORD_BUTTON), onClick = { onGoToResetPassword() }, @@ -172,7 +183,6 @@ fun SignInEmailLinkUI( contentPadding = PaddingValues.Zero ) { Text( - modifier = modifier, text = stringProvider.troubleSigningIn, style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, @@ -184,7 +194,9 @@ fun SignInEmailLinkUI( onClick = { onSignInWithEmailLink() }, - modifier = Modifier.align(Alignment.End), + modifier = Modifier + .align(Alignment.End) + .testTag(FirebaseAuthTestTags.EmailLink.SEND_LINK_BUTTON), enabled = !isLoading && isFormValid.value, ) { if (isLoading) { @@ -215,7 +227,9 @@ fun SignInEmailLinkUI( onClick = { onGoToSignIn() }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .testTag(FirebaseAuthTestTags.EmailLink.PASSWORD_SIGN_IN_BUTTON), enabled = !isLoading ) { Text(stringProvider.signInWithPassword.uppercase()) diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt index eb8b50159..9d5c1d436 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt @@ -48,6 +48,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign @@ -66,9 +67,11 @@ import com.firebase.ui.auth.credentialmanager.PasswordCredentialCancelledExcepti import com.firebase.ui.auth.credentialmanager.PasswordCredentialException import com.firebase.ui.auth.credentialmanager.PasswordCredentialHandler import com.firebase.ui.auth.credentialmanager.PasswordCredentialNotFoundException +import com.firebase.ui.auth.ui.FirebaseAuthTestTags import com.firebase.ui.auth.ui.components.AuthTextField import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -145,7 +148,7 @@ fun SignInUI( } Scaffold( - modifier = modifier, + modifier = modifier.exposeTestTagsAsResourceIds(), topBar = { TopAppBar( title = { @@ -156,7 +159,10 @@ fun SignInUI( }, navigationIcon = { if (onNavigateBack != null) { - IconButton(onClick = onNavigateBack) { + IconButton( + onClick = onNavigateBack, + modifier = Modifier.testTag(FirebaseAuthTestTags.SignIn.BACK_BUTTON) + ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringProvider.backAction @@ -175,6 +181,7 @@ fun SignInUI( .verticalScroll(rememberScrollState()), ) { AuthTextField( + modifier = Modifier.testTag(FirebaseAuthTestTags.SignIn.EMAIL_FIELD), value = email, validator = emailValidator, enabled = !isLoading, @@ -187,6 +194,7 @@ fun SignInUI( ) Spacer(modifier = Modifier.height(16.dp)) AuthTextField( + modifier = Modifier.testTag(FirebaseAuthTestTags.SignIn.PASSWORD_FIELD), value = password, validator = passwordValidator, enabled = !isLoading, @@ -196,12 +204,16 @@ fun SignInUI( }, onValueChange = { text -> onPasswordChange(text) - } + }, + visibilityToggleModifier = Modifier.testTag( + FirebaseAuthTestTags.SignIn.PASSWORD_VISIBILITY_TOGGLE + ) ) Spacer(modifier = Modifier.height(8.dp)) TextButton( modifier = Modifier - .align(Alignment.Start), + .align(Alignment.Start) + .testTag(FirebaseAuthTestTags.SignIn.FORGOT_PASSWORD_BUTTON), onClick = { onGoToResetPassword() }, @@ -209,7 +221,6 @@ fun SignInUI( contentPadding = PaddingValues.Zero ) { Text( - modifier = modifier, text = stringProvider.troubleSigningIn, style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, @@ -223,6 +234,8 @@ fun SignInUI( ) { if (provider.isNewAccountsAllowed) { Button( + modifier = Modifier + .testTag(FirebaseAuthTestTags.SignIn.SIGN_UP_BUTTON), onClick = { onGoToSignUp() }, @@ -233,6 +246,8 @@ fun SignInUI( Spacer(modifier = Modifier.width(16.dp)) } Button( + modifier = Modifier + .testTag(FirebaseAuthTestTags.SignIn.SIGN_IN_BUTTON), onClick = { onSignInClick() }, @@ -269,7 +284,9 @@ fun SignInUI( onClick = { onGoToEmailLinkSignIn() }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .testTag(FirebaseAuthTestTags.SignIn.EMAIL_LINK_BUTTON), enabled = !isLoading ) { Text(stringProvider.signInWithEmailLink.uppercase()) diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt index 7b6ba03c5..7cc7baa3f 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt @@ -39,6 +39,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.firebase.ui.auth.configuration.AuthUIConfiguration @@ -49,8 +50,10 @@ import com.firebase.ui.auth.configuration.theme.AuthUITheme import com.firebase.ui.auth.configuration.validators.EmailValidator import com.firebase.ui.auth.configuration.validators.GeneralFieldValidator import com.firebase.ui.auth.configuration.validators.PasswordValidator +import com.firebase.ui.auth.ui.FirebaseAuthTestTags import com.firebase.ui.auth.ui.components.AuthTextField import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -103,7 +106,7 @@ fun SignUpUI( } Scaffold( - modifier = modifier, + modifier = modifier.exposeTestTagsAsResourceIds(), topBar = { TopAppBar( title = { @@ -111,7 +114,10 @@ fun SignUpUI( }, navigationIcon = { if (onNavigateBack != null) { - IconButton(onClick = onNavigateBack) { + IconButton( + onClick = onNavigateBack, + modifier = Modifier.testTag(FirebaseAuthTestTags.SignUp.BACK_BUTTON) + ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringProvider.backAction @@ -131,6 +137,7 @@ fun SignUpUI( ) { if (provider.isDisplayNameRequired) { AuthTextField( + modifier = Modifier.testTag(FirebaseAuthTestTags.SignUp.NAME_FIELD), value = displayName, validator = displayNameValidator, enabled = !isLoading, @@ -144,6 +151,7 @@ fun SignUpUI( Spacer(modifier = Modifier.height(16.dp)) } AuthTextField( + modifier = Modifier.testTag(FirebaseAuthTestTags.SignUp.EMAIL_FIELD), value = email, validator = emailValidator, enabled = !isLoading, @@ -156,6 +164,7 @@ fun SignUpUI( ) Spacer(modifier = Modifier.height(16.dp)) AuthTextField( + modifier = Modifier.testTag(FirebaseAuthTestTags.SignUp.PASSWORD_FIELD), value = password, validator = passwordValidator, enabled = !isLoading, @@ -165,10 +174,14 @@ fun SignUpUI( }, onValueChange = { text -> onPasswordChange(text) - } + }, + visibilityToggleModifier = Modifier.testTag( + FirebaseAuthTestTags.SignUp.PASSWORD_VISIBILITY_TOGGLE + ) ) Spacer(modifier = Modifier.height(16.dp)) AuthTextField( + modifier = Modifier.testTag(FirebaseAuthTestTags.SignUp.CONFIRM_PASSWORD_FIELD), value = confirmPassword, validator = confirmPasswordValidator, enabled = !isLoading, @@ -178,7 +191,10 @@ fun SignUpUI( }, onValueChange = { text -> onConfirmPasswordChange(text) - } + }, + visibilityToggleModifier = Modifier.testTag( + FirebaseAuthTestTags.SignUp.CONFIRM_PASSWORD_VISIBILITY_TOGGLE + ) ) Spacer(modifier = Modifier.height(8.dp)) Row( @@ -186,6 +202,8 @@ fun SignUpUI( .align(Alignment.End), ) { Button( + modifier = Modifier + .testTag(FirebaseAuthTestTags.SignUp.SIGN_IN_BUTTON), onClick = { onGoToSignIn() }, @@ -195,6 +213,8 @@ fun SignUpUI( } Spacer(modifier = Modifier.width(16.dp)) Button( + modifier = Modifier + .testTag(FirebaseAuthTestTags.SignUp.SIGN_UP_BUTTON), onClick = { onSignUpClick() }, diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterPhoneNumberUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterPhoneNumberUI.kt index 2b9ffc13d..b6c859a3c 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterPhoneNumberUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterPhoneNumberUI.kt @@ -39,6 +39,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -49,9 +50,11 @@ import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvi import com.firebase.ui.auth.configuration.theme.AuthUITheme import com.firebase.ui.auth.configuration.validators.PhoneNumberValidator import com.firebase.ui.auth.data.CountryData +import com.firebase.ui.auth.ui.FirebaseAuthTestTags import com.firebase.ui.auth.ui.components.AuthTextField import com.firebase.ui.auth.ui.components.CountrySelector import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds import com.firebase.ui.auth.util.CountryUtils @OptIn(ExperimentalMaterial3Api::class) @@ -82,7 +85,7 @@ fun EnterPhoneNumberUI( } Scaffold( - modifier = modifier, + modifier = modifier.exposeTestTagsAsResourceIds(), topBar = { TopAppBar( title = { @@ -90,7 +93,10 @@ fun EnterPhoneNumberUI( }, navigationIcon = { if (onNavigateBack != null) { - IconButton(onClick = onNavigateBack) { + IconButton( + onClick = onNavigateBack, + modifier = Modifier.testTag(FirebaseAuthTestTags.PhoneNumber.BACK_BUTTON) + ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringProvider.backAction @@ -111,6 +117,7 @@ fun EnterPhoneNumberUI( Text(stringProvider.enterPhoneNumberTitle) Spacer(modifier = Modifier.height(16.dp)) AuthTextField( + modifier = Modifier.testTag(FirebaseAuthTestTags.PhoneNumber.PHONE_NUMBER_FIELD), value = phoneNumber, validator = phoneNumberValidator, enabled = !isLoading, @@ -122,6 +129,8 @@ fun EnterPhoneNumberUI( ), leadingIcon = { CountrySelector( + modifier = Modifier + .testTag(FirebaseAuthTestTags.PhoneNumber.COUNTRY_SELECTOR_BUTTON), selectedCountry = selectedCountry, onCountrySelected = onCountrySelected, enabled = !isLoading, @@ -139,6 +148,8 @@ fun EnterPhoneNumberUI( .align(Alignment.End), ) { Button( + modifier = Modifier + .testTag(FirebaseAuthTestTags.PhoneNumber.SEND_CODE_BUTTON), onClick = onSendCodeClick, enabled = !isLoading && isFormValid.value, ) { diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterVerificationCodeUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterVerificationCodeUI.kt index be90bbf0b..a5b4b1bd0 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterVerificationCodeUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterVerificationCodeUI.kt @@ -41,6 +41,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview @@ -51,8 +52,10 @@ import com.firebase.ui.auth.configuration.auth_provider.AuthProvider import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider import com.firebase.ui.auth.configuration.theme.AuthUITheme import com.firebase.ui.auth.configuration.validators.VerificationCodeValidator +import com.firebase.ui.auth.ui.FirebaseAuthTestTags import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm import com.firebase.ui.auth.ui.components.VerificationCodeInputField +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @@ -86,7 +89,7 @@ fun EnterVerificationCodeUI( val resendEnabled = resendTimer == 0 && !isLoading Scaffold( - modifier = modifier, + modifier = modifier.exposeTestTagsAsResourceIds(), topBar = { TopAppBar( title = { @@ -94,7 +97,12 @@ fun EnterVerificationCodeUI( }, navigationIcon = { if (onNavigateBack != null) { - IconButton(onClick = onNavigateBack) { + IconButton( + onClick = onNavigateBack, + modifier = Modifier.testTag( + FirebaseAuthTestTags.VerificationCode.BACK_BUTTON + ) + ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringProvider.backAction @@ -119,7 +127,9 @@ fun EnterVerificationCodeUI( Spacer(modifier = Modifier.height(8.dp)) TextButton( - modifier = Modifier.align(Alignment.Start), + modifier = Modifier + .align(Alignment.Start) + .testTag(FirebaseAuthTestTags.VerificationCode.CHANGE_PHONE_NUMBER_BUTTON), onClick = onChangeNumberClick, enabled = !isLoading, contentPadding = PaddingValues.Zero @@ -134,14 +144,18 @@ fun EnterVerificationCodeUI( Spacer(modifier = Modifier.height(16.dp)) VerificationCodeInputField( - modifier = Modifier.align(Alignment.CenterHorizontally), + modifier = Modifier + .align(Alignment.CenterHorizontally) + .testTag(FirebaseAuthTestTags.VerificationCode.CODE_FIELD), validator = verificationCodeValidator, onCodeChange = onVerificationCodeChange ) Spacer(modifier = Modifier.height(8.dp)) TextButton( - modifier = Modifier.align(Alignment.Start), + modifier = Modifier + .align(Alignment.Start) + .testTag(FirebaseAuthTestTags.VerificationCode.RESEND_CODE_BUTTON), onClick = onResendCodeClick, enabled = resendEnabled, contentPadding = PaddingValues.Zero @@ -168,6 +182,8 @@ fun EnterVerificationCodeUI( .align(Alignment.End), ) { Button( + modifier = Modifier + .testTag(FirebaseAuthTestTags.VerificationCode.VERIFY_BUTTON), onClick = onVerifyCodeClick, enabled = !isLoading && isFormValid.value, ) { diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreen.kt index 2406da779..826e7685f 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreen.kt @@ -17,6 +17,7 @@ package com.firebase.ui.auth.ui.screens.phone import android.content.Context import android.util.Log import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -111,9 +112,8 @@ class PhoneAuthContentState( ) /** - * A stateful composable that manages the complete logic for phone number authentication. It handles - * the multi-step flow of sending and verifying an SMS code, exposing the state for each step to a - * custom UI via a trailing lambda (slot). This component renders no UI itself. + * A stateful composable that manages the complete logic for phone number authentication, exposing + * state for each step to a custom UI slot. Contributes no UI beyond its hosting layout node. * * @param context The Android context. * @param configuration The authentication UI configuration containing the phone provider settings. @@ -121,9 +121,10 @@ class PhoneAuthContentState( * @param onSuccess Callback invoked when authentication succeeds with the [AuthResult]. * @param onError Callback invoked when an authentication error occurs. * @param onCancel Callback invoked when the user cancels the authentication flow. - * @param modifier Optional [Modifier] for the composable. + * @param modifier Applied once to the [Box] hosting the rendered content; it propagates minimum + * constraints so it doesn't change how content is measured. * @param content A composable lambda that receives [PhoneAuthContentState] to render the UI for - * each step. If null, no UI will be rendered. + * each step. If null, the default UI for the current step is rendered. */ @Composable fun PhoneAuthScreen( @@ -409,14 +410,18 @@ fun PhoneAuthScreen( } ) - if (content != null) { - content(state) - } else { - DefaultPhoneAuthContent( - configuration = configuration, - state = state, - onCancel = onCancel - ) + // propagateMinConstraints keeps this box layout-neutral: content is measured with the same + // constraints it would receive without the box. + Box(modifier = modifier, propagateMinConstraints = true) { + if (content != null) { + content(state) + } else { + DefaultPhoneAuthContent( + configuration = configuration, + state = state, + onCancel = onCancel + ) + } } } diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 6217412de..67266afb5 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -75,6 +75,7 @@ I\'ve saved these codes Secret key Verification code + Verification code digit %1$d of %2$d Identity verified. Please try your action again. diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/FirebaseAuthTestTagsTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/FirebaseAuthTestTagsTest.kt new file mode 100644 index 000000000..7de5ef061 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/FirebaseAuthTestTagsTest.kt @@ -0,0 +1,186 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui + +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import java.lang.reflect.Modifier +import org.junit.Test + +/** + * Enforces [FirebaseAuthTestTags] invariants by reflecting over the whole registry rather than a + * hand-maintained list. + * + * @suppress Internal test class + */ +class FirebaseAuthTestTagsTest { + + /** + * Valid Android resource names: a `fui_` prefix followed by lowercase `snake_case` segments. + */ + private val resourceNamePattern = Regex("^fui_[a-z0-9]+(_[a-z0-9]+)*$") + + @Test + fun `reflective traversal reaches every nested tag group`() { + val tags = registeredTags() + + assertWithMessage( + "Reflective traversal of FirebaseAuthTestTags found no tags. The traversal must walk " + + "the nested grouping objects, not just the registry root — otherwise the " + + "resource-name and uniqueness assertions in this class pass vacuously." + ).that(tags).isNotEmpty() + + assertThat(tags.keys).containsAtLeast( + "FirebaseAuthTestTags.MethodPicker.PROVIDER_LIST", + "FirebaseAuthTestTags.MethodPicker.CONTINUE_AS_BUTTON", + "FirebaseAuthTestTags.CountrySelector.COUNTRY_LIST" + ) + } + + @Test + fun `every tag value is a valid resource name`() { + registeredTags().forEach { (path, value) -> + assertWithMessage( + "Test tag value \"$value\" declared at $path is not a valid Android resource " + + "name. Tag values are surfaced as resource ids, so each must be lowercase " + + "snake_case with a fui_ prefix and match " + + "${resourceNamePattern.pattern} — no spaces, capitals, or leading, " + + "trailing, or doubled underscores." + ).that(value).matches(resourceNamePattern.pattern) + } + } + + @Test + fun `every tag value is distinct across the registry`() { + val duplicates = registeredTags().entries + .groupBy({ it.value }) { it.key } + .filterValues { paths -> paths.size > 1 } + + assertWithMessage( + "Tag values must be distinct across the whole registry. Two constants resolving to " + + "the same value make onNodeWithTag and By.res() match more than one node, so " + + "neither a Compose assertion nor a Robo directive can address either node. " + + "Duplicated values: $duplicates" + ).that(duplicates).isEmpty() + } + + @Test + fun `every tag lives in a grouping object and repeats its surface`() { + registeredTags().forEach { (path, value) -> + val segments = path.split('.') + + // Exactly one grouping level: the surface prefix below reads segments[1]. + assertWithMessage( + if (segments.size < EXPECTED_PATH_SEGMENTS) { + "Tag $path is declared directly on the registry root. Every tag belongs to a " + + "grouping object named after the screen or surface that owns it, so that " + + "sibling tags stay unambiguous as the registry grows." + } else { + "Tag $path is nested more than one grouping object deep. A group names a " + + "single screen or surface and does not nest further, because the tag " + + "value repeats exactly one group as its prefix. Flatten it to " + + "FirebaseAuthTestTags..." + } + ).that(segments).hasSize(EXPECTED_PATH_SEGMENTS) + + val surface = segments[1].toSnakeCase() + assertWithMessage( + "Test tag value \"$value\" declared at $path does not carry its surface. Values " + + "repeat the grouping object as a segment — expected the fui_${surface}_ " + + "prefix — so that a By.res prefix match selects exactly one surface." + ).that(value).startsWith("fui_${surface}_") + } + } + + @Test + fun `surface names keep acronym runs in one segment`() { + assertThat("MethodPicker".toSnakeCase()).isEqualTo("method_picker") + assertThat("CountrySelector".toSnakeCase()).isEqualTo("country_selector") + // Splitting inside an acronym would force a group named after one of the OAuth providers + // to name its values fui_o_auth_provider_* to satisfy the surface-prefix assertion. + assertThat("OAuthProvider".toSnakeCase()).isEqualTo("oauth_provider") + } + + /** + * Maps every tag constant in the registry to its value, keyed by the constant's fully nested + * path (for example `FirebaseAuthTestTags.MethodPicker.PROVIDER_LIST`). + */ + private fun registeredTags(): Map { + val tags = linkedMapOf() + collectTags(FirebaseAuthTestTags::class.java, "FirebaseAuthTestTags", tags) + return tags + } + + private fun collectTags(group: Class<*>, path: String, into: MutableMap) { + group.declaredFields + .filter { field -> + !field.isSynthetic && + Modifier.isStatic(field.modifiers) && + field.type == String::class.java + } + .forEach { field -> + field.isAccessible = true + into["$path.${field.name}"] = field.get(null) as String + } + + // A computed tag (`val TAG: String get() = …`) has no backing field; read it via getter. + val singleton = group.declaredFields + .firstOrNull { Modifier.isStatic(it.modifiers) && it.type == group } + ?.also { it.isAccessible = true } + ?.get(null) + + group.declaredMethods + .filter { method -> + !method.isSynthetic && + method.parameterCount == 0 && + method.returnType == String::class.java && + method.name.startsWith(GETTER_PREFIX) && + method.name.length > GETTER_PREFIX.length + } + .forEach { method -> + val name = method.name.removePrefix(GETTER_PREFIX) + if (into.containsKey("$path.$name")) return@forEach + + val receiver = if (Modifier.isStatic(method.modifiers)) { + null + } else { + checkNotNull(singleton) { + "Tag group ${group.name} exposes $name through a getter but has no " + + "singleton instance to read it from; tag groups must be objects." + } + } + method.isAccessible = true + into["$path.$name"] = method.invoke(receiver) as String + } + + group.declaredClasses.forEach { nested -> + collectTags(nested, "$path.${nested.simpleName}", into) + } + } + + /** + * Converts a grouping object name to its `snake_case` surface segment, keeping acronym runs + * (e.g. `OAuthProvider` -> `oauth_provider`) in one segment. + */ + private fun String.toSnakeCase(): String = + replace(Regex("(?<=[a-z0-9])(?=\\p{Upper})"), "_").lowercase() + + private companion object { + /** `FirebaseAuthTestTags` + one grouping object + the constant itself. */ + const val EXPECTED_PATH_SEGMENTS = 3 + + const val GETTER_PREFIX = "get" + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/MainSourceTestTagUsageTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/MainSourceTestTagUsageTest.kt new file mode 100644 index 000000000..073f5e249 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/MainSourceTestTagUsageTest.kt @@ -0,0 +1,602 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui + +import com.google.common.truth.Truth.assertWithMessage +import java.io.File +import org.junit.Test + +/** + * Lexically scans `auth/src/main` Kotlin sources for `testTag` calls that bypass + * [FirebaseAuthTestTags], since [FirebaseAuthTestTagsTest] only polices tags already in the registry. + * + * @suppress Internal test class + */ +class MainSourceTestTagUsageTest { + + @Test + fun `every main source test tag is a FirebaseAuthTestTags reference`() { + val violations = tagCallSites().filterNot { site -> + site.argument.startsWith(REGISTRY_REFERENCE_PREFIX) + } + + assertWithMessage(violationMessage(violations)).that(violations).isEmpty() + } + + /** + * An aliased `testTag` import puts every call through it beyond this lexical scan, so it is + * rejected outright rather than letting "cannot analyse this" resolve to "passes". + */ + @Test + fun `no main source file aliases the testTag import`() { + val root = mainSourceRoot() + val aliases = kotlinSources(root).flatMap { file -> + val relativePath = file.toRelativeString(root).replace(File.separatorChar, '/') + ALIASED_IMPORT_PATTERN.findAll(file.readText()).map { match -> + " $relativePath -> ${match.value.trim()}" + } + } + + assertWithMessage( + "Auth main sources must import `testTag` under its own name so that this class can see " + + "the calls that use it. The imports below rename it, which hides every call " + + "through the alias from the registry check in this class:\n" + + aliases.joinToString("\n") + + "\n\nFix: drop the `as` clause and call `testTag` directly, passing a " + + "$REGISTRY_REFERENCE_PREFIX constant." + ).that(aliases).isEmpty() + } + + /** + * Checks the flag over owners rather than tags: an owner with no tag flagged for free costs + * nothing, but a tag added inside an unflagged owner is a silent regression no Compose test sees. + */ + @Test + fun `every semantics owner the library creates flags itself`() { + val unflagged = semanticsOwnerSites().filterNot { site -> site.flagged } + + assertWithMessage( + "The semantics owners below are created by the library but do not call " + + "$FLAG_FUNCTION_NAME() in their own argument list, so a test tag applied inside " + + "one of them is not guaranteed to become an Android resource id. " + + "TestTagsAsResourceIds.kt states the rule as owners rather than " + + "tags on purpose: flagging an owner that holds no tag is free, because the property " + + "is not relevant to accessibility, while adding a tag inside an owner that was " + + "skipped is a silent regression that leaves every Compose assertion in this suite " + + "green and only Firebase Test Lab Robo or UiAutomator By.res() — neither of which " + + "runs in CI — able to see it. That is issue #2050:\n" + + unflagged.joinToString("\n") { " ${it.path}:${it.line} -> ${it.shape}" } + + "\n\nFix: pass Modifier.$FLAG_FUNCTION_NAME() to the owner, combining it with any " + + "modifier already there. An owner invoked with a trailing lambda and no argument " + + "list has nowhere to put it, so give it one. If the owner genuinely must not be " + + "flagged, it does not belong in SEMANTICS_OWNER_SHAPES — change the list and say " + + "why, rather than leaving a named exception here." + ).that(unflagged).isEmpty() + } + + /** + * Coarser backstop for the owner check above: a file that applies a tag must flag some owner + * somewhere in it, even for owner shapes the stricter check doesn't recognise by name. + */ + @Test + fun `every main source file that applies a test tag also flags a semantics owner`() { + val root = mainSourceRoot() + val unflagged = kotlinSources(root) + .filter { file -> tagCallSitesIn(file, root).isNotEmpty() } + // Masked, so a KDoc paragraph or a commented-out line that merely names the function + // does not count as applying it. + .filterNot { file -> + FLAG_APPLICATION_PATTERN.containsMatchIn(maskCommentsAndLiterals(file.readText())) + } + .map { file -> file.toRelativeString(root).replace(File.separatorChar, '/') } + + assertWithMessage( + "The files below apply a Compose test tag but never call " + + "$FLAG_FUNCTION_NAME(), so nothing in them exposes a tag as an Android resource " + + "id. Compose reads that flag by walking a node's semantics ancestors and the walk " + + "stops at the root of the node's own window, so a tag with no flagged owner above " + + "it is visible to Compose tests and invisible to Firebase Test Lab Robo and " + + "UiAutomator By.res() — the exact failure of issue #2050, and one that leaves " + + "every assertion in the suite green:\n" + + unflagged.joinToString("\n") { " $it" } + + "\n\nFix: call Modifier.$FLAG_FUNCTION_NAME() on the semantics owner that " + + "encloses the tagged node — the screen's root, or the dialog, bottom sheet, or " + + "popup it lives in, each of which is its own semantics owner and inherits nothing " + + "from the composable that opened it. If the owner is genuinely in another file " + + "because this file only contributes content to a flagged screen, applying it here " + + "as well is safe and satisfies this check: the property is not relevant to " + + "accessibility, so setting it twice is a no-op." + ).that(unflagged).isEmpty() + } + + /** + * Guards against the failure mode that would make this class worthless: resolving nothing and + * reporting success. Every scan step is pinned, so a moved module fails loudly instead. + */ + @Test + fun `the source scan cannot pass having scanned nothing`() { + val mainSources = mainSourceRoot() + val sources = kotlinSources(mainSources) + + assertWithMessage( + "Resolved the auth main source root to $mainSources but found no Kotlin files under " + + "it. Compose test tags are applied from Kotlin, so an empty scan means this " + + "class is no longer checking anything." + ).that(sources).isNotEmpty() + + val callSites = tagCallSites() + + assertWithMessage( + "Scanned ${sources.size} Kotlin files under $mainSources and found no testTag call " + + "sites at all. The auth screens do tag nodes, so zero matches means the scan is " + + "looking in the wrong place or no longer recognises the call shape — either way " + + "the registry-reference assertion in this class would pass vacuously." + ).that(callSites).isNotEmpty() + + assertWithMessage( + "Expected at least $MINIMUM_TAG_CALL_SITES testTag call sites in auth main sources " + + "but found ${callSites.size}:\n" + + callSites.joinToString("\n") { " ${it.path}:${it.line} -> ${it.argument}" } + + "\n\nThis is a floor, not a pin: adding tags is expected and needs no change here, " + + "but the count dropping below the tags that already exist means the scan has " + + "stopped reaching files it used to read, and the registry-reference assertion has " + + "quietly stopped covering them. If a tagged node was genuinely removed, lower " + + "MINIMUM_TAG_CALL_SITES to match." + ).that(callSites.size).isAtLeast(MINIMUM_TAG_CALL_SITES) + + val ownerSites = semanticsOwnerSites() + + assertWithMessage( + "Scanned ${sources.size} Kotlin files under $mainSources and recognised no semantics " + + "owner construction at all. The auth screens are built out of Scaffolds, dialogs " + + "and bottom sheets, so zero matches means the owner check in this class would pass " + + "vacuously." + ).that(ownerSites).isNotEmpty() + + assertWithMessage( + "Expected at least $MINIMUM_SEMANTICS_OWNER_SITES semantics owner constructions in " + + "auth main sources but found ${ownerSites.size}:\n" + + ownerSites.joinToString("\n") { " ${it.path}:${it.line} -> ${it.shape}" } + + "\n\nA floor, not a pin, for the same reason as MINIMUM_TAG_CALL_SITES: new " + + "screens are expected. Dropping below the owners that already exist means the scan " + + "has stopped recognising a call shape it used to, and the owner assertion has " + + "quietly stopped covering it. If an owner was genuinely removed, lower " + + "MINIMUM_SEMANTICS_OWNER_SITES to match." + ).that(ownerSites.size).isAtLeast(MINIMUM_SEMANTICS_OWNER_SITES) + } + + /** + * The message a contributor reads immediately after this test broke their build. It is the + * whole value of the test, so it names every offending site and says what to do instead. + */ + private fun violationMessage(violations: List): String = buildString { + append( + "Compose test tags applied in auth main sources must be $REGISTRY_REFERENCE_PREFIX " + + "references. The call sites below pass something else, which bypasses every " + + "invariant FirebaseAuthTestTagsTest enforces — valid resource-name shape, " + + "uniqueness across the registry, and the surface prefix — so the tag can ship " + + "unaddressable by Firebase Test Lab Robo directives and UiAutomator By.res().\n\n" + ) + violations.forEach { site -> + append(" ${site.path}:${site.line} -> testTag argument: ${site.argument}\n") + } + append( + "\nFix: add a constant to the ${FirebaseAuthTestTags::class.simpleName} group that " + + "owns this screen or surface — adding a new group if none fits — and pass that " + + "constant here.\n\n" + + "Non-literal arguments are rejected as well, not only string literals: an " + + "argument assembled from a variable, a parameter, a string template, or a helper " + + "call is a value this scan cannot check and the registry does not publish, so it " + + "is not an escape hatch. Only an expression beginning with " + + "\"$REGISTRY_REFERENCE_PREFIX\" is accepted. Note that a host application tagging " + + "one of our nodes with its own value does so by passing a modifier into the " + + "composable from its own sources; that is legitimate and is not affected by this " + + "check, which reads auth main sources only. Test sources are likewise not " + + "scanned, so fixture-local tags in the `:auth` unit tests stay free-form." + ) + } + + /** Every `testTag` application found in the auth main sources, in file order. */ + private fun tagCallSites(): List { + val mainSources = mainSourceRoot() + return kotlinSources(mainSources).flatMap { file -> tagCallSitesIn(file, mainSources) } + } + + /** Every semantics owner construction found in the auth main sources, in file order. */ + private fun semanticsOwnerSites(): List { + val mainSources = mainSourceRoot() + return kotlinSources(mainSources).flatMap { file -> semanticsOwnerSitesIn(file, mainSources) } + } + + /** + * Locates `auth/src/main` without trusting the working directory: tries module- and + * repository-relative paths up the ancestor chain, sentinelled on [FirebaseAuthTestTags]. + */ + private fun mainSourceRoot(): File { + val workingDirectory = File(System.getProperty("user.dir") ?: ".").absoluteFile + + val candidates = mutableListOf() + var directory: File? = workingDirectory + var depth = 0 + while (directory != null && depth <= MAX_ANCESTOR_WALK) { + candidates += File(directory, MODULE_RELATIVE_MAIN_SOURCES) + candidates += File(directory, REPOSITORY_RELATIVE_MAIN_SOURCES) + directory = directory.parentFile + depth++ + } + + val resolved = candidates.firstOrNull { candidate -> + REGISTRY_RELATIVE_PATHS.any { relativePath -> File(candidate, relativePath).isFile } + } + + assertWithMessage( + "Could not locate the auth main source tree, so this class cannot check anything and " + + "fails rather than passing on an empty scan. Looked for " + + "${REGISTRY_RELATIVE_PATHS.joinToString(" or ")} under " + + "\"$MODULE_RELATIVE_MAIN_SOURCES\" and " + + "\"$REPOSITORY_RELATIVE_MAIN_SOURCES\", relative to the working directory " + + "$workingDirectory and up to $MAX_ANCESTOR_WALK of its ancestors. If the :auth " + + "module or its source set moved, update the constants in this class." + ).that(resolved).isNotNull() + + return resolved!! + } + + private fun kotlinSources(root: File): List = + root.walkTopDown() + .filter { it.isFile && it.extension == KOTLIN_EXTENSION } + .sortedBy { it.invariantSeparatorsPath } + .toList() + + /** + * Finds `testTag(...)` calls and `testTag = ...` assignments in [file], matching over + * [maskCommentsAndLiterals] output so comments and string literals are never treated as source. + */ + private fun tagCallSitesIn(file: File, root: File): List { + val relativePath = file.toRelativeString(root).replace(File.separatorChar, '/') + val source = file.readText() + val masked = maskCommentsAndLiterals(source) + + return TAG_APPLICATION_PATTERN.findAll(masked) + .filter { match -> isTagApplication(masked, match) } + .map { match -> + val delimiterIndex = match.range.last + val argument = if (masked[delimiterIndex] == '(') { + balancedArgument(masked, source, delimiterIndex) + } else { + assignedExpression(masked, source, delimiterIndex) + } + + TagCallSite( + path = relativePath, + line = masked.take(match.range.first).count { it == '\n' } + 1, + argument = argument.replace(WHITESPACE_RUN, " ").trim() + ) + }.toList() + } + + /** + * Finds semantics owner constructions in [file] and whether each flags itself, requiring the + * flag inside the owner's own (masked, balanced) argument list rather than merely nearby. + */ + private fun semanticsOwnerSitesIn(file: File, root: File): List { + val relativePath = file.toRelativeString(root).replace(File.separatorChar, '/') + val source = file.readText() + val masked = maskCommentsAndLiterals(source) + + return OWNER_CONSTRUCTION_PATTERN.findAll(masked) + .filterNot { match -> precedingWord(masked, match.range.first) in CALL_DISQUALIFYING_KEYWORDS } + .filterNot { match -> isInsidePreview(masked, match.range.first) } + .map { match -> + val delimiterIndex = match.range.last + val arguments = if (masked[delimiterIndex] == '(') { + // Masked on both sides: a flag name quoted in a string is not an application. + balancedArgument(masked, masked, delimiterIndex) + } else { + "" + } + + OwnerSite( + path = relativePath, + line = masked.take(match.range.first).count { it == '\n' } + 1, + shape = match.groupValues[1], + flagged = FLAG_APPLICATION_PATTERN.containsMatchIn(arguments) + ) + }.toList() + } + + /** + * Whether the code at [index] sits in a `@Preview` composable, resolved by the nearest + * preceding function declaration's annotation block. Previews are never composed, so skip them. + */ + private fun isInsidePreview(masked: String, index: Int): Boolean { + val declaration = FUNCTION_DECLARATION_PATTERN + .findAll(masked) + .lastOrNull { match -> match.range.first < index } + ?: return false + + for (line in masked.take(declaration.range.first).lines().asReversed()) { + val trimmed = line.trim() + when { + trimmed.isEmpty() -> continue + trimmed.startsWith(PREVIEW_ANNOTATION_PREFIX) -> return true + trimmed.startsWith("@") -> continue + else -> return false + } + } + return false + } + + /** + * Rejects tokens that merely spell `testTag` without applying one — a declaration, a `val` + * binding, or an equality comparison were live false positives here. + */ + private fun isTagApplication(masked: String, match: MatchResult): Boolean { + val identifierStart = match.range.first + val delimiterIndex = match.range.last + + return if (masked[delimiterIndex] == '(') { + precedingSymbol(masked, identifierStart) == '.' || + precedingWord(masked, identifierStart) !in CALL_DISQUALIFYING_KEYWORDS + } else { + val next = masked.getOrNull(delimiterIndex + 1) + next != '=' && precedingWord(masked, identifierStart) !in BINDING_KEYWORDS + } + } + + /** The first non-whitespace character before [index], or `null` at the start of the file. */ + private fun precedingSymbol(masked: String, index: Int): Char? { + var cursor = index - 1 + while (cursor >= 0 && masked[cursor].isWhitespace()) cursor-- + return masked.getOrNull(cursor) + } + + /** The identifier immediately before [index], or the empty string if a symbol sits there. */ + private fun precedingWord(masked: String, index: Int): String { + var end = index - 1 + while (end >= 0 && masked[end].isWhitespace()) end-- + var start = end + while (start >= 0 && (masked[start].isLetterOrDigit() || masked[start] == '_')) start-- + return masked.substring(start + 1, end + 1) + } + + /** + * Reads the [original] text between the paren at [openIndex] in [masked] and its match, over + * the masked text so a paren already blanked inside a string literal cannot unbalance it. + */ + private fun balancedArgument(masked: String, original: String, openIndex: Int): String { + var depth = 0 + var index = openIndex + while (index < masked.length) { + when (masked[index]) { + '(' -> depth++ + ')' -> { + depth-- + if (depth == 0) return original.substring(openIndex + 1, index) + } + } + index++ + } + // Unbalanced source would not compile; report what is there so the site is still named. + return original.substring(openIndex + 1) + } + + /** + * Reads the right-hand side of a `testTag =` assignment at [equalsIndex]. The expression may + * wrap onto later lines, so taking only the `=` line's remainder yielded empty arguments. + */ + private fun assignedExpression(masked: String, original: String, equalsIndex: Int): String { + var index = equalsIndex + 1 + while (index < masked.length && masked[index].isWhitespace()) index++ + + val start = index + var depth = 0 + while (index < masked.length) { + val character = masked[index] + when { + character in OPENING_BRACKETS -> depth++ + character in CLOSING_BRACKETS -> { + if (depth == 0) break + depth-- + } + character == '\n' && depth == 0 -> break + } + index++ + } + return original.substring(start, index) + } + + /** + * Blanks comment and string/char literal contents to the same length as [source], keeping + * newlines and delimiters so offsets and line numbers still line up. + */ + private fun maskCommentsAndLiterals(source: String): String { + val masked = StringBuilder(source.length) + var index = 0 + + while (index < source.length) { + when { + source.startsWith(BLOCK_COMMENT_OPEN, index) -> { + var depth = 0 + while (index < source.length) { + when { + source.startsWith(BLOCK_COMMENT_OPEN, index) -> { + depth++ + masked.append(" ") + index += 2 + } + source.startsWith(BLOCK_COMMENT_CLOSE, index) -> { + depth-- + masked.append(" ") + index += 2 + if (depth == 0) break + } + else -> masked.append(blanked(source[index++])) + } + } + } + + source.startsWith(LINE_COMMENT, index) -> + while (index < source.length && source[index] != '\n') { + masked.append(blanked(source[index++])) + } + + source.startsWith(RAW_STRING_QUOTE, index) -> { + masked.append(RAW_STRING_QUOTE) + index += RAW_STRING_QUOTE.length + while (index < source.length && !source.startsWith(RAW_STRING_QUOTE, index)) { + masked.append(blanked(source[index++])) + } + if (index < source.length) { + masked.append(RAW_STRING_QUOTE) + index += RAW_STRING_QUOTE.length + } + } + + source[index] == '"' || source[index] == '\'' -> { + val quote = source[index] + masked.append(quote) + index++ + while (index < source.length && source[index] != quote && source[index] != '\n') { + if (source[index] == '\\') masked.append(blanked(source[index++])) + if (index < source.length) masked.append(blanked(source[index++])) + } + if (index < source.length && source[index] == quote) { + masked.append(quote) + index++ + } + } + + else -> masked.append(source[index++]) + } + } + + return masked.toString() + } + + private fun blanked(character: Char): Char = if (character == '\n') '\n' else ' ' + + /** One `testTag` application in a main source file. */ + private data class TagCallSite(val path: String, val line: Int, val argument: String) + + /** One semantics owner construction in a main source file, and whether it flags itself. */ + private data class OwnerSite( + val path: String, + val line: Int, + val shape: String, + val flagged: Boolean, + ) + + private companion object { + /** + * Lower bound on `testTag` applications, so a scan that stops reaching files announces + * itself; a floor, not a pin, since tagging more nodes is expected. + */ + const val MINIMUM_TAG_CALL_SITES = 63 + + /** Lower bound on recognised owner constructions, for the same reason as above. */ + const val MINIMUM_SEMANTICS_OWNER_SITES = 21 + + const val REGISTRY_REFERENCE_PREFIX = "FirebaseAuthTestTags." + + /** The modifier that exposes tags beneath a semantics owner as Android resource ids. */ + const val FLAG_FUNCTION_NAME = "exposeTestTagsAsResourceIds" + + /** + * `exposeTestTagsAsResourceIds(` as a call. The declaration in `TestTagsAsResourceIds.kt` + * matches too, but that file applies no tags and so is never examined. + */ + val FLAG_APPLICATION_PATTERN = Regex("""\b$FLAG_FUNCTION_NAME\s*\(""") + + /** + * Compose shapes that create a semantics owner the library must flag — each is its own + * window or subtree root, so a sibling's flag does not reach it. + */ + val SEMANTICS_OWNER_SHAPES = listOf( + "AlertDialog", + "Dialog", + "ModalBottomSheet", + "PlainTooltip", + "Popup", + "Scaffold", + ) + + /** One of [SEMANTICS_OWNER_SHAPES] being constructed, including trailing-lambda-only calls. */ + val OWNER_CONSTRUCTION_PATTERN = + Regex("""\b(${SEMANTICS_OWNER_SHAPES.joinToString("|")})\s*[({]""") + + /** + * A function declaration at any indentation, used to find the declaration enclosing a call + * site so its annotations can be read. + */ + val FUNCTION_DECLARATION_PATTERN = Regex( + """(?m)^[ \t]*(?:(?:private|internal|public|protected|expect|actual|override|""" + + """suspend|inline|operator|infix)\s+)*fun\s""" + ) + + /** Covers `@Preview` and its multipreview variants, such as `@PreviewLightDark`. */ + const val PREVIEW_ANNOTATION_PREFIX = "@Preview" + + /** + * The registry, used to sentinel that a candidate directory is really the auth main tree. + */ + val REGISTRY_RELATIVE_PATHS = listOf( + "java/com/firebase/ui/auth/ui/FirebaseAuthTestTags.kt", + "kotlin/com/firebase/ui/auth/ui/FirebaseAuthTestTags.kt", + ) + + /** Working directory is the module directory under Gradle's default. */ + const val MODULE_RELATIVE_MAIN_SOURCES = "src/main" + + /** Working directory is the repository root, as when launched from an IDE run config. */ + const val REPOSITORY_RELATIVE_MAIN_SOURCES = "auth/src/main" + + const val MAX_ANCESTOR_WALK = 6 + + const val KOTLIN_EXTENSION = "kt" + + const val BLOCK_COMMENT_OPEN = "/*" + + const val BLOCK_COMMENT_CLOSE = "*/" + + const val LINE_COMMENT = "//" + + const val RAW_STRING_QUOTE = "\"\"\"" + + val OPENING_BRACKETS = setOf('(', '[', '{') + + val CLOSING_BRACKETS = setOf(')', ']', '}') + + /** + * `fun testTag(` declares one rather than calling it, and `fun Scaffold(` would declare a + * composable rather than construct one. + */ + val CALL_DISQUALIFYING_KEYWORDS = setOf("fun") + + /** `val testTag =` binds a name; it does not assign a semantics property. */ + val BINDING_KEYWORDS = setOf("val", "var") + + /** + * `testTag(` as a call, or `testTag =` as an assignment; [isTagApplication] filters false + * matches from either. + */ + val TAG_APPLICATION_PATTERN = Regex("""\btestTag\s*[(=]""") + + /** `import …testTag as somethingElse`, in any of the packages that declare a `testTag`. */ + val ALIASED_IMPORT_PATTERN = Regex("""(?m)^\s*import\s+[\w.]*\btestTag\s+as\s+\w+.*$""") + + val WHITESPACE_RUN = Regex("""\s+""") + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/TestTagsAsResourceIdsTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/TestTagsAsResourceIdsTest.kt new file mode 100644 index 000000000..37f0ac793 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/TestTagsAsResourceIdsTest.kt @@ -0,0 +1,978 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui + +import android.content.Context +import android.os.Bundle +import android.view.accessibility.AccessibilityNodeInfo +import android.view.accessibility.AccessibilityNodeProvider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ViewRootForTest +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.hasAnyDescendant +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasScrollAction +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.configuration.MfaFactor +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.mfa.MfaChallengeContentState +import com.firebase.ui.auth.mfa.MfaEnrollmentContentState +import com.firebase.ui.auth.mfa.MfaEnrollmentStep +import com.firebase.ui.auth.ui.components.CountrySelector +import com.firebase.ui.auth.ui.components.ErrorRecoveryDialog +import com.firebase.ui.auth.ui.components.ReauthenticationDialog +import com.firebase.ui.auth.ui.method_picker.AuthMethodPicker +import com.firebase.ui.auth.ui.screens.DefaultMfaChallengeContent +import com.firebase.ui.auth.ui.screens.DefaultMfaEnrollmentContent +import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen +import com.firebase.ui.auth.ui.screens.email.ResetPasswordUI +import com.firebase.ui.auth.ui.screens.email.SignInEmailLinkUI +import com.firebase.ui.auth.ui.screens.email.SignInUI +import com.firebase.ui.auth.ui.screens.email.SignUpUI +import com.firebase.ui.auth.ui.screens.phone.EnterPhoneNumberUI +import com.firebase.ui.auth.ui.screens.phone.EnterVerificationCodeUI +import com.firebase.ui.auth.util.CountryUtils +import com.firebase.ui.auth.util.SignInPreferenceManager +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.MultiFactorInfo +import com.google.firebase.auth.PhoneMultiFactorInfo +import com.google.firebase.auth.TotpMultiFactorInfo +import com.google.firebase.auth.actionCodeSettings +import com.google.common.truth.Truth.assertWithMessage +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * End-to-end check that each published tag reaches a real node as an Android **resource id**, not + * only a Compose test tag — `assertExists()` on a `testTag` matcher can't tell the two apart. + * + * @suppress Internal test class + */ +@Config(sdk = [34]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@RunWith(RobolectricTestRunner::class) +class TestTagsAsResourceIdsTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var applicationContext: Context + private lateinit var stringProvider: AuthUIStringProvider + + @Before + fun setUp() { + applicationContext = ApplicationProvider.getApplicationContext() + stringProvider = DefaultAuthUIStringProvider(applicationContext) + } + + // ---- The assertion this class is built around ---- + + /** + * Asserts exactly one node carries [tag], is the kind of node [expected] describes, and is + * exposed to the platform as a resource id — not just that a tag exists somewhere. + */ + private fun assertExposedAsResourceId(tag: String, expected: SemanticsMatcher) { + val interaction: SemanticsNodeInteraction = composeTestRule.onNode(hasTestTag(tag)) + + interaction.assertExists( + "No node carries the test tag \"$tag\". Either the tag was not applied on this screen, " + + "or the node it belongs to is not composed in this fixture." + ) + interaction.assert(expected) + scrollIntoWindow(tag) + + val node = interaction.fetchSemanticsNode() + val view = (node.root as ViewRootForTest).view + val provider = requireNotNull(view.accessibilityNodeProvider) { + "The Compose view hosting \"$tag\" exposes no AccessibilityNodeProvider, so this test " + + "cannot read viewIdResourceName from it." + } + val info = requireNotNull(provider.createAccessibilityNodeInfo(node.id)) { + "No accessibility node exists for the node tagged \"$tag\"." + } + + assertWithMessage( + "The node tagged \"$tag\" is not exposed as an Android resource id: its accessibility " + + "node reports viewIdResourceName=${info.viewIdResourceName}. Firebase Test Lab " + + "Robo directives and UiAutomator By.res() match on that field, so without it the " + + "tag is reachable from Compose tests only and issue #2050 is not fixed. Fix: apply " + + "Modifier.exposeTestTagsAsResourceIds() at the semantics owner enclosing this node " + + "— and note that every dialog and bottom sheet is its own semantics owner, which " + + "inherits nothing from the composable that opened it." + ).that(info.viewIdResourceName).isEqualTo(tag) + } + + /** + * Scrolls the node tagged [tag] into the window when scrollable, since a node outside the + * root's bounds has no accessibility node to read a resource id from. + */ + private fun scrollIntoWindow(tag: String) { + val hasScrollableAncestor = composeTestRule + .onAllNodes(hasScrollAction() and hasAnyDescendant(hasTestTag(tag))) + .fetchSemanticsNodes() + .isNotEmpty() + + if (hasScrollableAncestor) { + composeTestRule.onNode(hasTestTag(tag)).performScrollTo() + } + } + + /** + * Enters [text] via `ACTION_SET_TEXT` into the node published under resource id [resourceName], + * the way a crawler would — deliberately not located by its Compose test tag. + */ + private fun setTextByResourceId(resourceName: String, text: String) { + val (provider, virtualViewId) = accessibilityNodePublishing(resourceName) + + val info = requireNotNull(provider.createAccessibilityNodeInfo(virtualViewId)) { + "The accessibility node published as \"$resourceName\" disappeared between being " + + "found and being read." + } + + assertWithMessage( + "The node published as \"$resourceName\" reports className=${info.className} rather " + + "than $EDIT_TEXT_CLASS_NAME, so a crawler will not treat it as typeable however " + + "many text actions it offers. Robo decides a node accepts text from its class, and " + + "its documented directive support is EditText-only. Compose derives the class from " + + "the node's semantics: `AndroidComposeViewAccessibilityDelegateCompat` maps " + + "EditableText to EditText, but prefers Text over it — so adding a Text or Role " + + "property anywhere in this node's modifier chain, including through a modifier a " + + "caller passes in, silently turns this into a TextView while ACTION_SET_TEXT stays " + + "on offer and every other assertion in this class keeps passing. Fix: keep the " + + "group's semantics to isEditable/editableText/maxTextLength and the text actions, " + + "and put any label on a child rather than on the node itself." + ).that(info.className?.toString()).isEqualTo(EDIT_TEXT_CLASS_NAME) + + assertWithMessage( + "The node published as \"$resourceName\" offers no ACTION_SET_TEXT, so a Robo " + + "inputText directive naming it would resolve a node and type nothing. Compose " + + "offers that action only where SemanticsActions.SetText is declared, so the node " + + "needs Modifier.semantics { setText { … } } (or to be a real text field). Actions " + + "offered: ${info.actionList.map { it.id }}." + ).that(info.actionList.map { it.id }).contains(AccessibilityNodeInfo.ACTION_SET_TEXT) + + val arguments = Bundle().apply { + putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, text) + } + + assertWithMessage( + "ACTION_SET_TEXT on \"$resourceName\" with \"$text\" was refused. The node advertises " + + "the action, so its SetText handler rejected the value." + ).that( + provider.performAction( + virtualViewId, + AccessibilityNodeInfo.ACTION_SET_TEXT, + arguments + ) + ).isTrue() + + composeTestRule.waitForIdle() + } + + /** + * The accessibility node whose `viewIdResourceName` is [resourceName], found by scanning the + * whole tree the way `By.res()` does — not via the matching Compose test tag. + */ + private fun accessibilityNodePublishing( + resourceName: String + ): Pair { + val matches = composeTestRule + .onAllNodes(ANY_NODE, useUnmergedTree = true) + .fetchSemanticsNodes() + .mapNotNull { node: SemanticsNode -> + val provider = (node.root as? ViewRootForTest) + ?.view + ?.accessibilityNodeProvider + ?: return@mapNotNull null + val info = provider.createAccessibilityNodeInfo(node.id) + if (info?.viewIdResourceName == resourceName) provider to node.id else null + } + + assertWithMessage( + "Expected exactly one accessibility node to publish viewIdResourceName " + + "\"$resourceName\". Zero means the tag is not exposed as a resource id here — " + + "either the tag is not applied, its node is scrolled out of the window, or the " + + "enclosing semantics owner is missing " + + "Modifier.exposeTestTagsAsResourceIds(). More than one means By.res() cannot " + + "address either of them. Found ${matches.size}." + ).that(matches).hasSize(1) + + return matches.single() + } + + private fun field(): SemanticsMatcher = hasSetTextAction() + + private fun button(): SemanticsMatcher = hasClickAction() + + // ---- Fixtures ---- + + private fun setContent(content: @Composable () -> Unit) { + composeTestRule.setContent { + CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { + content() + } + } + } + + private fun emailConfiguration( + isDisplayNameRequired: Boolean = true, + isEmailLinkSignInEnabled: Boolean = true, + isNewAccountsAllowed: Boolean = true, + ): AuthUIConfiguration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Email( + isDisplayNameRequired = isDisplayNameRequired, + isEmailLinkSignInEnabled = isEmailLinkSignInEnabled, + isNewAccountsAllowed = isNewAccountsAllowed, + emailLinkActionCodeSettings = if (isEmailLinkSignInEnabled) { + actionCodeSettings { + url = "https://example.com" + handleCodeInApp = true + setAndroidPackageName("com.example", true, null) + } + } else { + null + }, + passwordValidationRules = emptyList() + ) + ) + } + } + + private fun phoneConfiguration(): AuthUIConfiguration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ) + ) + } + } + + // ---- In-window screen roots ---- + + @Test + fun `sign in screen exposes its credential fields and actions`() { + setContent { + SignInUI( + configuration = emailConfiguration(), + isLoading = false, + emailSignInLinkSent = false, + email = "", + password = "password123", + onEmailChange = { }, + onPasswordChange = { }, + onRetrievedCredential = { }, + onSignInClick = { }, + onGoToSignUp = { }, + onGoToResetPassword = { }, + onGoToEmailLinkSignIn = { }, + onNavigateBack = { }, + ) + } + + assertExposedAsResourceId(FirebaseAuthTestTags.SignIn.EMAIL_FIELD, field()) + assertExposedAsResourceId(FirebaseAuthTestTags.SignIn.PASSWORD_FIELD, field()) + assertExposedAsResourceId(FirebaseAuthTestTags.SignIn.SIGN_IN_BUTTON, button()) + assertExposedAsResourceId(FirebaseAuthTestTags.SignIn.SIGN_UP_BUTTON, button()) + assertExposedAsResourceId(FirebaseAuthTestTags.SignIn.FORGOT_PASSWORD_BUTTON, button()) + assertExposedAsResourceId(FirebaseAuthTestTags.SignIn.EMAIL_LINK_BUTTON, button()) + assertExposedAsResourceId(FirebaseAuthTestTags.SignIn.BACK_BUTTON, button()) + assertExposedAsResourceId(FirebaseAuthTestTags.TermsAndPrivacy.TOS_LINK, button()) + assertExposedAsResourceId(FirebaseAuthTestTags.TermsAndPrivacy.PRIVACY_LINK, button()) + } + + /** + * Pins [AuthTextField]'s `visibilityToggleModifier` reaching a real resource id on the sign-in + * screen's toggle, since the shared component needs a distinct tag per call site. + */ + @Test + fun `sign in screen exposes its password visibility toggle`() { + setContent { + SignInUI( + configuration = emailConfiguration(), + isLoading = false, + emailSignInLinkSent = false, + email = "", + password = "password123", + onEmailChange = { }, + onPasswordChange = { }, + onRetrievedCredential = { }, + onSignInClick = { }, + onGoToSignUp = { }, + onGoToResetPassword = { }, + onGoToEmailLinkSignIn = { }, + ) + } + + assertExposedAsResourceId(FirebaseAuthTestTags.SignIn.PASSWORD_VISIBILITY_TOGGLE, button()) + } + + @Test + fun `sign up screen exposes its fields and actions`() { + setContent { + SignUpUI( + configuration = emailConfiguration(), + isLoading = false, + displayName = "", + email = "", + password = "password123", + confirmPassword = "password123", + onDisplayNameChange = { }, + onEmailChange = { }, + onPasswordChange = { }, + onConfirmPasswordChange = { }, + onGoToSignIn = { }, + onSignUpClick = { }, + onNavigateBack = { }, + ) + } + + assertExposedAsResourceId(FirebaseAuthTestTags.SignUp.NAME_FIELD, field()) + assertExposedAsResourceId(FirebaseAuthTestTags.SignUp.EMAIL_FIELD, field()) + assertExposedAsResourceId(FirebaseAuthTestTags.SignUp.PASSWORD_FIELD, field()) + assertExposedAsResourceId(FirebaseAuthTestTags.SignUp.CONFIRM_PASSWORD_FIELD, field()) + assertExposedAsResourceId(FirebaseAuthTestTags.SignUp.SIGN_UP_BUTTON, button()) + assertExposedAsResourceId(FirebaseAuthTestTags.SignUp.SIGN_IN_BUTTON, button()) + assertExposedAsResourceId(FirebaseAuthTestTags.SignUp.BACK_BUTTON, button()) + assertExposedAsResourceId(FirebaseAuthTestTags.SignUp.PASSWORD_VISIBILITY_TOGGLE, button()) + assertExposedAsResourceId( + FirebaseAuthTestTags.SignUp.CONFIRM_PASSWORD_VISIBILITY_TOGGLE, + button() + ) + } + + @Test + fun `reset password screen exposes its field and actions`() { + setContent { + ResetPasswordUI( + configuration = emailConfiguration(), + isLoading = false, + email = "", + resetLinkSent = false, + onEmailChange = { }, + onSendResetLink = { }, + onGoToSignIn = { }, + onNavigateBack = { }, + ) + } + + assertExposedAsResourceId(FirebaseAuthTestTags.ResetPassword.EMAIL_FIELD, field()) + assertExposedAsResourceId(FirebaseAuthTestTags.ResetPassword.SEND_BUTTON, button()) + assertExposedAsResourceId(FirebaseAuthTestTags.ResetPassword.SIGN_IN_BUTTON, button()) + assertExposedAsResourceId(FirebaseAuthTestTags.ResetPassword.BACK_BUTTON, button()) + } + + @Test + fun `email link screen exposes its field and actions`() { + setContent { + SignInEmailLinkUI( + configuration = emailConfiguration(), + isLoading = false, + emailSignInLinkSent = false, + email = "", + onEmailChange = { }, + onSignInWithEmailLink = { }, + onGoToSignIn = { }, + onGoToResetPassword = { }, + onNavigateBack = { }, + ) + } + + assertExposedAsResourceId(FirebaseAuthTestTags.EmailLink.EMAIL_FIELD, field()) + assertExposedAsResourceId(FirebaseAuthTestTags.EmailLink.SEND_LINK_BUTTON, button()) + assertExposedAsResourceId(FirebaseAuthTestTags.EmailLink.PASSWORD_SIGN_IN_BUTTON, button()) + assertExposedAsResourceId(FirebaseAuthTestTags.EmailLink.FORGOT_PASSWORD_BUTTON, button()) + assertExposedAsResourceId(FirebaseAuthTestTags.EmailLink.BACK_BUTTON, button()) + } + + @Test + fun `phone number screen exposes its field and actions`() { + setContent { + EnterPhoneNumberUI( + configuration = phoneConfiguration(), + isLoading = false, + phoneNumber = "", + selectedCountry = CountryUtils.getDefaultCountry(), + onPhoneNumberChange = { }, + onCountrySelected = { }, + onSendCodeClick = { }, + onNavigateBack = { }, + ) + } + + assertExposedAsResourceId(FirebaseAuthTestTags.PhoneNumber.PHONE_NUMBER_FIELD, field()) + assertExposedAsResourceId(FirebaseAuthTestTags.PhoneNumber.COUNTRY_SELECTOR_BUTTON, button()) + assertExposedAsResourceId(FirebaseAuthTestTags.PhoneNumber.SEND_CODE_BUTTON, button()) + assertExposedAsResourceId(FirebaseAuthTestTags.PhoneNumber.BACK_BUTTON, button()) + } + + @Test + fun `verification code screen exposes its field and actions`() { + setContent { + EnterVerificationCodeUI( + configuration = phoneConfiguration(), + isLoading = false, + verificationCode = "", + fullPhoneNumber = "+1 555 555 5555", + resendTimer = 0, + onVerificationCodeChange = { }, + onVerifyCodeClick = { }, + onResendCodeClick = { }, + onChangeNumberClick = { }, + onNavigateBack = { }, + ) + } + + // The tag names the digit group, which is the node that takes text — hence `field()`, not + // `hasAnyDescendant(field())`. + assertExposedAsResourceId(FirebaseAuthTestTags.VerificationCode.CODE_FIELD, field()) + assertExposedAsResourceId(FirebaseAuthTestTags.VerificationCode.VERIFY_BUTTON, button()) + assertExposedAsResourceId(FirebaseAuthTestTags.VerificationCode.RESEND_CODE_BUTTON, button()) + assertExposedAsResourceId( + FirebaseAuthTestTags.VerificationCode.CHANGE_PHONE_NUMBER_BUTTON, + button() + ) + assertExposedAsResourceId(FirebaseAuthTestTags.VerificationCode.BACK_BUTTON, button()) + } + + // ---- The code fields, which have to accept a code and not merely carry a tag ---- + + /** + * Regression pin: `fui_verification_code_code_field` used to name a bare container whose + * `ACTION_SET_TEXT` typed nothing into the real digit boxes, which had no resource id of their own. + */ + @Test + fun `a robo directive can type a whole code into the verification code field`() { + val entered = mutableStateOf("") + + setContent { + EnterVerificationCodeUI( + configuration = phoneConfiguration(), + isLoading = false, + verificationCode = entered.value, + fullPhoneNumber = "+1 555 555 5555", + resendTimer = 0, + onVerificationCodeChange = { entered.value = it }, + onVerifyCodeClick = { }, + onResendCodeClick = { }, + onChangeNumberClick = { }, + ) + } + + // Scrolling is a harness necessity, not part of the addressing: a node outside the window + // has no accessibility node at all, so there would be no resource id to find. + scrollIntoWindow(FirebaseAuthTestTags.VerificationCode.CODE_FIELD) + + setTextByResourceId(FirebaseAuthTestTags.VerificationCode.CODE_FIELD, VERIFICATION_CODE) + + assertWithMessage( + "ACTION_SET_TEXT on fui_verification_code_code_field reported success but the screen " + + "did not receive the code, so the tagged node accepted text without passing it to " + + "the digit boxes." + ).that(entered.value).isEqualTo(VERIFICATION_CODE) + + composeTestRule + .onNodeWithTag(FirebaseAuthTestTags.VerificationCode.VERIFY_BUTTON) + .assertIsEnabled() + } + + /** + * The MFA challenge screen shares the code input with the phone screen and had no tags at all, + * so it gets the same assertions rather than a weaker one. + */ + @Test + fun `a robo directive can type a whole code into the mfa challenge field`() { + val entered = mutableStateOf("") + + setContent { + DefaultMfaChallengeContent( + state = MfaChallengeContentState( + factorType = MfaFactor.Sms, + maskedPhoneNumber = "+1••••••890", + verificationCode = entered.value, + onVerificationCodeChange = { entered.value = it }, + ) + ) + } + + scrollIntoWindow(FirebaseAuthTestTags.MfaChallenge.CODE_FIELD) + + assertExposedAsResourceId(FirebaseAuthTestTags.MfaChallenge.CODE_FIELD, field()) + setTextByResourceId(FirebaseAuthTestTags.MfaChallenge.CODE_FIELD, VERIFICATION_CODE) + + assertWithMessage( + "ACTION_SET_TEXT on fui_mfa_challenge_code_field reported success but the challenge " + + "screen did not receive the code." + ).that(entered.value).isEqualTo(VERIFICATION_CODE) + + assertExposedAsResourceId(FirebaseAuthTestTags.MfaChallenge.VERIFY_BUTTON, button()) + composeTestRule + .onNodeWithTag(FirebaseAuthTestTags.MfaChallenge.VERIFY_BUTTON) + .assertIsEnabled() + + assertExposedAsResourceId(FirebaseAuthTestTags.MfaChallenge.RESEND_CODE_BUTTON, button()) + assertExposedAsResourceId(FirebaseAuthTestTags.MfaChallenge.CANCEL_BUTTON, button()) + } + + /** + * [FirebaseAuthTestTags.MfaChallenge.CANCEL_BUTTON] is shared by two mutually exclusive + * controls in [DefaultMfaChallengeContent]; this pins the TOTP side, distinct from SMS above. + */ + @Test + fun `mfa challenge cancel button is exposed for the totp factor as well`() { + setContent { + DefaultMfaChallengeContent( + state = MfaChallengeContentState( + factorType = MfaFactor.Totp, + ) + ) + } + + assertExposedAsResourceId(FirebaseAuthTestTags.MfaChallenge.CANCEL_BUTTON, button()) + composeTestRule + .onNodeWithTag(FirebaseAuthTestTags.MfaChallenge.RESEND_CODE_BUTTON) + .assertDoesNotExist() + } + + @Test + fun `method picker exposes its provider list`() { + setContent { + AuthMethodPicker( + providers = listOf( + AuthProvider.Google(scopes = emptyList(), serverClientId = null) + ), + onProviderSelected = { }, + ) + } + + assertExposedAsResourceId( + FirebaseAuthTestTags.MethodPicker.PROVIDER_LIST, + hasScrollAction() + ) + } + + /** + * The "Continue as …" button only exists when a sign-in preference is stored, so it needs its + * own fixture — and it's the first control a returning user's crawl reaches. + */ + @Test + fun `method picker exposes its continue as button`() { + val provider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + + setContent { + AuthMethodPicker( + providers = listOf(provider), + lastSignInPreference = SignInPreferenceManager.SignInPreference( + providerId = provider.providerId, + identifier = "user@example.com", + timestamp = 0L + ), + onProviderSelected = { }, + ) + } + + assertExposedAsResourceId(FirebaseAuthTestTags.MethodPicker.CONTINUE_AS_BUTTON, button()) + } + + // ---- Separate semantics owners: dialogs ---- + + /** + * The "reset link sent" dialog. Its dismiss button is the only way forward for a crawler that + * has just submitted the form, and it is in a different window from the screen behind it. + */ + @Test + fun `reset password sent dialog exposes its dismiss button`() { + setContent { + ResetPasswordUI( + configuration = emailConfiguration(), + isLoading = false, + email = "user@example.com", + resetLinkSent = true, + onEmailChange = { }, + onSendResetLink = { }, + onGoToSignIn = { }, + ) + } + + assertExposedAsResourceId(FirebaseAuthTestTags.ResetPassword.DISMISS_BUTTON, button()) + } + + @Test + fun `email link sent dialog exposes its dismiss button`() { + setContent { + SignInEmailLinkUI( + configuration = emailConfiguration(), + isLoading = false, + emailSignInLinkSent = true, + email = "user@example.com", + onEmailChange = { }, + onSignInWithEmailLink = { }, + onGoToSignIn = { }, + onGoToResetPassword = { }, + ) + } + + assertExposedAsResourceId(FirebaseAuthTestTags.EmailLink.DISMISS_BUTTON, button()) + } + + @Test + fun `reauthentication dialog exposes its password field and actions`() { + val user = mock() + whenever(user.email).thenReturn("user@example.com") + + setContent { + ReauthenticationDialog( + user = user, + onDismiss = { }, + onSuccess = { }, + onError = { }, + ) + } + + assertExposedAsResourceId(FirebaseAuthTestTags.Reauth.PASSWORD_FIELD, field()) + assertExposedAsResourceId(FirebaseAuthTestTags.Reauth.VERIFY_BUTTON, button()) + assertExposedAsResourceId(FirebaseAuthTestTags.Reauth.DISMISS_BUTTON, button()) + } + + /** + * [ErrorRecoveryDialog] publishes no tag of its own, so this pins the flag from the caller's + * side: a host app's tag passed via `modifier` becomes a resource id via the dialog's own flag. + */ + @Test + fun `error recovery dialog exposes a caller supplied tag`() { + setContent { + ErrorRecoveryDialog( + error = AuthException.NetworkException(message = "offline"), + stringProvider = stringProvider, + onRetry = { }, + onDismiss = { }, + modifier = Modifier.testTag(CALLER_TAG), + ) + } + + assertExposedAsResourceId(CALLER_TAG, hasAnyDescendant(hasClickAction())) + } + + /** + * The dialog's own retry and dismiss actions, as opposed to the caller-supplied tag above: + * these are the library's tags, always present regardless of what modifier a caller passes in. + */ + @Test + fun `error recovery dialog exposes its retry and dismiss buttons`() { + setContent { + ErrorRecoveryDialog( + error = AuthException.NetworkException(message = "offline"), + stringProvider = stringProvider, + onRetry = { }, + onDismiss = { }, + ) + } + + assertExposedAsResourceId(FirebaseAuthTestTags.ErrorRecovery.RETRY_BUTTON, button()) + assertExposedAsResourceId(FirebaseAuthTestTags.ErrorRecovery.DISMISS_BUTTON, button()) + } + + // ---- Separate semantics owners: bottom sheets ---- + + /** + * The country selector sheet: [FirebaseAuthScreenModifierTest] shows a modifier passed to the + * flow's root never reaches it, so the sheet must set the flag itself. + */ + @Test + fun `country selector bottom sheet exposes its country list`() { + setContent { + CountrySelector( + selectedCountry = CountryUtils.getDefaultCountry(), + onCountrySelected = { }, + ) + } + + composeTestRule.onNodeWithContentDescription(COUNTRY_SELECTOR_DESCRIPTION).performClick() + composeTestRule.waitForIdle() + + assertExposedAsResourceId( + FirebaseAuthTestTags.CountrySelector.COUNTRY_LIST, + hasScrollAction() + ) + } + + // ---- MFA enrollment: the factor-selection step, and the collision it is built to avoid ---- + + /** + * [DefaultMfaEnrollmentContent] renders both enroll buttons at once via `forEach`, so this + * proves both are addressable simultaneously — a one-at-a-time fixture would miss a shared tag. + */ + @Test + fun `mfa enrollment exposes distinct buttons for each available factor at once`() { + setContent { + DefaultMfaEnrollmentContent( + state = MfaEnrollmentContentState( + step = MfaEnrollmentStep.SelectFactor, + availableFactors = listOf(MfaFactor.Sms, MfaFactor.Totp), + enrolledFactors = emptyList(), + onSkipClick = { }, + ), + authConfiguration = phoneConfiguration(), + user = mock(), + ) + } + + assertExposedAsResourceId(FirebaseAuthTestTags.MfaEnrollment.ENROLL_SMS_BUTTON, button()) + assertExposedAsResourceId(FirebaseAuthTestTags.MfaEnrollment.ENROLL_TOTP_BUTTON, button()) + assertExposedAsResourceId(FirebaseAuthTestTags.MfaEnrollment.SKIP_BUTTON, button()) + } + + /** + * Mirror case on `EnrolledFactorItem`: a user enrolled in both factors composes one remove + * button per factor in the same `forEach`, so both need to be addressable at once too. + */ + @Test + fun `mfa enrollment exposes distinct remove buttons for each enrolled factor at once`() { + val phoneFactor = mock() + whenever(phoneFactor.phoneNumber).thenReturn("+1234567890") + val totpFactor = mock() + + setContent { + DefaultMfaEnrollmentContent( + state = MfaEnrollmentContentState( + step = MfaEnrollmentStep.SelectFactor, + availableFactors = listOf(MfaFactor.Sms, MfaFactor.Totp), + enrolledFactors = listOf(phoneFactor, totpFactor), + ), + authConfiguration = phoneConfiguration(), + user = mock(), + ) + } + + assertExposedAsResourceId(FirebaseAuthTestTags.MfaEnrollment.REMOVE_SMS_BUTTON, button()) + assertExposedAsResourceId(FirebaseAuthTestTags.MfaEnrollment.REMOVE_TOTP_BUTTON, button()) + } + + // ---- MFA enrollment: TOTP setup and verification ---- + + @Test + fun `mfa enrollment totp setup step exposes its back and continue buttons`() { + setContent { + DefaultMfaEnrollmentContent( + state = MfaEnrollmentContentState( + step = MfaEnrollmentStep.ConfigureTotp, + ), + authConfiguration = phoneConfiguration(), + user = mock(), + ) + } + + assertExposedAsResourceId( + FirebaseAuthTestTags.MfaEnrollment.CONFIGURE_TOTP_BACK_BUTTON, + button() + ) + assertExposedAsResourceId( + FirebaseAuthTestTags.MfaEnrollment.CONFIGURE_TOTP_CONTINUE_BUTTON, + button() + ) + } + + /** + * The TOTP verification code field is the one enrollment node a Robo directive must type a + * real value into, so it gets the same `ACTION_SET_TEXT` treatment as the phone code field. + */ + @Test + fun `a robo directive can type a code into the mfa enrollment totp verification field`() { + val entered = mutableStateOf("") + + setContent { + DefaultMfaEnrollmentContent( + state = MfaEnrollmentContentState( + step = MfaEnrollmentStep.VerifyFactor, + selectedFactor = MfaFactor.Totp, + verificationCode = entered.value, + onVerificationCodeChange = { entered.value = it }, + ), + authConfiguration = phoneConfiguration(), + user = mock(), + ) + } + + assertExposedAsResourceId(FirebaseAuthTestTags.MfaEnrollment.VERIFY_TOTP_CODE_FIELD, field()) + setTextByResourceId(FirebaseAuthTestTags.MfaEnrollment.VERIFY_TOTP_CODE_FIELD, VERIFICATION_CODE) + + assertWithMessage( + "ACTION_SET_TEXT on fui_mfa_enrollment_verify_totp_code_field reported success but " + + "the enrollment screen did not receive the code." + ).that(entered.value).isEqualTo(VERIFICATION_CODE) + + assertExposedAsResourceId(FirebaseAuthTestTags.MfaEnrollment.VERIFY_TOTP_BACK_BUTTON, button()) + assertExposedAsResourceId(FirebaseAuthTestTags.MfaEnrollment.VERIFY_TOTP_BUTTON, button()) + } + + @Test + fun `mfa enrollment recovery codes step exposes its saved confirmation button`() { + setContent { + DefaultMfaEnrollmentContent( + state = MfaEnrollmentContentState( + step = MfaEnrollmentStep.ShowRecoveryCodes, + recoveryCodes = listOf("1111-1111", "2222-2222"), + ), + authConfiguration = phoneConfiguration(), + user = mock(), + ) + } + + assertExposedAsResourceId( + FirebaseAuthTestTags.MfaEnrollment.RECOVERY_CODES_SAVED_BUTTON, + button() + ) + } + + // ---- The flow root ---- + + /** + * The flow root's `Surface` in [FirebaseAuthScreen] carries the flag too, covering content + * hosted directly beneath it — the custom method-picker slot bypasses [AuthMethodPicker]'s own flag. + */ + @Test + fun `flow root exposes tags on content hosted directly beneath it`() { + // Two providers, so the method-picker route — the one the custom slot replaces — is where + // the flow starts. + val configuration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ) + ) + } + } + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = configuration, + authUI = authUI, + onSignInSuccess = { }, + onSignInFailure = { }, + onSignInCancelled = { }, + customMethodPickerLayout = { _, _ -> + Text(text = "Custom Picker", modifier = Modifier.testTag(CALLER_TAG)) + } + ) + } + + assertExposedAsResourceId(CALLER_TAG, hasText("Custom Picker")) + } + + // ---- FirebaseAuthScreen needs a live FirebaseApp ---- + + private val authUI: FirebaseAuthUI + get() { + FirebaseAuthUI.clearInstanceCache() + FirebaseApp.getApps(applicationContext).forEach { it.delete() } + FirebaseApp.initializeApp( + applicationContext, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + return FirebaseAuthUI.getInstance() + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + FirebaseApp.getApps(applicationContext).forEach { + try { + it.delete() + } catch (_: Exception) { + } + } + } + + private companion object { + /** A host application's own tag, which the library must not require to be registered. */ + const val CALLER_TAG = "host_app_supplied_tag" + + /** Opens the country selector sheet. */ + const val COUNTRY_SELECTOR_DESCRIPTION = "Country selector" + + /** Six digits, the length both code screens expect. */ + const val VERIFICATION_CODE = "123456" + + /** + * The class a crawler requires before typing into a node; Robo's `inputText` directives are + * documented for `EditText` only. + */ + const val EDIT_TEXT_CLASS_NAME = "android.widget.EditText" + + /** + * Matches every semantics node, so [accessibilityNodePublishing] can search the whole tree + * for a resource id rather than being handed the node by its test tag. + */ + val ANY_NODE = SemanticsMatcher("any semantics node") { true } + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/components/AuthProviderButtonTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/components/AuthProviderButtonTest.kt index a91518522..267792967 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/components/AuthProviderButtonTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/components/AuthProviderButtonTest.kt @@ -15,15 +15,23 @@ package com.firebase.ui.auth.ui.components import android.content.Context +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Star +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.core.app.ApplicationProvider @@ -67,9 +75,7 @@ class AuthProviderButtonTest { clickedProvider = null } - // ============================================================================================= - // Basic UI Tests - // ============================================================================================= + // ---- Basic UI Tests ---- @Test fun `AuthProviderButton displays Google provider correctly`() { @@ -320,9 +326,7 @@ class AuthProviderButtonTest { .assertIsEnabled() } - // ============================================================================================= - // Click Interaction Tests - // ============================================================================================= + // ---- Click Interaction Tests ---- @Test fun `AuthProviderButton onClick is called when clicked`() { @@ -364,9 +368,7 @@ class AuthProviderButtonTest { assertThat(clickedProvider).isNull() } - // ============================================================================================= - // Style Resolution Tests - // ============================================================================================= + // ---- Style Resolution Tests ---- @Test fun `AuthProviderButton uses custom style when provided`() { @@ -469,9 +471,7 @@ class AuthProviderButtonTest { assertThat(resolvedStyle.icon).isEqualTo(googleDefaultStyle.icon) } - // ============================================================================================= - // Provider Style Fallback Tests - // ============================================================================================= + // ---- Provider Style Fallback Tests ---- @Test fun `AuthProviderButton provides fallback for unknown provider`() { @@ -546,4 +546,89 @@ class AuthProviderButtonTest { assertThat(resolvedStyle.backgroundColor).isEqualTo(AuthUITheme.ProviderStyle.Empty.backgroundColor) assertThat(resolvedStyle.contentColor).isEqualTo(AuthUITheme.ProviderStyle.Empty.contentColor) } + + // ---- Modifier contract tests ---- + + /** + * A composable must apply `modifier` to exactly one node. This button used to hand the same + * instance to both the Button and its inner Row, duplicating tags and padding. + */ + @Test + fun `caller modifier is applied to exactly one node`() { + composeTestRule.setContent { + AuthProviderButton( + modifier = Modifier + .fillMaxWidth() + .testTag(CALLER_TAG), + provider = AuthProvider.Google(scopes = emptyList(), serverClientId = null), + onClick = { }, + stringProvider = stringProvider + ) + } + + composeTestRule + .onAllNodesWithTag(CALLER_TAG, useUnmergedTree = true) + .assertCountEquals(1) + } + + /** + * The caller's modifier must reach the button itself, not the content row. This is a guard on + * which node owns the tag, not a pin on the fix — the unmerged count test above pins that. + */ + @Test + fun `caller modifier lands on the button rather than its content`() { + composeTestRule.setContent { + AuthProviderButton( + modifier = Modifier + .fillMaxWidth() + .testTag(CALLER_TAG), + provider = AuthProvider.Google(scopes = emptyList(), serverClientId = null), + onClick = { }, + stringProvider = stringProvider + ) + } + + composeTestRule + .onNodeWithTag(CALLER_TAG) + .assertHasClickAction() + } + + /** + * The content row owns its own width now, so a full-width button still start-aligns its icon + * and label rather than centering them. + */ + @Test + fun `full width button keeps its content start aligned`() { + composeTestRule.setContent { + Column(modifier = Modifier.fillMaxWidth()) { + AuthProviderButton( + modifier = Modifier + .fillMaxWidth() + .testTag(CALLER_TAG), + provider = AuthProvider.Google(scopes = emptyList(), serverClientId = null), + onClick = { }, + stringProvider = stringProvider + ) + } + } + + val label = context.getString(R.string.fui_sign_in_with_google) + val buttonBounds = composeTestRule.onNodeWithTag(CALLER_TAG).getUnclippedBoundsInRoot() + val iconBounds = composeTestRule + .onNodeWithContentDescription(label, useUnmergedTree = true) + .getUnclippedBoundsInRoot() + + // 12.dp of Button content padding is the only gap expected between the two left edges. + val inset = iconBounds.left - buttonBounds.left + assertThat(inset.value).isWithin(TOLERANCE_DP).of(CONTENT_PADDING_DP) + } + + private companion object { + const val CALLER_TAG = "caller_supplied_tag" + + /** Horizontal `contentPadding` applied by [AuthProviderButton] to the Material button. */ + const val CONTENT_PADDING_DP = 12f + + const val TOLERANCE_DP = 0.5f + } } \ No newline at end of file diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/components/CountrySelectorTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/components/CountrySelectorTest.kt new file mode 100644 index 000000000..24d9b1185 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/components/CountrySelectorTest.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.components + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.data.CountryData +import com.firebase.ui.auth.ui.FirebaseAuthTestTags +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [CountrySelector] covering the bottom sheet country list, including the stable + * test tag host applications and Robo directives target it by. + * + * @suppress Internal test class + */ +@Config(manifest = Config.NONE, sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class CountrySelectorTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var context: Context + private var selectedCountry: CountryData? = null + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + selectedCountry = null + } + + private fun setContentWithStringProvider(content: @Composable () -> Unit) { + composeTestRule.setContent { + CompositionLocalProvider( + LocalAuthUIStringProvider provides DefaultAuthUIStringProvider(context) + ) { + content() + } + } + } + + private fun setCountrySelectorContent() { + setContentWithStringProvider { + CountrySelector( + selectedCountry = CountryData( + name = "United States", + dialCode = "+1", + countryCode = "US", + flagEmoji = "🇺🇸" + ), + onCountrySelected = { selectedCountry = it } + ) + } + } + + // ---- Test Tag Tests ---- + + @Test + fun `CountrySelector does not tag a country list while the bottom sheet is closed`() { + setCountrySelectorContent() + + composeTestRule + .onNodeWithTag(FirebaseAuthTestTags.CountrySelector.COUNTRY_LIST) + .assertDoesNotExist() + } + + // ---- Selection Tests ---- + + @Test + fun `CountrySelector reports the country picked from the tagged list`() { + setCountrySelectorContent() + + composeTestRule.onNodeWithContentDescription("Country selector").performClick() + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithTag(FirebaseAuthTestTags.CountrySelector.COUNTRY_LIST) + .assertIsDisplayed() + composeTestRule.onNodeWithText(FIRST_COUNTRY_NAME).performClick() + composeTestRule.waitForIdle() + + assertThat(selectedCountry?.countryCode).isEqualTo(FIRST_COUNTRY_CODE) + } + + private companion object { + /** First entry of `ALL_COUNTRIES`, so it needs no scrolling to reach. */ + const val FIRST_COUNTRY_NAME = "Afghanistan" + const val FIRST_COUNTRY_CODE = "AF" + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/components/VerificationCodeInputFieldSemanticsTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/components/VerificationCodeInputFieldSemanticsTest.kt new file mode 100644 index 000000000..022ba6360 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/components/VerificationCodeInputFieldSemanticsTest.kt @@ -0,0 +1,303 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.components + +import android.content.Context +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.AccessibilityAction +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.text.AnnotatedString +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The text-input contract [VerificationCodeInputField] declares on the group holding its digit + * boxes, without which a Robo `inputText` directive resolved the node and typed nothing (#2050). + * + * @suppress Internal test class + */ +@Config(manifest = Config.NONE, sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class VerificationCodeInputFieldSemanticsTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val entered = mutableListOf() + private val context: Context = ApplicationProvider.getApplicationContext() + private val stringProvider = DefaultAuthUIStringProvider(context) + + private fun setContent(codeLength: Int = 6) { + composeTestRule.setContent { + CompositionLocalProvider( + LocalAuthUIStringProvider provides stringProvider + ) { + VerificationCodeInputField( + modifier = Modifier.testTag(CODE_FIELD_TAG), + codeLength = codeLength, + onCodeChange = { entered += it } + ) + } + } + } + + /** The code the group currently holds, as its callback last reported it. */ + private fun currentCode(): String = entered.last() + + /** The code the group publishes on itself, which is what a reader of the node sees. */ + private fun editableText(): String = + composeTestRule + .onNodeWithTag(CODE_FIELD_TAG) + .fetchSemanticsNode() + .config[SemanticsProperties.EditableText] + .text + + /** + * Invokes a text action on the group directly, so its `Boolean` result — which the Compose test + * APIs throw away — can be asserted. + */ + private fun invokeSetText(text: String): Boolean = invoke(SemanticsActions.SetText, text) + + private fun invokeInsert(text: String): Boolean = + invoke(SemanticsActions.InsertTextAtCursor, text) + + private fun invoke( + key: SemanticsPropertyKey Boolean>>, + text: String, + ): Boolean { + val config = composeTestRule.onNodeWithTag(CODE_FIELD_TAG).fetchSemanticsNode().config + + assertWithMessage( + "The node tagged $CODE_FIELD_TAG declares no ${key.name} action, so it cannot be typed " + + "into from outside Compose — which is the whole point of declaring semantics on the " + + "group. Its config: ${config.joinToString { it.key.name }}." + ).that(config.contains(key)).isTrue() + + val action = requireNotNull(config[key].action) { "${key.name} has a null handler." } + val result = composeTestRule.runOnIdle { action(AnnotatedString(text)) } + composeTestRule.waitForIdle() + return result + } + + @Test + fun `setText spreads a whole code across the boxes`() { + setContent() + + assertThat(invokeSetText("123456")).isTrue() + assertThat(currentCode()).isEqualTo("123456") + } + + @Test + fun `setText replaces rather than appends`() { + setContent() + + invokeSetText("123456") + assertThat(invokeSetText("987")).isTrue() + + assertWithMessage( + "setText is a replacement, so a shorter code must leave the trailing boxes empty " + + "rather than keeping digits from the previous one." + ).that(currentCode()).isEqualTo("987") + } + + @Test + fun `setText clears the code when given an empty string`() { + setContent() + + invokeSetText("123456") + assertThat(invokeSetText("")).isTrue() + assertThat(currentCode()).isEmpty() + } + + @Test + fun `setText refuses input the boxes cannot hold`() { + setContent() + + invokeSetText("12") + + assertWithMessage("A letter is not a digit in any script, so no box can show it.") + .that(invokeSetText("12a456")).isFalse() + assertWithMessage("Seven digits do not fit in six boxes.") + .that(invokeSetText("1234567")).isFalse() + + assertWithMessage( + "A refused action must leave the code untouched; anything else is a partial write " + + "reported as a failure." + ).that(currentCode()).isEqualTo("12") + } + + @Test + fun `insertTextAtCursor fills forward from the first empty box`() { + setContent() + + assertThat(invokeInsert("12")).isTrue() + assertThat(invokeInsert("3456")).isTrue() + assertThat(currentCode()).isEqualTo("123456") + } + + @Test + fun `insertTextAtCursor refuses more digits than there are empty boxes`() { + setContent() + + invokeInsert("1234") + + assertWithMessage("Three digits do not fit in the two remaining boxes.") + .that(invokeInsert("567")).isFalse() + + invokeInsert("56") + assertWithMessage("A full code leaves nowhere to insert.") + .that(invokeInsert("7")).isFalse() + + assertThat(currentCode()).isEqualTo("123456") + } + + /** + * The path a host application takes, as the registry KDoc invites: `performTextInput` against the + * published tag rather than against any single box. + */ + @Test + fun `performTextInput and performTextReplacement both enter a whole code`() { + setContent() + + composeTestRule.onNodeWithTag(CODE_FIELD_TAG).performTextInput("123456") + composeTestRule.waitForIdle() + assertThat(currentCode()).isEqualTo("123456") + + composeTestRule.onNodeWithTag(CODE_FIELD_TAG).performTextReplacement("654321") + composeTestRule.waitForIdle() + assertThat(currentCode()).isEqualTo("654321") + } + + /** + * Inserting nothing is a no-op that reports success, even on a full code: the emptiness check + * in `insertTextAtCursor` runs before the "nowhere to insert" check. + */ + @Test + fun `insertTextAtCursor accepts an empty insert whether or not the code is full`() { + setContent() + + assertWithMessage("Inserting nothing into an empty code changes nothing and fails nothing.") + .that(invokeInsert("")).isTrue() + + invokeInsert("123456") + + assertWithMessage( + "Inserting nothing into a full code is still a no-op. Reporting failure here would " + + "have a caller believe its input was refused when it asked for nothing." + ).that(invokeInsert("")).isTrue() + assertThat(currentCode()).isEqualTo("123456") + } + + /** + * Pins the digit definition as `Character.isDigit`'s rather than ASCII `0`-`9`, matching the + * per-box keyboard path so a code typed on a localised keypad isn't refused via `ACTION_SET_TEXT`. + */ + @Test + fun `the actions accept non ASCII digits and normalise them`() { + setContent() + + assertThat(invokeSetText(ARABIC_INDIC_CODE)).isTrue() + assertWithMessage( + "Arabic-Indic digits are digits to Character.isDigit and carry the same values, so the " + + "code arrives normalised to ASCII rather than refused." + ).that(currentCode()).isEqualTo("123456") + + assertThat(invokeSetText(FULLWIDTH_CODE)).isTrue() + assertThat(currentCode()).isEqualTo("123") + } + + /** + * The group's `editableText` is the digits before the first empty box, not every digit entered: + * boxes can be filled out of order, and compacting would misreport a gap-filled digit's position. + */ + @Test + fun `editableText reports the digits before the first gap rather than a compacted code`() { + setContent() + + invokeInsert("12") + + assertThat(editableText()).isEqualTo("12") + + // A gap: the fourth box is filled directly, as tapping it and typing would. + composeTestRule.onAllNodesWithContentDescription(DIGIT_BOX_DESCRIPTION, substring = true)[3] + .performTextInput("4") + composeTestRule.waitForIdle() + + assertWithMessage( + "The third box is empty, so the code the node reports must stop at it. Compacting to " + + "\"124\" would claim a 4 in the third position." + ).that(editableText()).isEqualTo("12") + + assertWithMessage( + "onCodeChange stays compacted — screens on this branch read it as the code so far — so " + + "the two deliberately disagree while there is a gap." + ).that(currentCode()).isEqualTo("124") + + // Filling the gap makes the prefix the whole code again. + composeTestRule.onAllNodesWithContentDescription(DIGIT_BOX_DESCRIPTION, substring = true)[2] + .performTextInput("3") + composeTestRule.waitForIdle() + + assertThat(editableText()).isEqualTo("1234") + } + + /** A code that is not six digits long, to pin that the actions follow `codeLength`. */ + @Test + fun `the actions follow a non default code length`() { + setContent(codeLength = 4) + + assertThat(invokeSetText("1234")).isTrue() + assertThat(currentCode()).isEqualTo("1234") + assertThat(invokeSetText("12345")).isFalse() + assertThat(currentCode()).isEqualTo("1234") + } + + private companion object { + /** + * A fixture-local tag: the registry values belong to screens, and this exercises the + * widget on its own, standing in for a host app's own tag too. + */ + const val CODE_FIELD_TAG = "verification_code_group_under_test" + + /** + * Shared prefix of each digit box's content description (e.g. "...digit 3 of 6"), used + * with a substring match to reach one box by its index. + */ + const val DIGIT_BOX_DESCRIPTION = "Verification code digit" + + /** `123456` in Arabic-Indic digits. */ + const val ARABIC_INDIC_CODE = "١٢٣٤٥٦" + + /** `123` in fullwidth digits. */ + const val FULLWIDTH_CODE = "123" + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPickerTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPickerTest.kt index 870e71132..fd17ef25c 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPickerTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPickerTest.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.test.performScrollToNode import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.R import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.ui.FirebaseAuthTestTags import com.firebase.ui.auth.ui.method_picker.MethodPickerTermsConfiguration import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider @@ -462,7 +463,7 @@ class AuthMethodPickerTest { .assertIsDisplayed() composeTestRule - .onNodeWithTag("AuthMethodPicker LazyColumn") + .onNodeWithTag(FirebaseAuthTestTags.MethodPicker.PROVIDER_LIST) .performScrollToNode(hasText(context.getString(R.string.fui_sign_in_anonymously))) composeTestRule @@ -494,9 +495,27 @@ class AuthMethodPickerTest { ) } + // Load-bearing: catches the tag drifting onto a non-clickable wrapper around the button. composeTestRule - .onNodeWithTag("ContinueAsButton") + .onNodeWithTag(FirebaseAuthTestTags.MethodPicker.CONTINUE_AS_BUTTON) .assertIsDisplayed() + .assertHasClickAction() + } + + @Test + fun `AuthMethodPicker hides ContinueAsButton when there is no lastSignInPreference`() { + setContentWithStringProvider { + AuthMethodPicker( + providers = listOf( + AuthProvider.Google(scopes = emptyList(), serverClientId = null) + ), + onProviderSelected = { selectedProvider = it } + ) + } + + composeTestRule + .onNodeWithTag(FirebaseAuthTestTags.MethodPicker.CONTINUE_AS_BUTTON) + .assertDoesNotExist() } @Test @@ -518,7 +537,7 @@ class AuthMethodPickerTest { } composeTestRule - .onNodeWithTag("ContinueAsButton") + .onNodeWithTag(FirebaseAuthTestTags.MethodPicker.CONTINUE_AS_BUTTON) .assertDoesNotExist() } @@ -549,7 +568,7 @@ class AuthMethodPickerTest { } composeTestRule - .onNodeWithTag("ContinueAsButton") + .onNodeWithTag(FirebaseAuthTestTags.MethodPicker.CONTINUE_AS_BUTTON) .performClick() Truth.assertThat(continueAsProvider).isEqualTo(emailProvider) @@ -578,7 +597,7 @@ class AuthMethodPickerTest { } composeTestRule - .onNodeWithTag("ContinueAsButton") + .onNodeWithTag(FirebaseAuthTestTags.MethodPicker.CONTINUE_AS_BUTTON) .performClick() Truth.assertThat(selectedProvider).isEqualTo(emailProvider) diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenModifierTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenModifierTest.kt new file mode 100644 index 000000000..53fcbff71 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenModifierTest.kt @@ -0,0 +1,247 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.screens + +import android.content.Context +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasAnyDescendant +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.ui.FirebaseAuthTestTags +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Tests that [FirebaseAuthScreen] applies the caller's `modifier` once, to its own outermost node + * — it used to hardcode `Modifier.fillMaxSize()` at the root and forward the caller's instance in. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [34]) +class FirebaseAuthScreenModifierTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var context: Context + private lateinit var authUI: FirebaseAuthUI + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + FirebaseAuthUI.clearInstanceCache() + FirebaseApp.getApps(context).forEach { it.delete() } + FirebaseApp.initializeApp( + context, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + authUI = FirebaseAuthUI.getInstance() + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + FirebaseApp.getApps(context).forEach { + try { it.delete() } catch (_: Exception) {} + } + } + + /** A single email provider makes the email screen — not the method picker — the start route. */ + private fun emailOnlyConfiguration(): AuthUIConfiguration = authUIConfiguration { + context = this@FirebaseAuthScreenModifierTest.context + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + } + } + + /** A single phone provider makes the phone screen the start route, country selector included. */ + private fun phoneOnlyConfiguration(): AuthUIConfiguration = authUIConfiguration { + context = this@FirebaseAuthScreenModifierTest.context + providers { + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ) + ) + } + } + + private fun methodPickerConfiguration(): AuthUIConfiguration = authUIConfiguration { + context = this@FirebaseAuthScreenModifierTest.context + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ) + ) + } + } + + private fun setContent( + configuration: AuthUIConfiguration, + modifier: Modifier, + customMethodPickerLayout: (@Composable () -> Unit)? = null, + ) { + composeTestRule.setContent { + FirebaseAuthScreen( + modifier = modifier, + configuration = configuration, + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = {}, + customMethodPickerLayout = customMethodPickerLayout?.let { slot -> + { _, _ -> slot() } + } + ) + } + } + + /** + * The decisive case: on a route other than the method picker, the old code dropped the + * caller's modifier entirely, so this found zero nodes before the fix. + */ + @Test + fun `caller modifier reaches the root on a route that never received it`() { + setContent(emailOnlyConfiguration(), Modifier.testTag(CALLER_TAG)) + + composeTestRule + .onAllNodesWithTag(CALLER_TAG, useUnmergedTree = true) + .assertCountEquals(1) + } + + /** + * And the node it reaches really is the root, not a leaf that happens to exist once: the + * destination's content sits underneath it. + */ + @Test + fun `caller modifier node hosts the destination content`() { + setContent(emailOnlyConfiguration(), Modifier.testTag(CALLER_TAG)) + + val screenTitle = DefaultAuthUIStringProvider(context).signInDefault + + composeTestRule + .onAllNodes( + hasTestTag(CALLER_TAG) and hasAnyDescendant(hasText(screenTitle)), + useUnmergedTree = true + ) + .assertCountEquals(1) + } + + /** + * Guard against the modifier being duplicated or dropped when a custom method-picker slot is + * supplied, not a pin on a specific arrangement. + */ + @Test + fun `caller modifier is applied once when a custom method picker is supplied`() { + setContent( + configuration = methodPickerConfiguration(), + modifier = Modifier.testTag(CALLER_TAG), + customMethodPickerLayout = { + Text(text = "Custom Picker", modifier = Modifier.testTag(SENTINEL_TAG)) + } + ) + + composeTestRule + .onAllNodesWithTag(CALLER_TAG, useUnmergedTree = true) + .assertCountEquals(1) + + composeTestRule + .onAllNodes( + hasTestTag(CALLER_TAG) and hasAnyDescendant(hasTestTag(SENTINEL_TAG)), + useUnmergedTree = true + ) + .assertCountEquals(1) + } + + /** + * Pins a boundary, not a bug: a bottom sheet is a separate semantics owner, so its content is + * not a descendant of the root the caller's modifier lands on. + */ + @Test + fun `caller modifier does not reach bottom sheet content, which is a separate semantics owner`() { + setContent(phoneOnlyConfiguration(), Modifier.testTag(CALLER_TAG)) + + composeTestRule.onNodeWithContentDescription(COUNTRY_SELECTOR_DESCRIPTION).performClick() + composeTestRule.waitForIdle() + + // The sheet is open and its tagged node is findable, so the query below is meaningful. + composeTestRule + .onAllNodesWithTag(SHEET_TAG, useUnmergedTree = true) + .assertCountEquals(1) + + // But it hangs off the sheet's own semantics root, not off the caller-tagged one. + composeTestRule + .onAllNodes( + hasTestTag(SHEET_TAG) and hasAnyAncestor(hasTestTag(CALLER_TAG)), + useUnmergedTree = true + ) + .assertCountEquals(0) + } + + private companion object { + const val CALLER_TAG = "caller_supplied_tag" + + const val SENTINEL_TAG = "destination_content_sentinel" + + /** A tag applied inside a [androidx.compose.material3.ModalBottomSheet] by the flow. */ + const val SHEET_TAG = FirebaseAuthTestTags.CountrySelector.COUNTRY_LIST + + /** Opens the country selector sheet from the phone screen. */ + const val COUNTRY_SELECTOR_DESCRIPTION = "Country selector" + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenSlotsTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenSlotsTest.kt index 6273b32b4..4a9c5e529 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenSlotsTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenSlotsTest.kt @@ -27,6 +27,7 @@ import com.firebase.ui.auth.AuthState import com.firebase.ui.auth.FirebaseAuthUI import com.firebase.ui.auth.configuration.authUIConfiguration import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.ui.FirebaseAuthTestTags import com.firebase.ui.auth.ui.method_picker.MethodPickerTermsConfiguration import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseOptions @@ -137,7 +138,7 @@ class FirebaseAuthScreenSlotsTest { ) } - composeTestRule.onNodeWithTag("AuthMethodPicker LazyColumn").assertIsDisplayed() + composeTestRule.onNodeWithTag(FirebaseAuthTestTags.MethodPicker.PROVIDER_LIST).assertIsDisplayed() } @Test @@ -169,7 +170,7 @@ class FirebaseAuthScreenSlotsTest { composeTestRule.onNodeWithTag("custom_method_picker").assertIsDisplayed() // AuthMethodPicker (and with it, the logo/ToS footer it renders) must not exist at all — // customMethodPickerLayout now takes over the entire screen, it doesn't sit alongside them. - composeTestRule.onNodeWithTag("AuthMethodPicker LazyColumn").assertDoesNotExist() + composeTestRule.onNodeWithTag(FirebaseAuthTestTags.MethodPicker.PROVIDER_LIST).assertDoesNotExist() } @Test @@ -246,7 +247,7 @@ class FirebaseAuthScreenSlotsTest { composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("custom_reauth_picker").assertIsDisplayed() - composeTestRule.onNodeWithTag("AuthMethodPicker LazyColumn").assertDoesNotExist() + composeTestRule.onNodeWithTag(FirebaseAuthTestTags.MethodPicker.PROVIDER_LIST).assertDoesNotExist() } // ============================================================================================= diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUIModifierTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUIModifierTest.kt new file mode 100644 index 000000000..1427bb244 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUIModifierTest.kt @@ -0,0 +1,112 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.screens.email + +import android.content.Context +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Modifier-contract tests for [SignInEmailLinkUI], which applied the screen-level `modifier` a + * second time to the "trouble signing in" label, the same defect [SignInUI] had. + * + * @suppress Internal test class + */ +@Config(manifest = Config.NONE, sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class SignInEmailLinkUIModifierTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var applicationContext: Context + private lateinit var stringProvider: AuthUIStringProvider + + @Before + fun setUp() { + applicationContext = ApplicationProvider.getApplicationContext() + stringProvider = DefaultAuthUIStringProvider(applicationContext) + } + + private fun setContent(modifier: Modifier) { + val provider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + } + + composeTestRule.setContent { + CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { + SignInEmailLinkUI( + modifier = modifier, + configuration = configuration, + isLoading = false, + emailSignInLinkSent = false, + email = "", + onEmailChange = { }, + onSignInWithEmailLink = { }, + onGoToSignIn = { }, + onGoToResetPassword = { }, + ) + } + } + } + + @Test + fun `caller modifier does not reach the trouble signing in label`() { + setContent(modifier = Modifier.testTag(CALLER_TAG)) + + composeTestRule + .onNode( + hasTestTag(CALLER_TAG) and hasText(stringProvider.troubleSigningIn), + useUnmergedTree = true + ) + .assertDoesNotExist() + } + + @Test + fun `caller modifier is applied to exactly one node`() { + setContent(modifier = Modifier.testTag(CALLER_TAG)) + + composeTestRule + .onAllNodesWithTag(CALLER_TAG, useUnmergedTree = true) + .assertCountEquals(1) + } + + private companion object { + const val CALLER_TAG = "caller_supplied_tag" + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignInUITest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignInUITest.kt index 6a3775287..ac6a5b784 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignInUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignInUITest.kt @@ -16,10 +16,15 @@ package com.firebase.ui.auth.ui.screens.email import android.content.Context import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithText import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.configuration.authUIConfiguration @@ -170,4 +175,67 @@ class SignInUITest { composeTestRule.onNodeWithText("user@example.com").assertDoesNotExist() } + + // ---- Modifier contract tests ---- + + private fun setSignInUIContent(modifier: Modifier) { + val provider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + } + + composeTestRule.setContent { + CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { + SignInUI( + modifier = modifier, + configuration = configuration, + isLoading = false, + emailSignInLinkSent = false, + email = "", + password = "", + onEmailChange = { }, + onPasswordChange = { }, + onSignInClick = { }, + onRetrievedCredential = { }, + onGoToEmailLinkSignIn = { }, + onGoToSignUp = { }, + onGoToResetPassword = { }, + ) + } + } + } + + /** + * The screen's `modifier` used to also land on the "trouble signing in" `Text`; it must reach + * only the screen root. + */ + @Test + fun `caller modifier does not reach the trouble signing in label`() { + setSignInUIContent(modifier = Modifier.testTag(CALLER_TAG)) + + composeTestRule + .onNode( + hasTestTag(CALLER_TAG) and hasText(stringProvider.troubleSigningIn), + useUnmergedTree = true + ) + .assertDoesNotExist() + } + + /** The corollary: exactly one node carries what the caller passed. */ + @Test + fun `caller modifier is applied to exactly one node`() { + setSignInUIContent(modifier = Modifier.testTag(CALLER_TAG)) + + composeTestRule + .onAllNodesWithTag(CALLER_TAG, useUnmergedTree = true) + .assertCountEquals(1) + } + + private companion object { + const val CALLER_TAG = "caller_supplied_tag" + } } diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreenModifierTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreenModifierTest.kt new file mode 100644 index 000000000..4d50327f6 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreenModifierTest.kt @@ -0,0 +1,142 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.screens.phone + +import android.content.Context +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Tests that [PhoneAuthScreen] honours its `modifier` contract, which used to be dead: declared + * but never applied on either the content-slot or default-UI rendering path. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [34]) +class PhoneAuthScreenModifierTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var context: Context + private lateinit var authUI: FirebaseAuthUI + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + FirebaseAuthUI.clearInstanceCache() + FirebaseApp.getApps(context).forEach { it.delete() } + FirebaseApp.initializeApp( + context, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + authUI = FirebaseAuthUI.getInstance() + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + FirebaseApp.getApps(context).forEach { + try { it.delete() } catch (_: Exception) {} + } + } + + private fun phoneOnlyConfiguration(): AuthUIConfiguration = authUIConfiguration { + context = this@PhoneAuthScreenModifierTest.context + providers { + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ) + ) + } + } + + private fun setContent( + modifier: Modifier, + content: @Composable ((PhoneAuthContentState) -> Unit)? = null, + ) { + val configuration = phoneOnlyConfiguration() + composeTestRule.setContent { + CompositionLocalProvider( + LocalAuthUIStringProvider provides DefaultAuthUIStringProvider(context) + ) { + PhoneAuthScreen( + context = context, + configuration = configuration, + authUI = authUI, + onSuccess = { }, + onError = { }, + onCancel = { }, + modifier = modifier, + content = content, + ) + } + } + } + + @Test + fun `caller modifier reaches the rendered tree on the default content path`() { + setContent(modifier = Modifier.testTag(CALLER_TAG)) + + composeTestRule + .onAllNodesWithTag(CALLER_TAG, useUnmergedTree = true) + .assertCountEquals(1) + } + + @Test + fun `caller modifier reaches the rendered tree on the content slot path`() { + setContent(modifier = Modifier.testTag(CALLER_TAG)) { state -> + Text(text = "step: ${state.step}") + } + + composeTestRule + .onAllNodesWithTag(CALLER_TAG, useUnmergedTree = true) + .assertCountEquals(1) + } + + private companion object { + const val CALLER_TAG = "caller_supplied_tag" + } +} diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/ui/AccessibilityTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/ui/AccessibilityTest.kt index f7ca19e25..82ac3e8bd 100644 --- a/e2eTest/src/test/java/com/firebase/ui/auth/ui/AccessibilityTest.kt +++ b/e2eTest/src/test/java/com/firebase/ui/auth/ui/AccessibilityTest.kt @@ -10,7 +10,6 @@ import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertContentDescriptionEquals import androidx.compose.ui.test.hasContentDescription -import androidx.compose.ui.test.hasSetTextAction import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText @@ -64,11 +63,15 @@ class AccessibilityTest { @Test fun verificationCodeInputField_rendersCorrectly() { composeTestRule.setContent { - VerificationCodeInputField( - codeLength = 6, - onCodeComplete = {}, - onCodeChange = {} - ) + CompositionLocalProvider( + LocalAuthUIStringProvider provides stringProvider + ) { + VerificationCodeInputField( + codeLength = 6, + onCodeComplete = {}, + onCodeChange = {} + ) + } } // Verify the verification code field renders @@ -76,6 +79,39 @@ class AccessibilityTest { composeTestRule.waitForIdle() } + @Test + fun verificationCodeInputField_digitBoxesHaveDistinctPositionalDescriptions() { + val codeLength = 6 + + composeTestRule.setContent { + CompositionLocalProvider( + LocalAuthUIStringProvider provides stringProvider + ) { + VerificationCodeInputField( + codeLength = codeLength, + onCodeComplete = {}, + onCodeChange = {} + ) + } + } + + // Each box's description must be distinct and positional, not the single shared literal + // all six boxes used to carry. + val descriptions = (1..codeLength).map { position -> + stringProvider.verificationCodeDigitDescription(position, codeLength) + } + + assert(descriptions.toSet().size == codeLength) { + "Expected $codeLength distinct digit descriptions, got: $descriptions" + } + + descriptions.forEach { description -> + composeTestRule + .onNodeWithContentDescription(description) + .assertExists() + } + } + @Test fun authTextField_email_rendersCorrectly() { composeTestRule.setContent { diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/CredentialLinkingScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/CredentialLinkingScreenTest.kt index a862a4e57..8702c0839 100644 --- a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/CredentialLinkingScreenTest.kt +++ b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/CredentialLinkingScreenTest.kt @@ -16,7 +16,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled -import androidx.compose.ui.test.hasSetTextAction import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag @@ -43,6 +42,7 @@ import com.firebase.ui.auth.testutil.awaitWithLooper import com.firebase.ui.auth.testutil.ensureFreshUser import com.firebase.ui.auth.testutil.generateMockGoogleIdToken import com.firebase.ui.auth.testutil.verifyEmailInEmulator +import com.firebase.ui.auth.ui.FirebaseAuthTestTags import com.firebase.ui.auth.util.CountryUtils import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import com.google.common.truth.Truth.assertThat @@ -246,11 +246,13 @@ class CredentialLinkingScreenTest { // Step 6: Enter verification code println("TEST: Entering verification code: $phoneCode") - val textFields = composeTestRule.onAllNodes(hasSetTextAction()) - phoneCode.forEachIndexed { index, digit -> - composeTestRule.waitForIdle() - textFields[index].performTextInput(digit.toString()) - } + // The whole code goes in via the published tag in one call, rather than selecting boxes + // positionally out of onAllNodes(hasSetTextAction()). + composeTestRule.waitForIdle() + composeTestRule + .onNodeWithTag(FirebaseAuthTestTags.VerificationCode.CODE_FIELD) + .performTextInput(phoneCode) + composeTestRule.waitForIdle() composeTestRule.onNodeWithText(stringProvider.verifyPhoneNumber.uppercase()) .performScrollTo() @@ -371,7 +373,7 @@ class CredentialLinkingScreenTest { // Step 5: Click the Google sign-in button on the method picker println("TEST: Clicking Google sign-in button...") composeTestRule - .onNodeWithTag("AuthMethodPicker LazyColumn") + .onNodeWithTag(FirebaseAuthTestTags.MethodPicker.PROVIDER_LIST) .performScrollToNode(hasText(stringProvider.signInWithGoogle)) composeTestRule .onNode(hasText(stringProvider.signInWithGoogle)) diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/GoogleAuthScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/GoogleAuthScreenTest.kt index 942d70678..1f8faceb5 100644 --- a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/GoogleAuthScreenTest.kt +++ b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/GoogleAuthScreenTest.kt @@ -40,6 +40,7 @@ import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringPro import com.firebase.ui.auth.testutil.AUTH_STATE_WAIT_TIMEOUT_MS import com.firebase.ui.auth.testutil.EmulatorAuthApi import com.firebase.ui.auth.testutil.generateMockGoogleIdToken +import com.firebase.ui.auth.ui.FirebaseAuthTestTags import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp @@ -253,7 +254,7 @@ class GoogleAuthScreenTest { // Step 3: Click the Google sign-in button on the method picker println("TEST: Scrolling to Google sign-in button...") composeTestRule - .onNodeWithTag("AuthMethodPicker LazyColumn") + .onNodeWithTag(FirebaseAuthTestTags.MethodPicker.PROVIDER_LIST) .performScrollToNode(hasText(stringProvider.signInWithGoogle)) println("TEST: Clicking Google sign-in button...") @@ -363,7 +364,7 @@ class GoogleAuthScreenTest { // Scroll to the Google sign-in button composeTestRule - .onNodeWithTag("AuthMethodPicker LazyColumn") + .onNodeWithTag(FirebaseAuthTestTags.MethodPicker.PROVIDER_LIST) .performScrollToNode(hasText(stringProvider.signInWithGoogle)) // Click the actual Google sign-in button diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/PhoneAuthScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/PhoneAuthScreenTest.kt index 2bb006dfa..fe21f60dd 100644 --- a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/PhoneAuthScreenTest.kt +++ b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/PhoneAuthScreenTest.kt @@ -9,7 +9,6 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertTextContains -import androidx.compose.ui.test.hasSetTextAction import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription @@ -34,6 +33,7 @@ import com.firebase.ui.auth.testutil.EmulatorAuthApi import com.firebase.ui.auth.testutil.awaitWithLooper import com.firebase.ui.auth.testutil.ensureFreshUser import com.firebase.ui.auth.testutil.verifyEmailInEmulator +import com.firebase.ui.auth.ui.FirebaseAuthTestTags import com.firebase.ui.auth.ui.screens.phone.EnterPhoneNumberUI import com.firebase.ui.auth.ui.screens.phone.EnterVerificationCodeUI import com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen @@ -172,7 +172,7 @@ class PhoneAuthScreenTest { .performClick() composeTestRule.waitForIdle() // Select country from list - composeTestRule.onNodeWithTag("CountrySelector LazyColumn") + composeTestRule.onNodeWithTag(FirebaseAuthTestTags.CountrySelector.COUNTRY_LIST) .assertIsDisplayed() .performScrollToNode(hasText(country.name)) composeTestRule.onNodeWithText(country.name) @@ -226,12 +226,12 @@ class PhoneAuthScreenTest { // Check current page is Verify Phone Number & Enter verification code composeTestRule.onNodeWithText(stringProvider.verifyPhoneNumber) - val textFields = composeTestRule.onAllNodes(hasSetTextAction()) - // Enter each digit into its corresponding field - phoneCode.forEachIndexed { index, digit -> - composeTestRule.waitForIdle() - textFields[index].performTextInput(digit.toString()) - } + // The whole code goes in via the published tag in one call, rather than selecting boxes + // positionally out of onAllNodes(hasSetTextAction()). + composeTestRule.waitForIdle() + composeTestRule + .onNodeWithTag(FirebaseAuthTestTags.VerificationCode.CODE_FIELD) + .performTextInput(phoneCode) composeTestRule.waitForIdle() // Submit verification code composeTestRule.onNodeWithText(stringProvider.verifyPhoneNumber.uppercase()) @@ -497,11 +497,13 @@ class PhoneAuthScreenTest { // Step 5: enter verification code println("TEST: Entering verification code: $phoneCode") - val textFields = composeTestRule.onAllNodes(hasSetTextAction()) - phoneCode.forEachIndexed { index, digit -> - composeTestRule.waitForIdle() - textFields[index].performTextInput(digit.toString()) - } + // The whole code goes in via the published tag in one call, rather than selecting boxes + // positionally out of onAllNodes(hasSetTextAction()). + composeTestRule.waitForIdle() + composeTestRule + .onNodeWithTag(FirebaseAuthTestTags.VerificationCode.CODE_FIELD) + .performTextInput(phoneCode) + composeTestRule.waitForIdle() composeTestRule.onNodeWithText(stringProvider.verifyPhoneNumber.uppercase()) .performScrollTo()