From 3e9e3e185ddeb2a0b533d72a472fd16e95207bd6 Mon Sep 17 00:00:00 2001 From: demolaf Date: Mon, 17 Aug 2026 13:20:46 +0100 Subject: [PATCH 01/16] refactor(auth): centralize compose test tags in FirebaseAuthTestTags --- .../ui/auth/ui/FirebaseAuthTestTags.kt | 66 ++++++ .../ui/auth/ui/components/CountrySelector.kt | 3 +- .../auth/ui/method_picker/AuthMethodPicker.kt | 5 +- .../ui/auth/ui/FirebaseAuthTestTagsTest.kt | 194 ++++++++++++++++++ .../auth/ui/components/CountrySelectorTest.kt | 135 ++++++++++++ .../ui/method_picker/AuthMethodPickerTest.kt | 31 ++- .../ui/screens/FirebaseAuthScreenSlotsTest.kt | 7 +- .../ui/screens/CredentialLinkingScreenTest.kt | 3 +- .../auth/ui/screens/GoogleAuthScreenTest.kt | 5 +- .../ui/auth/ui/screens/PhoneAuthScreenTest.kt | 3 +- 10 files changed, 437 insertions(+), 15 deletions(-) create mode 100644 auth/src/main/java/com/firebase/ui/auth/ui/FirebaseAuthTestTags.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/ui/FirebaseAuthTestTagsTest.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/ui/components/CountrySelectorTest.kt 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..f1fca1c25 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/FirebaseAuthTestTags.kt @@ -0,0 +1,66 @@ +/* + * 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 values are **public API**. Host applications reference them from their own UI tests, and + * they are intended to be surfaced as Android resource ids so that Firebase Test Lab Robo + * directives and Play pre-launch reports can target the auth surfaces. Renaming or removing a + * constant — or changing the string a constant resolves to — is a **breaking change**, not an + * internal refactor. + * + * Constants are grouped by the screen or surface that owns them, and every value repeats that + * surface as a segment so a `By.res` prefix match selects exactly one surface. Within a group the + * element type is the **last** token of both the constant name and the value, so sibling nodes sort + * and complete together. + * + * Values are lowercase `snake_case` with a `fui_` prefix. The prefix is not decoration: once these + * tags are exposed as resource ids they land in the host application's `id` namespace, so it + * namespaces our tags away from the host app's own resource ids and keeps `By.res()` lookups + * unambiguous. + * + * ## Usage Example: + * + * ```kotlin + * composeTestRule + * .onNodeWithTag(FirebaseAuthTestTags.MethodPicker.PROVIDER_LIST) + * .performScrollToNode(hasText("Sign in with Google")) + * ``` + * + * @since 10.0.0 + */ +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" + } +} 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..602bdd9be 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,7 @@ 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.util.CountryUtils import kotlinx.coroutines.launch @@ -165,7 +166,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/method_picker/AuthMethodPicker.kt b/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt index 26c2feaed..476f30925 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,6 +43,7 @@ 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.util.SignInPreferenceManager @@ -144,7 +145,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 +240,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/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..c09ee7a48 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/FirebaseAuthTestTagsTest.kt @@ -0,0 +1,194 @@ +/* + * 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 the invariants [FirebaseAuthTestTags] documents, by reflecting over the whole registry + * — the root object and every nested grouping object — rather than over a hand-maintained list. + * + * The registry is a plain Kotlin object of [String] constants, so these tests need no Android + * runtime and run on plain JUnit. + * + * @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, in both directions: the surface prefix asserted below + // is read from segments[1], so a second nesting level would leave it ambiguous which + // of the two enclosing groups a value has to repeat. + 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, so the scan above + // would miss it and it would escape every invariant in this class. Read it through its + // getter instead, skipping the getters that back the fields already collected. + 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 the `snake_case` surface segment its tag values repeat. + * A run of capitals stays one segment, so `OAuthProvider` becomes `oauth_provider` rather than + * `o_auth_provider`. + */ + 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/components/CountrySelectorTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/components/CountrySelectorTest.kt new file mode 100644 index 000000000..e15b047b4 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/components/CountrySelectorTest.kt @@ -0,0 +1,135 @@ +/* + * 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 tags the country list once the bottom sheet is open`() { + setCountrySelectorContent() + + composeTestRule.onNodeWithContentDescription("Country selector").performClick() + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithTag(FirebaseAuthTestTags.CountrySelector.COUNTRY_LIST) + .assertIsDisplayed() + } + + @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/method_picker/AuthMethodPickerTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPickerTest.kt index 870e71132..c7fd3c418 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,29 @@ class AuthMethodPickerTest { ) } + // assertHasClickAction is the only check in this suite that catches the tag drifting onto a + // non-clickable wrapper around the button: the click tests inject raw touch events, which + // still hit-test through an untagged wrapper to the button beneath. Keep it. 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 +539,7 @@ class AuthMethodPickerTest { } composeTestRule - .onNodeWithTag("ContinueAsButton") + .onNodeWithTag(FirebaseAuthTestTags.MethodPicker.CONTINUE_AS_BUTTON) .assertDoesNotExist() } @@ -549,7 +570,7 @@ class AuthMethodPickerTest { } composeTestRule - .onNodeWithTag("ContinueAsButton") + .onNodeWithTag(FirebaseAuthTestTags.MethodPicker.CONTINUE_AS_BUTTON) .performClick() Truth.assertThat(continueAsProvider).isEqualTo(emailProvider) @@ -578,7 +599,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/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/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..f988398ae 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 @@ -43,6 +43,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 @@ -371,7 +372,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..7f1661da9 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 @@ -34,6 +34,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 +173,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) From af6131d9d6c625e8a35fcfde1b32d0f2e2d45513 Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 18 Aug 2026 02:55:56 +0100 Subject: [PATCH 02/16] test(auth): reject compose test tags declared outside the registry --- .../ui/auth/ui/MainSourceTestTagUsageTest.kt | 260 ++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 auth/src/test/java/com/firebase/ui/auth/ui/MainSourceTestTagUsageTest.kt 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..5846313b0 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/MainSourceTestTagUsageTest.kt @@ -0,0 +1,260 @@ +/* + * 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 + +/** + * Closes the one-sided gap left by [FirebaseAuthTestTagsTest]. + * + * That class reflects over the registry and so only polices tags that already opted in. A + * contributor writing `Modifier.testTag("Email Field")` inline in a main source file satisfies the + * compiler, keeps the `:auth` suite green, and still ships a tag that cannot be addressed as an + * Android resource id — which is the entire reason the registry exists. This class scans the + * `auth/src/main` Kotlin sources instead of the compiled registry and fails the build when a tag is + * applied without going through [FirebaseAuthTestTags]. + * + * A source scan is used rather than a custom lint rule because it needs no new module, no lint API + * surface, and no UAST plumbing to answer a purely lexical question, and it runs inside the unit + * test task that already gates every change. + * + * It lives in its own class rather than in [FirebaseAuthTestTagsTest] because it tests a different + * thing by a different mechanism: the registry test asserts properties of compiled constants, while + * this one reads files off disk and therefore carries a failure mode of its own — a scan that + * finds nothing and passes. Keeping them apart keeps that vacuity guard from reading as noise + * inside the registry invariants. + * + * @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() + } + + /** + * Guards this class against the failure mode that would make it worthless: resolving no source + * tree, or the wrong one, scanning nothing, and reporting success. Every step of the scan is + * pinned, so a moved module or a relocated source set breaks the build loudly instead of + * quietly disarming the assertion above. + */ + @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 $EXPECTED_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 count is pinned on purpose. If you added or removed a tag, update " + + "EXPECTED_TAG_CALL_SITES in this class to match. If you did not, the scan is " + + "reaching a different set of files than it should and the check above has " + + "quietly stopped covering the ones it misses." + ).that(callSites).hasSize(EXPECTED_TAG_CALL_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, then update EXPECTED_TAG_CALL_SITES in this class if the number " + + "of tagged nodes changed.\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) } + } + + /** + * Locates `auth/src/main/java` without trusting the working directory. + * + * Gradle runs unit tests with the module directory as the working directory, but that is a + * default rather than a guarantee, and the same test may be launched from an IDE or from the + * repository root. So the module-relative and repository-relative paths are both tried at the + * working directory and at each of its ancestors, and a candidate only counts once + * [FirebaseAuthTestTags] itself is found inside it. Using the registry file as the sentinel + * means resolution cannot silently land on some unrelated `src/main/java`. + */ + 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 -> + File(candidate, REGISTRY_RELATIVE_PATH).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_PATH 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 = ...` semantics assignments in [file]. + * + * Whole-line comments are blanked before matching, so a call shown in KDoc is not reported, + * while line numbering stays intact. Arguments are read with a paren-balancing scan that + * ignores delimiters inside string literals, so a multi-line call site is captured whole. + */ + private fun tagCallSitesIn(file: File, root: File): List { + val relativePath = file.toRelativeString(root).replace(File.separatorChar, '/') + val source = file.readLines().joinToString("\n") { line -> + if (COMMENT_PREFIXES.any { line.trimStart().startsWith(it) }) "" else line + } + + return TAG_APPLICATION_PATTERN.findAll(source).map { match -> + val delimiterIndex = match.range.last + val argument = if (source[delimiterIndex] == '(') { + balancedArgument(source, delimiterIndex) + } else { + source.substring(delimiterIndex + 1).substringBefore('\n') + } + + TagCallSite( + path = relativePath, + line = source.take(match.range.first).count { it == '\n' } + 1, + argument = argument.replace(WHITESPACE_RUN, " ").trim() + ) + }.toList() + } + + /** Reads the text between the paren at [openIndex] and its match, string literals included. */ + private fun balancedArgument(source: String, openIndex: Int): String { + var depth = 0 + var index = openIndex + var inString = false + while (index < source.length) { + val character = source[index] + when { + inString && character == '\\' -> index++ + character == '"' -> inString = !inString + inString -> Unit + character == '(' -> depth++ + character == ')' -> { + depth-- + if (depth == 0) return source.substring(openIndex + 1, index) + } + } + index++ + } + // Unbalanced source would not compile; report what is there so the site is still named. + return source.substring(openIndex + 1) + } + + /** One `testTag` application in a main source file. */ + private data class TagCallSite(val path: String, val line: Int, val argument: String) + + private companion object { + /** + * Number of `testTag` applications in `auth/src/main`. Pinned so that a scan which stops + * reaching files announces itself. Update it deliberately when tagging a new node. + */ + const val EXPECTED_TAG_CALL_SITES = 3 + + const val REGISTRY_REFERENCE_PREFIX = "FirebaseAuthTestTags." + + const val REGISTRY_RELATIVE_PATH = "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/java" + + /** Working directory is the repository root, as when launched from an IDE run config. */ + const val REPOSITORY_RELATIVE_MAIN_SOURCES = "auth/src/main/java" + + const val MAX_ANCESTOR_WALK = 6 + + const val KOTLIN_EXTENSION = "kt" + + val COMMENT_PREFIXES = listOf("//", "*", "/*") + + /** + * `testTag(` as a call, or `testTag =` as a `SemanticsPropertyReceiver` assignment. Both + * apply a tag, so both are checked; the trailing delimiter says which form matched. + */ + val TAG_APPLICATION_PATTERN = Regex("""\btestTag\s*[(=]""") + + val WHITESPACE_RUN = Regex("""\s+""") + } +} From 557c992569ea0bd9498528bb94104a3eb9a3e600 Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 18 Aug 2026 03:11:29 +0100 Subject: [PATCH 03/16] fix(auth): apply caller modifier once at each composable root --- .../auth/ui/components/AuthProviderButton.kt | 3 +- .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 9 +- .../ui/screens/email/SignInEmailLinkUI.kt | 1 - .../ui/auth/ui/screens/email/SignInUI.kt | 1 - .../ui/components/AuthProviderButtonTest.kt | 103 ++++++++ .../screens/FirebaseAuthScreenModifierTest.kt | 225 ++++++++++++++++++ .../email/SignInEmailLinkUIModifierTest.kt | 115 +++++++++ .../ui/auth/ui/screens/email/SignInUITest.kt | 71 ++++++ 8 files changed, 522 insertions(+), 6 deletions(-) create mode 100644 auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenModifierTest.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUIModifierTest.kt 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..36699ee3e 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 @@ -118,7 +119,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/screens/FirebaseAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt index 14e0965f7..e8dfeee77 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 @@ -103,6 +103,9 @@ 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] that hosts the whole auth flow. It is not + * forwarded to individual destinations, so it affects the flow as a whole rather than any single + * screen. * @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,7 +205,7 @@ fun FirebaseAuthScreen( LocalAuthUITheme provides (configuration.theme ?: LocalAuthUITheme.current) ) { Surface( - modifier = Modifier + modifier = modifier .fillMaxSize() ) { NavHost( @@ -223,13 +226,13 @@ fun FirebaseAuthScreen( ) { composable(AuthRoute.MethodPicker.route) { if (customMethodPickerLayout != null) { - Box(modifier = modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize()) { customMethodPickerLayout(configuration.providers, onProviderSelected) } } else { Scaffold { innerPadding -> AuthMethodPicker( - modifier = modifier + modifier = Modifier .padding(innerPadding), providers = configuration.providers, logo = logoAsset, 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..0339ad059 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 @@ -172,7 +172,6 @@ fun SignInEmailLinkUI( contentPadding = PaddingValues.Zero ) { Text( - modifier = modifier, text = stringProvider.troubleSigningIn, style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, 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..5a726131d 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 @@ -209,7 +209,6 @@ fun SignInUI( contentPadding = PaddingValues.Zero ) { Text( - modifier = modifier, text = stringProvider.troubleSigningIn, style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, 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..605e06edf 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 @@ -546,4 +554,99 @@ 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 its `modifier` to exactly one node, its outermost one. This button + * used to hand the same instance to both the [androidx.compose.material3.Button] and the inner + * content [androidx.compose.foundation.layout.Row], which duplicated everything the caller + * passed: a `testTag` landed on two nodes, and padding was applied twice. + * + * The unmerged tree is what matters here. `TestTag`'s merge policy keeps the ancestor's value, + * so the duplicate collapses to a single node in the merged tree and is invisible to an + * ordinary `onNodeWithTag` lookup — while still being two real nodes, and so two Android + * resource ids once `testTagsAsResourceId` is enabled. + */ + @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 one node the caller's modifier reaches is the button itself, not the content row: the + * tagged node has to be the clickable one. + */ + @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 now owns its own width instead of inheriting the caller's, so a full-width + * button still lays its icon and label out from the start edge rather than centring them. + * This pins the rendered layout that the duplicated modifier used to produce by accident. + */ + @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/screens/FirebaseAuthScreenModifierTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenModifierTest.kt new file mode 100644 index 000000000..19479538d --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenModifierTest.kt @@ -0,0 +1,225 @@ +/* + * 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.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.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.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] honours the Compose modifier contract: the caller's `modifier` + * is applied once, to the composable's own outermost node. + * + * The screen used to ignore its `modifier` at the root — the hosting `Surface` hardcoded + * `Modifier.fillMaxSize()` — and forward the caller's instance into individual `NavHost` + * destinations instead. That made the parameter mean "decorate whichever screen happens to be + * showing", which is both surprising and incomplete: destinations other than the method picker + * never received it at all, so a caller could not decorate the flow as a whole. In particular a + * host application could not attach `semantics { testTagsAsResourceId = true }` through the public + * API, because no destination-level modifier reaches the root. + * + * @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() + ) + ) + } + } + + 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. When the flow starts somewhere other than the method picker, the old code + * dropped the caller's modifier entirely — there was no `modifier` forwarding on any route + * except `MethodPicker`, and the root `Surface` used a fresh `Modifier`. So this found zero + * nodes before the fix and finds exactly one after it. + */ + @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) + } + + /** + * Regression guard for the method-picker route, which is the one route that did receive the + * caller's modifier. It must now be tagged once, at the root, rather than on the picker's own + * `Column`. This asserts the count only, so it held before the fix as well; the routing case + * above is what pins the change. + */ + @Test + fun `caller modifier is applied once on the method picker route`() { + setContent(methodPickerConfiguration(), Modifier.testTag(CALLER_TAG)) + + composeTestRule + .onAllNodesWithTag(CALLER_TAG, useUnmergedTree = true) + .assertCountEquals(1) + } + + /** + * The custom method-picker slot used to take the caller's modifier on its wrapping `Box`; it + * now sits under the tagged root instead. Both arrangements satisfy these assertions, so this + * is a guard against the modifier being duplicated or dropped on this path rather than a pin + * on the change itself. + */ + @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) + } + + private companion object { + const val CALLER_TAG = "caller_supplied_tag" + + const val SENTINEL_TAG = "destination_content_sentinel" + } +} 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..90edfeee6 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUIModifierTest.kt @@ -0,0 +1,115 @@ +/* + * 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]. + * + * This screen carried the same defect as [SignInUI]: the screen-level `modifier` was correctly + * applied to the `Scaffold` and then applied a second time to the "trouble signing in" label, so a + * caller's sizing, padding or tag reached a leaf it was never meant to touch. + * + * @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..61cca5e3e 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,70 @@ 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 be handed to the "trouble signing in" `Text` as well as to + * the `Scaffold`, so a caller's sizing, padding or tag silently landed on a single label deep + * inside the layout. It must reach the screen root and nothing else. + */ + @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" + } } From f96e54b4e010439019207625d7463783a58ed6e9 Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 18 Aug 2026 03:25:38 +0100 Subject: [PATCH 04/16] fix(auth): apply PhoneAuthScreen's declared modifier to its content --- .../auth/ui/screens/phone/PhoneAuthScreen.kt | 31 ++-- .../phone/PhoneAuthScreenModifierTest.kt | 148 ++++++++++++++++++ 2 files changed, 168 insertions(+), 11 deletions(-) create mode 100644 auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreenModifierTest.kt 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..6a4ccac88 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 @@ -113,7 +114,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. + * custom UI via a trailing lambda (slot). It contributes no UI of its own beyond the layout node + * that hosts the rendered content. * * @param context The Android context. * @param configuration The authentication UI configuration containing the phone provider settings. @@ -121,9 +123,11 @@ 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] that hosts the rendered content — the [content] slot + * when one is supplied, otherwise the default per-step UI. That box propagates its incoming minimum + * constraints, so it does not change how the 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 +413,19 @@ fun PhoneAuthScreen( } ) - if (content != null) { - content(state) - } else { - DefaultPhoneAuthContent( - configuration = configuration, - state = state, - onCancel = onCancel - ) + // The caller's modifier is applied exactly once, here, to this composable's outermost node. + // `propagateMinConstraints` keeps the box layout-neutral: the content is measured with the same + // constraints it received before the box existed. + Box(modifier = modifier, propagateMinConstraints = true) { + if (content != null) { + content(state) + } else { + DefaultPhoneAuthContent( + configuration = configuration, + state = state, + onCancel = onCancel + ) + } } } 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..2a4156e08 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreenModifierTest.kt @@ -0,0 +1,148 @@ +/* + * 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 the Compose modifier contract. + * + * The screen declared a `modifier` parameter and documented it as "Optional [Modifier] for the + * composable", but never applied it: the composable rendered either the caller's `content` slot, + * which takes no modifier, or the default per-step UI, which had no `modifier` parameter. The + * parameter was therefore dead, and any caller sizing, padding or tagging the screen through it was + * silently ignored. The screen now applies it once, to the outermost node it introduces for the + * rendered content, so the same instance takes effect on both branches. + * + * @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" + } +} From 446211de2a751f0769698a62fe10af0c9db79cff Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 18 Aug 2026 04:25:39 +0100 Subject: [PATCH 05/16] fix(auth): correct modifier scope docs and harden the test tag scan --- .../auth/ui/components/AuthProviderButton.kt | 5 +- .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 11 +- .../ui/auth/ui/MainSourceTestTagUsageTest.kt | 294 +++++++++++++++--- .../ui/components/AuthProviderButtonTest.kt | 5 + .../screens/FirebaseAuthScreenModifierTest.kt | 64 ++++ 5 files changed, 330 insertions(+), 49 deletions(-) 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 36699ee3e..face71d97 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 @@ -67,7 +67,10 @@ import com.firebase.ui.auth.configuration.theme.ProviderStyleDefaults * ) * ``` * - * @param modifier A modifier for the button + * @param modifier Applied to the button itself, and to nothing else — the content row sizes + * independently. Note that the content row fills the available width unconditionally, so the button + * renders full-width whatever width this modifier asks for; constrain it from the parent layout + * instead (for example by placing the button in a fixed-width container). * @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. 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 e8dfeee77..3da704298 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 @@ -103,9 +103,14 @@ 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] that hosts the whole auth flow. It is not - * forwarded to individual destinations, so it affects the flow as a whole rather than any single - * screen. + * @param modifier Applied once to the root [Surface] that hosts the flow's navigation graph. It is + * not forwarded to individual destinations, so it decorates whichever destination is showing rather + * than any single screen. Its reach stops at this composable's own window: content that Compose + * hosts in a separate semantics owner — every dialog and bottom sheet the flow shows, including the + * default reauthentication sheet and the phone number country selector — is not a descendant of + * this [Surface] and is not affected. So passing `Modifier.semantics { testTagsAsResourceId = true }` + * here exposes the navigation destinations' test tags as resource ids but not the tags applied + * inside those sheets. * @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). 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 index 5846313b0..9796b6992 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/MainSourceTestTagUsageTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/MainSourceTestTagUsageTest.kt @@ -30,7 +30,20 @@ import org.junit.Test * * A source scan is used rather than a custom lint rule because it needs no new module, no lint API * surface, and no UAST plumbing to answer a purely lexical question, and it runs inside the unit - * test task that already gates every change. + * test task that already gates every change. The cost of that choice is that the scan has to do its + * own tokenising: matching `testTag` with a bare regex over raw file text reports commented-out + * code, `val testTag = "…"` bindings, and call shapes quoted inside string literals, all of which + * are correct code. So [maskCommentsAndLiterals] runs first and blanks comments and literal + * contents — length-preservingly, so offsets and line numbers still line up with the original text + * — and matches are then required to be a member access or an assignment rather than any token + * spelled `testTag`. Arguments are still read from the original text, so violations are reported + * with the literal a contributor actually wrote. + * + * What a lexical scan cannot see, it is required to refuse rather than wave through. An aliased + * import (`import …testTag as composeTestTag`) renames the call out of this scan's reach for one + * line of effort, so a separate test in this class fails on the alias itself instead of pretending + * the file is clean. Indirection through a local wrapper function remains out of reach and is + * accepted: it costs more to write than to review, and the reviewer sees the wrapper. * * It lives in its own class rather than in [FirebaseAuthTestTagsTest] because it tests a different * thing by a different mechanism: the registry test asserts properties of compiled constants, while @@ -51,6 +64,32 @@ class MainSourceTestTagUsageTest { assertWithMessage(violationMessage(violations)).that(violations).isEmpty() } + /** + * Renaming `testTag` on import puts every call through it beyond a lexical scan, so the alias + * is rejected outright. The check is deliberately blunt: there is no legitimate reason for auth + * main sources to import `testTag` under another name, and "the guard cannot analyse this" must + * not resolve to "the guard 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() + } + /** * Guards this class against the failure mode that would make it worthless: resolving no source * tree, or the wrong one, scanning nothing, and reporting success. Every step of the scan is @@ -78,14 +117,15 @@ class MainSourceTestTagUsageTest { ).that(callSites).isNotEmpty() assertWithMessage( - "Expected $EXPECTED_TAG_CALL_SITES testTag call sites in auth main sources but " + - "found ${callSites.size}:\n" + + "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 count is pinned on purpose. If you added or removed a tag, update " + - "EXPECTED_TAG_CALL_SITES in this class to match. If you did not, the scan is " + - "reaching a different set of files than it should and the check above has " + - "quietly stopped covering the ones it misses." - ).that(callSites).hasSize(EXPECTED_TAG_CALL_SITES) + "\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) } /** @@ -106,8 +146,7 @@ class MainSourceTestTagUsageTest { 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, then update EXPECTED_TAG_CALL_SITES in this class if the number " + - "of tagged nodes changed.\n\n" + + "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 " + @@ -174,64 +213,209 @@ class MainSourceTestTagUsageTest { /** * Finds `testTag(...)` calls and `testTag = ...` semantics assignments in [file]. * - * Whole-line comments are blanked before matching, so a call shown in KDoc is not reported, - * while line numbering stays intact. Arguments are read with a paren-balancing scan that - * ignores delimiters inside string literals, so a multi-line call site is captured whole. + * Matching runs over [maskCommentsAndLiterals] output, so nothing inside a comment — of either + * form, including a trailing comment on a line of live code — and nothing inside a string + * literal is treated as source. Arguments are then sliced out of the original text at the same + * offsets, because the argument is what the contributor needs to see in the failure message. */ private fun tagCallSitesIn(file: File, root: File): List { val relativePath = file.toRelativeString(root).replace(File.separatorChar, '/') - val source = file.readLines().joinToString("\n") { line -> - if (COMMENT_PREFIXES.any { line.trimStart().startsWith(it) }) "" else line + 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() + } + + /** + * Rejects the tokens that merely spell `testTag` without applying one. + * + * A call only counts when it is reached through a receiver (`Modifier.testTag(`) or is a bare + * call rather than a declaration of a function by that name; an assignment only counts when it + * is an assignment (`semantics { testTag = … }`) rather than a `val`/`var` binding or an + * equality comparison. Both were live false positives: a `val testTag = "…"` local was + * reported as a registry violation, with fix advice that made no sense for it. + */ + 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 } + } - return TAG_APPLICATION_PATTERN.findAll(source).map { match -> - val delimiterIndex = match.range.last - val argument = if (source[delimiterIndex] == '(') { - balancedArgument(source, delimiterIndex) - } else { - source.substring(delimiterIndex + 1).substringBefore('\n') - } + /** 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) + } - TagCallSite( - path = relativePath, - line = source.take(match.range.first).count { it == '\n' } + 1, - argument = argument.replace(WHITESPACE_RUN, " ").trim() - ) - }.toList() + /** 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 text between the paren at [openIndex] and its match, string literals included. */ - private fun balancedArgument(source: String, openIndex: Int): String { + /** + * Reads the [original] text between the paren at [openIndex] in [masked] and its match. + * + * Balancing runs over the masked text, where a paren inside a string literal has already been + * blanked, so a tag value such as `"(unused)"` cannot unbalance the scan. + */ + private fun balancedArgument(masked: String, original: String, openIndex: Int): String { var depth = 0 var index = openIndex - var inString = false - while (index < source.length) { - val character = source[index] - when { - inString && character == '\\' -> index++ - character == '"' -> inString = !inString - inString -> Unit - character == '(' -> depth++ - character == ')' -> { + while (index < masked.length) { + when (masked[index]) { + '(' -> depth++ + ')' -> { depth-- - if (depth == 0) return source.substring(openIndex + 1, index) + 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 source.substring(openIndex + 1) + return original.substring(openIndex + 1) + } + + /** + * Reads the right-hand side of a `testTag =` assignment whose `=` sits at [equalsIndex]. + * + * The expression may start on the following line, so leading whitespace is skipped before + * reading, and it ends at the first newline or closing bracket reached at nesting depth zero. + * Taking only the remainder of the `=` line — as this did previously — yielded an empty + * argument for a wrapped assignment, and an empty argument fails the registry-prefix check. + */ + 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) + } + + /** + * Replaces the contents of comments and of string and character literals with spaces, keeping + * the result the same length as [source] and its newlines in place so offsets and line numbers + * carry over unchanged. Literal delimiters are kept so a blanked literal still reads as one. + * + * Block comments nest, as they do in Kotlin. A string template containing a nested string + * literal (`"${'$'}{f("x")}"`) is the one shape this mis-tokenises; it garbles the region + * rather than failing, and no auth source writes one. + */ + 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) private companion object { /** - * Number of `testTag` applications in `auth/src/main`. Pinned so that a scan which stops - * reaching files announces itself. Update it deliberately when tagging a new node. + * Lower bound on the number of `testTag` applications in `auth/src/main`, so a scan that + * stops reaching files announces itself. A floor rather than an exact count: tagging more + * nodes is the expected direction of travel and should not redden an unrelated build. */ - const val EXPECTED_TAG_CALL_SITES = 3 + const val MINIMUM_TAG_CALL_SITES = 3 const val REGISTRY_REFERENCE_PREFIX = "FirebaseAuthTestTags." @@ -247,14 +431,34 @@ class MainSourceTestTagUsageTest { const val KOTLIN_EXTENSION = "kt" - val COMMENT_PREFIXES = listOf("//", "*", "/*") + 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. */ + 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 a `SemanticsPropertyReceiver` assignment. Both * apply a tag, so both are checked; the trailing delimiter says which form matched. + * [isTagApplication] then discards the matches that are neither. */ 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/components/AuthProviderButtonTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/components/AuthProviderButtonTest.kt index 605e06edf..8a994ea09 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 @@ -591,6 +591,11 @@ class AuthProviderButtonTest { /** * The one node the caller's modifier reaches is the button itself, not the content row: the * tagged node has to be the clickable one. + * + * This queries the merged tree, where `TestTag`'s merge policy keeps the ancestor's value, so + * the duplicated tag resolved to the button before the fix as well and this assertion held + * either way. It is a guard on which node owns the tag, not a pin on the change; the unmerged + * count above is what pins it. */ @Test fun `caller modifier lands on the button rather than its content`() { 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 index 19479538d..a11181911 100644 --- 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 @@ -20,17 +20,21 @@ 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 @@ -53,6 +57,10 @@ import org.robolectric.annotation.Config * host application could not attach `semantics { testTagsAsResourceId = true }` through the public * API, because no destination-level modifier reaches the root. * + * One test here asserts the opposite of the rest on purpose. "Reaches the root" is not the same as + * "reaches everything the flow shows", and the last test in this class pins where the difference + * lies so the gap is recorded rather than assumed away. + * * @suppress Internal test class */ @RunWith(RobolectricTestRunner::class) @@ -102,6 +110,20 @@ class FirebaseAuthScreenModifierTest { } } + /** 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 { @@ -217,9 +239,51 @@ class FirebaseAuthScreenModifierTest { .assertCountEquals(1) } + /** + * Pins the documented edge of the modifier's reach, and is expected to keep passing: a + * bottom sheet is a **separate semantics owner**, so its content is not a descendant of the + * root the caller's modifier lands on. + * + * This is a boundary, not a bug, and it is asserted rather than left implicit because the + * obvious reading of "the modifier reaches the root of the flow" is that it therefore covers + * everything the flow shows. It does not. `Modifier.semantics { testTagsAsResourceId = true }` + * passed into [FirebaseAuthScreen] turns the navigation destinations' tags into resource ids + * and leaves every dialog and bottom sheet untouched — including this one, which owns + * [FirebaseAuthTestTags.CountrySelector.COUNTRY_LIST], so `By.res("fui_country_selector_…")` + * still will not resolve it. Making those surfaces opt in individually is separate work; if + * this test starts failing because the sheet's content became a descendant of the root, that + * work landed and the [FirebaseAuthScreen] `modifier` KDoc needs updating with it. + */ + @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" } } From 7e3069531382131603a78f3a8a949ad38a9792b6 Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 18 Aug 2026 05:07:25 +0100 Subject: [PATCH 06/16] feat(auth): expose auth input test tags as resource ids --- .../ui/auth/ui/FirebaseAuthTestTags.kt | 124 ++++ .../ui/auth/ui/TestTagsAsResourceIds.kt | 37 ++ .../ui/auth/ui/components/CountrySelector.kt | 6 +- .../auth/ui/components/ErrorRecoveryDialog.kt | 3 +- .../ui/components/ReauthenticationDialog.kt | 11 +- .../auth/ui/method_picker/AuthMethodPicker.kt | 3 +- .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 14 +- .../auth/ui/screens/MfaChallengeDefaults.kt | 3 +- .../auth/ui/screens/MfaEnrollmentDefaults.kt | 2 + .../auth/ui/screens/email/ResetPasswordUI.kt | 13 +- .../ui/screens/email/SignInEmailLinkUI.kt | 17 +- .../ui/auth/ui/screens/email/SignInUI.kt | 18 +- .../ui/auth/ui/screens/email/SignUpUI.kt | 13 +- .../ui/screens/phone/EnterPhoneNumberUI.kt | 10 +- .../screens/phone/EnterVerificationCodeUI.kt | 19 +- .../ui/auth/ui/MainSourceTestTagUsageTest.kt | 2 +- .../ui/auth/ui/TestTagsAsResourceIdsTest.kt | 612 ++++++++++++++++++ .../screens/FirebaseAuthScreenModifierTest.kt | 19 +- 18 files changed, 895 insertions(+), 31 deletions(-) create mode 100644 auth/src/main/java/com/firebase/ui/auth/ui/TestTagsAsResourceIds.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/ui/TestTagsAsResourceIdsTest.kt 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 index f1fca1c25..506986509 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/FirebaseAuthTestTags.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/FirebaseAuthTestTags.kt @@ -63,4 +63,128 @@ object FirebaseAuthTestTags { /** 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" + } + + /** 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" + } + + /** 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" + } + + /** 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" + } + + /** 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" + } + + /** Tags on the SMS verification code screen. */ + object VerificationCode { + + /** The verification code input. */ + 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" + } + + /** + * Tags on the re-authentication dialog. + * + * Deliberately a group of its own rather than reusing [SignIn]: the re-authentication surface + * and the flow behind it are composed at the same time while the dialog is open, so a shared + * value would match two nodes and neither could be addressed. + */ + 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" + } } 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..a2cb3f143 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/TestTagsAsResourceIds.kt @@ -0,0 +1,37 @@ +/* + * 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 the [FirebaseAuthTestTags] applied beneath this node as Android resource ids, so that + * they appear as `viewIdResourceName` on the accessibility node. + * + * This is what makes the tags addressable from outside a Compose test: Firebase Test Lab Robo + * directives take resource names, Google Play pre-launch reports drive the same Robo crawler, and + * UiAutomator's `By.res()` matches on the same field. Without it a tag is visible only to Compose's + * own test APIs, which is no help to a crawler — the gap that made the 9.x `@id/email` and + * `@id/password` targets unreachable after the Compose rewrite. + * + * Apply it once per **semantics owner**, not once per screen. The flag is read by walking a node's + * semantics ancestors, and that walk stops at the root of the window the node lives in. Every dialog + * and bottom sheet Compose shows is hosted in its own window with its own semantics root, so it + * inherits nothing from the composable that opened it and has to carry the flag itself. + */ +internal fun Modifier.exposeTestTagsAsResourceIds(): Modifier = + semantics { testTagsAsResourceId = true } 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 602bdd9be..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 @@ -58,6 +58,7 @@ import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvi 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 @@ -65,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. @@ -73,6 +75,7 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun CountrySelector( + modifier: Modifier = Modifier, selectedCountry: CountryData, onCountrySelected: (CountryData) -> Unit, enabled: Boolean = true, @@ -105,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 @@ -135,6 +138,7 @@ fun CountrySelector( if (showBottomSheet) { ModalBottomSheet( + modifier = Modifier.exposeTestTagsAsResourceIds(), onDismissRequest = { showBottomSheet = false searchQuery = "" 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..b7fe2415d 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 @@ -25,6 +25,7 @@ import androidx.compose.ui.platform.LocalView 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.exposeTestTagsAsResourceIds import com.google.firebase.auth.EmailAuthProvider import com.google.firebase.auth.FacebookAuthProvider import com.google.firebase.auth.GithubAuthProvider @@ -118,7 +119,7 @@ fun ErrorRecoveryDialog( ) } }, - 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/method_picker/AuthMethodPicker.kt b/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt index 476f30925..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 @@ -45,6 +45,7 @@ import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvi 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 /** @@ -118,7 +119,7 @@ fun AuthMethodPicker( termsConfiguration.accepted Column( - modifier = modifier + modifier = modifier.exposeTestTagsAsResourceIds() ) { logo?.let { Image( 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 3da704298..cb27abd0f 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 @@ -108,9 +109,13 @@ import kotlinx.coroutines.tasks.await * than any single screen. Its reach stops at this composable's own window: content that Compose * hosts in a separate semantics owner — every dialog and bottom sheet the flow shows, including the * default reauthentication sheet and the phone number country selector — is not a descendant of - * this [Surface] and is not affected. So passing `Modifier.semantics { testTagsAsResourceId = true }` - * here exposes the navigation destinations' test tags as resource ids but not the tags applied - * inside those sheets. + * this [Surface], so a modifier passed here does not decorate it. + * + * That boundary no longer costs you the resource ids, though. Exposing + * [com.firebase.ui.auth.ui.FirebaseAuthTestTags] as Android resource ids is not something a caller + * has to arrange: the library sets `testTagsAsResourceId` itself at every semantics owner it + * creates, dialogs and bottom sheets included, so Firebase Test Lab Robo directives and + * `By.res()` resolve every published tag without any modifier being passed here. * @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). @@ -212,6 +217,7 @@ fun FirebaseAuthScreen( Surface( modifier = modifier .fillMaxSize() + .exposeTestTagsAsResourceIds() ) { NavHost( navController = navController, @@ -719,6 +725,7 @@ fun FirebaseAuthScreen( val reauthConfig = pendingReauthConfig.value if (reauthConfig != null) { ModalBottomSheet( + modifier = Modifier.exposeTestTagsAsResourceIds(), onDismissRequest = { pendingReauthOperation.value = null pendingReauthConfig.value = null @@ -937,6 +944,7 @@ private fun ProfileCompletionContent( @Composable private fun LoadingDialog(message: String) { AlertDialog( + modifier = Modifier.exposeTestTagsAsResourceIds(), onDismissRequest = {}, confirmButton = {}, containerColor = Color.Transparent, 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..d7e309e0a 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 @@ -46,6 +46,7 @@ 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.components.VerificationCodeInputField +import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds @Composable internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) { @@ -55,7 +56,7 @@ internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) { VerificationCodeValidator(stringProvider) } - Scaffold { innerPadding -> + Scaffold(modifier = Modifier.exposeTestTagsAsResourceIds()) { innerPadding -> Column( modifier = Modifier .fillMaxWidth() 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..72fc2a47a 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 @@ -59,6 +59,7 @@ import com.firebase.ui.auth.mfa.MfaEnrollmentStep import com.firebase.ui.auth.mfa.toMfaErrorMessage 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 @@ -252,6 +253,7 @@ private fun SelectFactorUI( val factorsToEnroll = availableFactors.filter { it !in enrolledFactorIds } Scaffold( + modifier = Modifier.exposeTestTagsAsResourceIds(), topBar = { TopAppBar( title = { Text(stringProvider.mfaManageFactorsTitle) }, 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..feb6c0240 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 = { @@ -140,6 +146,7 @@ fun ResetPasswordUI( .verticalScroll(rememberScrollState()), ) { AuthTextField( + modifier = Modifier.testTag(FirebaseAuthTestTags.ResetPassword.EMAIL_FIELD), value = email, validator = emailValidator, enabled = !isLoading, @@ -156,6 +163,8 @@ fun ResetPasswordUI( .align(Alignment.End), ) { Button( + modifier = Modifier + .testTag(FirebaseAuthTestTags.ResetPassword.SIGN_IN_BUTTON), onClick = { onGoToSignIn() }, @@ -165,6 +174,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 0339ad059..321df5b0b 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 = { @@ -151,6 +157,7 @@ fun SignInEmailLinkUI( .verticalScroll(rememberScrollState()), ) { AuthTextField( + modifier = Modifier.testTag(FirebaseAuthTestTags.EmailLink.EMAIL_FIELD), value = email, validator = emailValidator, enabled = !isLoading, @@ -183,7 +190,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) { @@ -214,7 +223,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 5a726131d..2956abaeb 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 = { @@ -175,6 +178,7 @@ fun SignInUI( .verticalScroll(rememberScrollState()), ) { AuthTextField( + modifier = Modifier.testTag(FirebaseAuthTestTags.SignIn.EMAIL_FIELD), value = email, validator = emailValidator, enabled = !isLoading, @@ -187,6 +191,7 @@ fun SignInUI( ) Spacer(modifier = Modifier.height(16.dp)) AuthTextField( + modifier = Modifier.testTag(FirebaseAuthTestTags.SignIn.PASSWORD_FIELD), value = password, validator = passwordValidator, enabled = !isLoading, @@ -201,7 +206,8 @@ fun SignInUI( Spacer(modifier = Modifier.height(8.dp)) TextButton( modifier = Modifier - .align(Alignment.Start), + .align(Alignment.Start) + .testTag(FirebaseAuthTestTags.SignIn.FORGOT_PASSWORD_BUTTON), onClick = { onGoToResetPassword() }, @@ -222,6 +228,8 @@ fun SignInUI( ) { if (provider.isNewAccountsAllowed) { Button( + modifier = Modifier + .testTag(FirebaseAuthTestTags.SignIn.SIGN_UP_BUTTON), onClick = { onGoToSignUp() }, @@ -232,6 +240,8 @@ fun SignInUI( Spacer(modifier = Modifier.width(16.dp)) } Button( + modifier = Modifier + .testTag(FirebaseAuthTestTags.SignIn.SIGN_IN_BUTTON), onClick = { onSignInClick() }, @@ -268,7 +278,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..72c97fdce 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 = { @@ -131,6 +134,7 @@ fun SignUpUI( ) { if (provider.isDisplayNameRequired) { AuthTextField( + modifier = Modifier.testTag(FirebaseAuthTestTags.SignUp.NAME_FIELD), value = displayName, validator = displayNameValidator, enabled = !isLoading, @@ -144,6 +148,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 +161,7 @@ fun SignUpUI( ) Spacer(modifier = Modifier.height(16.dp)) AuthTextField( + modifier = Modifier.testTag(FirebaseAuthTestTags.SignUp.PASSWORD_FIELD), value = password, validator = passwordValidator, enabled = !isLoading, @@ -169,6 +175,7 @@ fun SignUpUI( ) Spacer(modifier = Modifier.height(16.dp)) AuthTextField( + modifier = Modifier.testTag(FirebaseAuthTestTags.SignUp.CONFIRM_PASSWORD_FIELD), value = confirmPassword, validator = confirmPasswordValidator, enabled = !isLoading, @@ -186,6 +193,8 @@ fun SignUpUI( .align(Alignment.End), ) { Button( + modifier = Modifier + .testTag(FirebaseAuthTestTags.SignUp.SIGN_IN_BUTTON), onClick = { onGoToSignIn() }, @@ -195,6 +204,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..83acc2749 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 = { @@ -111,6 +114,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 +126,8 @@ fun EnterPhoneNumberUI( ), leadingIcon = { CountrySelector( + modifier = Modifier + .testTag(FirebaseAuthTestTags.PhoneNumber.COUNTRY_SELECTOR_BUTTON), selectedCountry = selectedCountry, onCountrySelected = onCountrySelected, enabled = !isLoading, @@ -139,6 +145,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..4a114183a 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 = { @@ -119,7 +122,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 +139,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 +177,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/test/java/com/firebase/ui/auth/ui/MainSourceTestTagUsageTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/MainSourceTestTagUsageTest.kt index 9796b6992..a8499f61b 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/MainSourceTestTagUsageTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/MainSourceTestTagUsageTest.kt @@ -415,7 +415,7 @@ class MainSourceTestTagUsageTest { * stops reaching files announces itself. A floor rather than an exact count: tagging more * nodes is the expected direction of travel and should not redden an unrelated build. */ - const val MINIMUM_TAG_CALL_SITES = 3 + const val MINIMUM_TAG_CALL_SITES = 33 const val REGISTRY_REFERENCE_PREFIX = "FirebaseAuthTestTags." 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..caf9b4a3a --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/TestTagsAsResourceIdsTest.kt @@ -0,0 +1,612 @@ +/* + * 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 androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ViewRootForTest +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assert +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.performClick +import androidx.compose.ui.test.performScrollTo +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.string_provider.LocalAuthUIStringProvider +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.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.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.FirebaseUser +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 + +/** + * The end-to-end check for the reason [FirebaseAuthTestTags] exists: that each published tag is + * applied to a real node on its screen, and that the node carries the tag as an Android **resource + * id** rather than only as a Compose test tag. + * + * That distinction is the whole point, and it is why these tests read `viewIdResourceName` off the + * accessibility node instead of calling `assertExists()` on a `testTag` matcher. A `testTag` is + * visible only to Compose's own test APIs. Firebase Test Lab Robo directives, the Play pre-launch + * report's crawler, and UiAutomator's `By.res()` all match on the resource name, and they see it + * only when [exposeTestTagsAsResourceIds] has been applied at the enclosing semantics owner. An + * `onNodeWithTag(...).assertExists()` passes identically with and without that modifier, so it + * cannot tell the fixed state from the bug — issue #2050, where Robo could type a username but + * never reach the password field. + * + * Two harness details are load-bearing: + * + * * `@GraphicsMode(NATIVE)`. Compose builds its accessibility node tree by subtracting each node's + * bounds from an `android.graphics.Region` of unaccounted space. Under Robolectric's legacy + * graphics that `Region` is inert, every node below the root is treated as covered, and + * `createAccessibilityNodeInfo` returns an empty node for which `viewIdResourceName` is always + * `null` — the assertions here would fail for a reason that has nothing to do with the library. + * * Scrolling a node into the window before reading it. Nodes lying outside the root's bounds are + * culled from that same tree, and these screens are taller than the test window, so a tag near + * the bottom of a screen has no accessibility node until it is scrolled to. Enlarging the window + * is the obvious alternative and is deliberately not used: at `h2400dp` an `AlertDialog` + * containing a text field — the shape [com.firebase.ui.auth.ui.components.ReauthenticationDialog] + * has — sends Robolectric's text measurement into a runaway allocation and the suite dies with an + * `OutOfMemoryError` that has nothing to do with test tags. It reproduces with stock Material 3 + * components and no test tags involved, so it is a harness limit, not a library one. + * + * Dialogs and bottom sheets are covered explicitly rather than incidentally. Each is hosted in its + * own window with its own semantics root, so it inherits nothing from the composable that opened it + * — the case with no coverage before this class existed, and the one where forgetting the modifier + * is invisible in a Compose-only assertion. + * + * @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 that exactly one node carries [tag], that it is the kind of node [expected] describes, + * and that the platform sees the tag as a resource id. + * + * The node kind is asserted alongside the tag because "the tag exists somewhere" is not the + * claim worth making — a tag parked on a `Spacer` next to the password field would satisfy it + * while leaving Robo with nothing to type into. So a field is required to accept text and a + * button to be clickable. + * + * `node.root` is read rather than assumed, so the same helper works for a dialog or bottom + * sheet: those live in a different window, and this resolves whichever window actually hosts + * the node. + */ + 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 it sits inside a scrollable, because a node + * outside the root's bounds has no accessibility node to read a resource id from. + * + * The scrollable ancestor is looked for rather than the scroll being attempted unconditionally, + * so that a genuine `performScrollTo` failure still fails the test instead of being swallowed. + */ + private fun scrollIntoWindow(tag: String) { + val hasScrollableAncestor = composeTestRule + .onAllNodes(hasScrollAction() and hasAnyDescendant(hasTestTag(tag))) + .fetchSemanticsNodes() + .isNotEmpty() + + if (hasScrollableAncestor) { + composeTestRule.onNode(hasTestTag(tag)).performScrollTo() + } + } + + 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 = "", + onEmailChange = { }, + onPasswordChange = { }, + onRetrievedCredential = { }, + onSignInClick = { }, + onGoToSignUp = { }, + onGoToResetPassword = { }, + onGoToEmailLinkSignIn = { }, + ) + } + + 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()) + } + + @Test + fun `sign up screen exposes its fields and actions`() { + setContent { + SignUpUI( + configuration = emailConfiguration(), + isLoading = false, + displayName = "", + email = "", + password = "", + confirmPassword = "", + onDisplayNameChange = { }, + onEmailChange = { }, + onPasswordChange = { }, + onConfirmPasswordChange = { }, + onGoToSignIn = { }, + onSignUpClick = { }, + ) + } + + 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()) + } + + @Test + fun `reset password screen exposes its field and actions`() { + setContent { + ResetPasswordUI( + configuration = emailConfiguration(), + isLoading = false, + email = "", + resetLinkSent = false, + onEmailChange = { }, + onSendResetLink = { }, + onGoToSignIn = { }, + ) + } + + assertExposedAsResourceId(FirebaseAuthTestTags.ResetPassword.EMAIL_FIELD, field()) + assertExposedAsResourceId(FirebaseAuthTestTags.ResetPassword.SEND_BUTTON, button()) + assertExposedAsResourceId(FirebaseAuthTestTags.ResetPassword.SIGN_IN_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 = { }, + ) + } + + assertExposedAsResourceId(FirebaseAuthTestTags.EmailLink.EMAIL_FIELD, field()) + assertExposedAsResourceId(FirebaseAuthTestTags.EmailLink.SEND_LINK_BUTTON, button()) + assertExposedAsResourceId(FirebaseAuthTestTags.EmailLink.PASSWORD_SIGN_IN_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 = { }, + ) + } + + assertExposedAsResourceId(FirebaseAuthTestTags.PhoneNumber.PHONE_NUMBER_FIELD, field()) + assertExposedAsResourceId(FirebaseAuthTestTags.PhoneNumber.COUNTRY_SELECTOR_BUTTON, button()) + assertExposedAsResourceId(FirebaseAuthTestTags.PhoneNumber.SEND_CODE_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 = { }, + ) + } + + // The code input is a row of single-digit fields rather than one text field, so the tag + // names the container and the field check applies to what it contains. + assertExposedAsResourceId( + FirebaseAuthTestTags.VerificationCode.CODE_FIELD, + hasAnyDescendant(hasSetTextAction()) + ) + assertExposedAsResourceId(FirebaseAuthTestTags.VerificationCode.VERIFY_BUTTON, button()) + assertExposedAsResourceId(FirebaseAuthTestTags.VerificationCode.RESEND_CODE_BUTTON, button()) + assertExposedAsResourceId( + FirebaseAuthTestTags.VerificationCode.CHANGE_PHONE_NUMBER_BUTTON, + button() + ) + } + + @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() + ) + } + + // ============================================================================================= + // 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 application's tag passed in through the public `modifier` becomes a resource id. + * Without the library's own flag inside the dialog it would not, because the dialog's window is + * a fresh semantics root and the flag is read by walking ancestors. + */ + @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())) + } + + // ============================================================================================= + // Separate semantics owners: bottom sheets + // ============================================================================================= + + /** + * The country selector sheet, and the case that made this work necessary rather than merely + * tidy: [FirebaseAuthScreenModifierTest] shows that a modifier passed to the flow's root does + * not reach this sheet at all, so before the library set the flag here itself, + * `By.res("fui_country_selector_country_list")` could not resolve however the caller was + * configured. + */ + @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() + ) + } + + // ============================================================================================= + // The flow root + // ============================================================================================= + + /** + * The root [androidx.compose.material3.Surface] inside [FirebaseAuthScreen] carries the flag as + * well, so content the flow hosts directly — rather than through one of the screen composables + * that flags itself — is covered too. The custom method-picker slot is used because it renders + * under that `Surface` without going through [AuthMethodPicker], which would supply a flag of + * its own and make this pass either way. + */ + @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" + } +} 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 index a11181911..dd06b5459 100644 --- 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 @@ -59,7 +59,7 @@ import org.robolectric.annotation.Config * * One test here asserts the opposite of the rest on purpose. "Reaches the root" is not the same as * "reaches everything the flow shows", and the last test in this class pins where the difference - * lies so the gap is recorded rather than assumed away. + * lies so the boundary is recorded rather than assumed away. * * @suppress Internal test class */ @@ -246,13 +246,16 @@ class FirebaseAuthScreenModifierTest { * * This is a boundary, not a bug, and it is asserted rather than left implicit because the * obvious reading of "the modifier reaches the root of the flow" is that it therefore covers - * everything the flow shows. It does not. `Modifier.semantics { testTagsAsResourceId = true }` - * passed into [FirebaseAuthScreen] turns the navigation destinations' tags into resource ids - * and leaves every dialog and bottom sheet untouched — including this one, which owns - * [FirebaseAuthTestTags.CountrySelector.COUNTRY_LIST], so `By.res("fui_country_selector_…")` - * still will not resolve it. Making those surfaces opt in individually is separate work; if - * this test starts failing because the sheet's content became a descendant of the root, that - * work landed and the [FirebaseAuthScreen] `modifier` KDoc needs updating with it. + * everything the flow shows. It does not — no modifier passed to [FirebaseAuthScreen] decorates + * anything inside this sheet. + * + * What that boundary no longer implies is that the sheet's tags are unreachable as resource ids. + * The library now sets `testTagsAsResourceId` at each semantics owner it creates, this sheet + * included, so [FirebaseAuthTestTags.CountrySelector.COUNTRY_LIST] does resolve under + * `By.res("fui_country_selector_country_list")` — by the library's own doing rather than by + * inheritance from the caller's modifier. That exposure is asserted in + * [TestTagsAsResourceIdsTest]; the assertions here stay about the modifier's reach, which is + * unchanged. */ @Test fun `caller modifier does not reach bottom sheet content, which is a separate semantics owner`() { From 5734b010d1e385d6b9ccfcb4c2f86c5e83138212 Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 18 Aug 2026 10:35:53 +0100 Subject: [PATCH 07/16] fix(auth): make the verification code field typeable by resource id --- .../ui/auth/ui/FirebaseAuthTestTags.kt | 45 ++- .../ui/auth/ui/TestTagsAsResourceIds.kt | 21 +- .../components/VerificationCodeInputField.kt | 108 ++++++- .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 14 +- .../auth/ui/screens/MfaChallengeDefaults.kt | 10 +- .../auth/ui/screens/MfaEnrollmentDefaults.kt | 11 +- .../ui/auth/ui/MainSourceTestTagUsageTest.kt | 68 ++++- .../ui/auth/ui/TestTagsAsResourceIdsTest.kt | 280 +++++++++++++++++- ...VerificationCodeInputFieldSemanticsTest.kt | 211 +++++++++++++ .../ui/screens/CredentialLinkingScreenTest.kt | 14 +- .../ui/auth/ui/screens/PhoneAuthScreenTest.kt | 28 +- 11 files changed, 775 insertions(+), 35 deletions(-) create mode 100644 auth/src/test/java/com/firebase/ui/auth/ui/components/VerificationCodeInputFieldSemanticsTest.kt 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 index 506986509..dcdb2b6bc 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/FirebaseAuthTestTags.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/FirebaseAuthTestTags.kt @@ -156,7 +156,17 @@ object FirebaseAuthTestTags { /** Tags on the SMS verification code screen. */ object VerificationCode { - /** The verification code input. */ + /** + * The verification code input. + * + * The code is drawn as one box per digit, and this names the group rather than any one box. + * The group is the editable node: it accepts a whole code in a single `ACTION_SET_TEXT` or + * `performTextInput` and spreads it across the boxes, so one Robo directive + * (`{"resourceName": "fui_verification_code_code_field", "inputText": "123456"}`) enters the + * whole thing. See + * [com.firebase.ui.auth.ui.components.VerificationCodeInputField] for why the boxes + * themselves are not addressable. + */ const val CODE_FIELD = "fui_verification_code_code_field" /** The button that submits the entered code. */ @@ -169,12 +179,45 @@ object FirebaseAuthTestTags { const val CHANGE_PHONE_NUMBER_BUTTON = "fui_verification_code_change_phone_number_button" } + /** + * Tags on the multi-factor sign-in challenge screen — the second factor a user is asked for + * after their password, which is part of a plain sign-in and not of MFA enrollment. + * + * Separate from [VerificationCode] even though both screens show the same code input: the SMS + * step of a phone sign-in and the second-factor challenge are different screens reached by + * different routes, and giving them one value would leave a `By.res` match unable to say which + * screen it landed on. + */ + object MfaChallenge { + + /** + * The verification code input, for both the SMS and TOTP factors. + * + * Accepts a whole code in one action, exactly as + * [VerificationCode.CODE_FIELD] does — the two screens share the input widget. + */ + 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" + } + /** * Tags on the re-authentication dialog. * * Deliberately a group of its own rather than reusing [SignIn]: the re-authentication surface * and the flow behind it are composed at the same time while the dialog is open, so a shared * value would match two nodes and neither could be addressed. + * + * That reasoning covers the tags declared here, but not everything the re-authentication surface + * can show. The default re-authentication bottom sheet re-enters the ordinary email flow to + * collect a password, so [SignIn.EMAIL_FIELD] and its siblings appear *inside* the sheet, under + * the same values they carry on the sign-in screen. Nothing collides today, because the sheet is + * only raised from the post-sign-in surface and no sign-in screen is composed behind it — so + * each value still resolves to one node. What a crawler cannot do is tell from the resource id + * alone whether it is looking at the sign-in screen or at the re-authentication sheet; a test + * that needs to distinguish them has to key off something else on the surface, and anything + * that raises the sheet over a live sign-in screen would turn this into a real collision. */ object Reauth { 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 index a2cb3f143..452b43703 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/TestTagsAsResourceIds.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/TestTagsAsResourceIds.kt @@ -31,7 +31,26 @@ import androidx.compose.ui.semantics.testTagsAsResourceId * Apply it once per **semantics owner**, not once per screen. The flag is read by walking a node's * semantics ancestors, and that walk stops at the root of the window the node lives in. Every dialog * and bottom sheet Compose shows is hosted in its own window with its own semantics root, so it - * inherits nothing from the composable that opened it and has to carry the flag itself. + * inherits nothing from the composable that opened it and has to carry the flag itself. Sibling + * branches within one window are a quieter version of the same trap: a `Scaffold` per step of a + * wizard inherits nothing from the `Scaffold` of the step beside it. + * + * ## The rule: flag the owner, tags or not + * + * **Every semantics owner the library creates carries this flag, whether or not anything inside it + * is tagged today.** Flagging is applied to owners rather than to tags on purpose, and the reason is + * asymmetry of failure. Flagging an owner that holds no tags costs nothing — the property is + * `isImportantForAccessibility = false`, so it changes no accessibility behaviour and is invisible + * until a tag appears beneath it. Adding a tag inside an owner that was skipped costs a silent + * regression: `onNodeWithTag(...).assertExists()` still passes, the suite stays green, and only a + * Robo directive or `By.res()` — neither of which runs in CI — can tell that the tag never became a + * resource id. + * + * So the condition for applying it is "the library creates a semantics owner here", never "there is + * a tag under here worth exposing". Owners currently flagged with nothing tagged inside them are + * deliberate and should not be pruned as dead code: the loading dialog, the default + * re-authentication bottom sheet, the manage-MFA tooltip, and the TOTP enrollment steps are all in + * that state, and each of them is one added tag away from needing it. */ internal fun Modifier.exposeTestTagsAsResourceIds(): Modifier = semantics { testTagsAsResourceId = true } 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..f20815b3b 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,62 @@ 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.theme.AuthUITheme import com.firebase.ui.auth.configuration.validators.FieldValidator +/** + * A row of [codeLength] single-character boxes that together hold one verification code. + * + * ## Why the group, and not the boxes, is the editable node + * + * Visually this is one field; structurally it is [codeLength] separate `BasicTextField`s, each of + * which rejects anything longer than a single digit. That split is invisible to a user and fatal to + * anything driving the screen from outside Compose. A Firebase Test Lab Robo directive, a Play + * pre-launch crawl, and UiAutomator all type by issuing `ACTION_SET_TEXT` against one accessibility + * node, and there was no node that could accept a whole code: the boxes take one character each and + * are indistinguishable from one another in the accessibility tree, while the container that a + * caller can address by test tag carried no text-input action at all. A directive naming it resolved + * a node and typed nothing — the shape of issue #2050, where Robo found the login field but could + * not fill the password. + * + * So the container declares the text-input semantics itself: [setText] replaces the whole code and + * [insertTextAtCursor] fills forward from the first empty box, both spreading the string they are + * given across the boxes. One `ACTION_SET_TEXT` carrying `"123456"` therefore enters the entire + * code, and `performTextInput` works on the container in a host application's own Compose tests. + * [editableText] and [isEditable] are declared alongside them because they are what makes the + * platform describe this node as an editable field rather than a plain container, which is how a + * crawler decides a node is worth typing into at all. + * + * The cost is that the group is now a node accessibility services will visit, sitting above + * [codeLength] boxes they also visit, so a screen reader reads the code once and then the boxes. + * That is accepted: it is more verbose, but the alternative leaves the code unreachable by every + * tool the tags exist for. + * + * Both actions refuse input they cannot represent — a non-digit, or more digits than there are + * remaining boxes — rather than silently truncating it, so a caller sees a failed action instead of + * a half-entered code. + * + * @param modifier Applied to the group. A [androidx.compose.ui.platform.testTag] passed here names + * the node that accepts the code, so it is the handle both Compose tests and resource-id lookups + * should use. + * @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, @@ -127,8 +177,64 @@ fun VerificationCodeInputField( errorMessage } + // The digits of [text], or null when this field cannot hold them. Rejecting is deliberate: a + // caller that asked to enter "12a456" is better served by a failed action than by a code that + // silently lost a character. + 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 + // Makes the group the node that accepts a whole code — see this composable's KDoc. + // Applied after [modifier] so that a testTag the caller passed lands on the same + // layout node as these actions, which is what lets one resource-id lookup both find + // this node and type into it. + .semantics { + isEditable = true + maxTextLength = codeLength + editableText = AnnotatedString(code.value.mapNotNull { it }.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 -> + val firstEmpty = code.value.indexOfFirst { it == null } + if (firstEmpty < 0) return@insertTextAtCursor false + + val digits = digitsOf(inserted.text, codeLength - firstEmpty) + ?: return@insertTextAtCursor false + if (digits.isEmpty()) return@insertTextAtCursor true + + code.value = code.value.toMutableList().also { updated -> + digits.forEachIndexed { offset, digit -> + updated[firstEmpty + offset] = digit + } + } + focusedIndex.value = cursorAfter(firstEmpty + digits.size) + true + } + + // Focus is moved by writing the index the widget already watches rather than by + // touching a FocusRequester directly, so this cannot throw if it is invoked before + // the boxes have been attached. + requestFocus { + focusedIndex.value = + code.value.indexOfFirst { it == null }.takeIf { it >= 0 } + ?: (codeLength - 1) + true + } + }, horizontalAlignment = Alignment.CenterHorizontally ) { Row( 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 cb27abd0f..5de4411e9 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 @@ -113,9 +113,12 @@ import kotlinx.coroutines.tasks.await * * That boundary no longer costs you the resource ids, though. Exposing * [com.firebase.ui.auth.ui.FirebaseAuthTestTags] as Android resource ids is not something a caller - * has to arrange: the library sets `testTagsAsResourceId` itself at every semantics owner it - * creates, dialogs and bottom sheets included, so Firebase Test Lab Robo directives and - * `By.res()` resolve every published tag without any modifier being passed here. + * has to arrange: the library sets `testTagsAsResourceId` itself at each semantics owner it creates + * — every screen root, and every dialog, bottom sheet, and popup, including the ones holding no tag + * of their own — so Firebase Test Lab Robo directives and `By.res()` resolve every published tag + * without any modifier being passed here. Owners are flagged as a standing rule rather than a + * case-by-case judgement, so a tag added inside one later cannot quietly fail to become a resource + * id. * @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). @@ -859,7 +862,10 @@ private fun AuthSuccessContent( TooltipAnchorPosition.Above ), tooltip = { - PlainTooltip { + // The tooltip is shown in a popup, which is its own semantics owner and so + // inherits nothing from the Surface above. Nothing here is tagged yet; the flag + // is applied because the owner exists — see exposeTestTagsAsResourceIds. + PlainTooltip(modifier = Modifier.exposeTestTagsAsResourceIds()) { Text(stringProvider.mfaDisabledTooltip) } }, 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 d7e309e0a..28ff214ad 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,6 +46,7 @@ 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 @@ -98,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, @@ -150,7 +154,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 72fc2a47a..2488cc83d 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 @@ -131,6 +131,11 @@ internal fun DefaultMfaEnrollmentContent( ) } + // Each step below composes its own root. They are siblings rather than nested, so the flag one + // of them applies reaches none of the others and each has to carry + // Modifier.exposeTestTagsAsResourceIds() itself — which they all do, whether or not they tag + // anything today. The steps that delegate to a shared screen (EnterPhoneNumberUI, + // EnterVerificationCodeUI) get it from that screen. Box(modifier = Modifier.fillMaxSize()) { when (state.step) { MfaEnrollmentStep.SelectFactor -> { @@ -420,7 +425,7 @@ private fun ConfigureTotpUI( error: String?, stringProvider: AuthUIStringProvider ) { - Scaffold { innerPadding -> + Scaffold(modifier = Modifier.exposeTestTagsAsResourceIds()) { innerPadding -> Column( modifier = Modifier .fillMaxSize() @@ -509,7 +514,7 @@ private fun VerifyTotpUI( error: String?, stringProvider: AuthUIStringProvider ) { - Scaffold { innerPadding -> + Scaffold(modifier = Modifier.exposeTestTagsAsResourceIds()) { innerPadding -> Column( modifier = Modifier .fillMaxSize() @@ -582,7 +587,7 @@ private fun ShowRecoveryCodesUI( error: String?, stringProvider: AuthUIStringProvider ) { - Scaffold { innerPadding -> + Scaffold(modifier = Modifier.exposeTestTagsAsResourceIds()) { innerPadding -> Column( modifier = Modifier .fillMaxSize() 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 index a8499f61b..7690c713b 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/MainSourceTestTagUsageTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/MainSourceTestTagUsageTest.kt @@ -45,6 +45,11 @@ import org.junit.Test * the file is clean. Indirection through a local wrapper function remains out of reach and is * accepted: it costs more to write than to review, and the reviewer sees the wrapper. * + * The same scan answers a second, adjacent question that nothing else could: whether a file applying + * a tag also flags a semantics owner, so the tag can become a resource id at all. Both failures are + * invisible to every Compose assertion in the suite, which is why a lexical check is worth its + * limitations here. + * * It lives in its own class rather than in [FirebaseAuthTestTagsTest] because it tests a different * thing by a different mechanism: the registry test asserts properties of compiled constants, while * this one reads files off disk and therefore carries a failure mode of its own — a scan that @@ -90,6 +95,58 @@ class MainSourceTestTagUsageTest { ).that(aliases).isEmpty() } + /** + * Catches the other half of the same problem: a tag that goes through the registry but never + * becomes a resource id because the semantics owner around it was never flagged. + * + * Nothing in the type system ties a `testTag` call to an enclosing + * [exposeTestTagsAsResourceIds]. The flag is read at runtime by walking a node's semantics + * ancestors, so omitting it leaves `onNodeWithTag(...).assertExists()` passing, the suite green, + * and only `By.res()` — which does not run in CI — able to tell. [TestTagsAsResourceIdsTest] + * covers the tags that exist today, but a tag added on a screen it has no fixture for would slip + * through both classes. + * + * The check this can make lexically is coarser than the real rule: it requires every file that + * applies a tag to also apply the flag *somewhere*, not that the flag encloses that particular + * tag. Two things it therefore cannot see are a file that flags one owner and tags a node under + * a second, unflagged one, and a tag placed in a file with no owner of its own that relies on a + * flag applied by its caller. The first is what [TestTagsAsResourceIdsTest] is for. The second + * would be a false positive here, and the fix for it — apply the flag in the file too — is + * harmless rather than wrong: the property is `isImportantForAccessibility = false`, so setting + * it again where it is already set changes nothing. A coarse check that costs a redundant + * one-liner is worth more than no check. + */ + @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 this class against the failure mode that would make it worthless: resolving no source * tree, or the wrong one, scanning nothing, and reporting success. Every step of the scan is @@ -415,10 +472,19 @@ class MainSourceTestTagUsageTest { * stops reaching files announces itself. A floor rather than an exact count: tagging more * nodes is the expected direction of travel and should not redden an unrelated build. */ - const val MINIMUM_TAG_CALL_SITES = 33 + const val MINIMUM_TAG_CALL_SITES = 35 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*\(""") + const val REGISTRY_RELATIVE_PATH = "com/firebase/ui/auth/ui/FirebaseAuthTestTags.kt" /** Working directory is the module directory under Gradle's default. */ 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 index caf9b4a3a..c70e898cb 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/TestTagsAsResourceIdsTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/TestTagsAsResourceIdsTest.kt @@ -15,15 +15,21 @@ 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 @@ -32,8 +38,10 @@ 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 @@ -42,11 +50,14 @@ 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.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.FirebaseAuthScreen import com.firebase.ui.auth.ui.screens.email.ResetPasswordUI import com.firebase.ui.auth.ui.screens.email.SignInEmailLinkUI @@ -55,6 +66,7 @@ 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 @@ -106,6 +118,21 @@ import org.robolectric.annotation.GraphicsMode * — the case with no coverage before this class existed, and the one where forgetting the modifier * is invisible in a Compose-only assertion. * + * ## What is deliberately not covered here + * + * [exposeTestTagsAsResourceIds] is applied to every semantics owner the library creates, including + * the ones that hold no tag today, and those flags have no test of their own. That is intentional + * twice over. There is nothing to assert — the property is only observable through a tag beneath it, + * so a test would have to plant its own tag and would then be testing Compose. And the flags exist + * precisely because no test can see them missing: the whole reason for flagging owners rather than + * tags is that a tag added inside an unflagged owner keeps every Compose assertion green. The loading + * dialog, the default re-authentication sheet, the manage-MFA tooltip, and the TOTP enrollment steps + * are all in that state. + * + * The one case where a flag-without-tags *is* asserted is `error recovery dialog exposes a caller + * supplied tag`, which stands in for all of them: it plants a caller tag in an owner the library + * flags but does not tag, and so proves the mechanism works the moment a tag arrives. + * * @suppress Internal test class */ @Config(sdk = [34]) @@ -191,6 +218,86 @@ class TestTagsAsResourceIdsTest { } } + /** + * Enters [text] into whatever node the platform publishes under the resource id [resourceName], + * the way something outside Compose would: the node is found by scanning the accessibility tree + * for that resource id, and the text is delivered through `ACTION_SET_TEXT` — the action behind + * a Robo `inputText` directive and UiAutomator's `setText`. + * + * The Compose test tag is deliberately not used to locate the node, because that is the claim + * under test. `performTextInput` on a tag shows that a Compose test can type; it says nothing + * about whether a crawler holding only a resource name can, which is the gap issue #2050 is + * about. + */ + 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\" 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], together with the + * provider that owns it, asserting that exactly one node claims the id. + * + * Every semantics node in the tree is examined rather than the one carrying a matching test tag, + * so this resolves the id the same way `By.res()` does — and so a tag that never reached the + * accessibility tree fails here instead of being found by the back door. + */ + 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() @@ -379,12 +486,10 @@ class TestTagsAsResourceIdsTest { ) } - // The code input is a row of single-digit fields rather than one text field, so the tag - // names the container and the field check applies to what it contains. - assertExposedAsResourceId( - FirebaseAuthTestTags.VerificationCode.CODE_FIELD, - hasAnyDescendant(hasSetTextAction()) - ) + // The code is drawn as one box per digit, but the tag names the group and the group is the + // node that takes text — so `field()` here, not `hasAnyDescendant(field())`. See the typing + // tests below for why that distinction is the whole point. + assertExposedAsResourceId(FirebaseAuthTestTags.VerificationCode.CODE_FIELD, field()) assertExposedAsResourceId(FirebaseAuthTestTags.VerificationCode.VERIFY_BUTTON, button()) assertExposedAsResourceId(FirebaseAuthTestTags.VerificationCode.RESEND_CODE_BUTTON, button()) assertExposedAsResourceId( @@ -393,6 +498,132 @@ class TestTagsAsResourceIdsTest { ) } + // ============================================================================================= + // The code fields, which have to accept a code and not merely carry a tag + // ============================================================================================= + + /** + * The finding this section exists for: `fui_verification_code_code_field` used to name a bare + * container whose entire semantics config was its test tag. A Robo directive + * `{"resourceName": "fui_verification_code_code_field", "inputText": "123456"}` resolved that + * node and typed nothing, and the six real digit boxes were unaddressable — no resource id of + * their own, and six identical content descriptions between them. The constant promised a code + * input and delivered a `Column`. + * + * So this drives the code in the way that failed: locate the node by resource id, issue + * `ACTION_SET_TEXT`, and then require the code to have arrived where the screen keeps it. The + * verify button being enabled afterwards is the part that matters — it is the screen agreeing + * that it holds a complete, valid code, which is as far as a crawler needs to get. + */ + @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 same claim for a host application's own Compose tests, which the registry KDoc invites: + * `performTextInput` against the published tag enters the whole code. It exercises a different + * semantics action from the test above — `InsertTextAtCursor` rather than `SetText` — so both + * are covered. + */ + @Test + fun `performTextInput on the verification code tag enters the whole code`() { + 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 = { }, + ) + } + + composeTestRule + .onNodeWithTag(FirebaseAuthTestTags.VerificationCode.CODE_FIELD) + .performTextInput(VERIFICATION_CODE) + + // The code reaches the caller from a LaunchedEffect keyed on the digits, so the recomposition + // the action triggers has to settle before the callback has run. + composeTestRule.waitForIdle() + + assertWithMessage( + "performTextInput on the published verification code tag did not enter the code." + ).that(entered.value).isEqualTo(VERIFICATION_CODE) + } + + /** + * The multi-factor challenge screen shares the code input with the phone screen and had no tags + * at all, so it had the same hole in a place a user reaches during an ordinary sign-in rather + * than during enrollment. It is covered by the same assertions rather than by a weaker one, + * because "reachable by a crawler" means the same thing on both screens. + */ + @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() + } + @Test fun `method picker exposes its provider list`() { setContent { @@ -410,6 +641,34 @@ class TestTagsAsResourceIdsTest { ) } + /** + * The "Continue as …" button only exists when a previous sign-in preference is stored, so it + * needs a fixture of its own and had no coverage without one. It is worth having: for a + * returning user it is the first control on the flow's first screen, so it is what a crawl + * reaches before anything else. + */ + @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 // ============================================================================================= @@ -608,5 +867,14 @@ class TestTagsAsResourceIdsTest { /** 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" + + /** + * 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/VerificationCodeInputFieldSemanticsTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/components/VerificationCodeInputFieldSemanticsTest.kt new file mode 100644 index 000000000..260d1fd20 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/components/VerificationCodeInputFieldSemanticsTest.kt @@ -0,0 +1,211 @@ +/* + * 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 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.SemanticsPropertyKey +import androidx.compose.ui.test.junit4.createComposeRule +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 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 that holds its digit + * boxes. + * + * That contract is the reason the group carries semantics at all. The boxes accept one character + * each, so nothing could be handed a whole code, and the node a caller can address by test tag had + * no text-input action — a Firebase Test Lab Robo `inputText` directive naming it resolved a node and + * typed nothing, which is issue #2050 reproduced on the verification screen. + * [com.firebase.ui.auth.ui.TestTagsAsResourceIdsTest] proves the code arrives through the resource id + * a crawler would use; this class pins the behaviour underneath that — how a string is spread across + * the boxes, and what happens to input the boxes cannot hold. + * + * The rejection cases matter as much as the accepting ones. Both actions return a `Boolean`, and + * silently truncating over-long or non-numeric input would hand a caller a half-entered code and a + * success. So the actions are invoked directly here rather than through `performTextReplacement`, + * which discards the result. + * + * @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 fun setContent(codeLength: Int = 6) { + composeTestRule.setContent { + 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() + + /** + * 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 non-digit cannot be shown in a numeric box.") + .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") + } + + /** 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 — a host application tagging our node with its own value is supported and this + * stands in for that too. + */ + const val CODE_FIELD_TAG = "verification_code_group_under_test" + } +} 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 f988398ae..e9d432017 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 @@ -247,11 +247,15 @@ 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 through the published tag in one call: the tagged group is the + // editable node and spreads the string across its digit boxes. Selecting the boxes + // positionally out of onAllNodes(hasSetTextAction()) — as this did — depended on which + // nodes happen to accept text and in what order. + composeTestRule.waitForIdle() + composeTestRule + .onNodeWithTag(FirebaseAuthTestTags.VerificationCode.CODE_FIELD) + .performTextInput(phoneCode) + composeTestRule.waitForIdle() composeTestRule.onNodeWithText(stringProvider.verifyPhoneNumber.uppercase()) .performScrollTo() 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 7f1661da9..044802fb6 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 @@ -227,12 +227,14 @@ 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 through the published tag in one call: the tagged group is the + // editable node and spreads the string across its digit boxes. Selecting the boxes + // positionally out of onAllNodes(hasSetTextAction()) — as this did — depended on which + // nodes happen to accept text and in what order. + composeTestRule.waitForIdle() + composeTestRule + .onNodeWithTag(FirebaseAuthTestTags.VerificationCode.CODE_FIELD) + .performTextInput(phoneCode) composeTestRule.waitForIdle() // Submit verification code composeTestRule.onNodeWithText(stringProvider.verifyPhoneNumber.uppercase()) @@ -498,11 +500,15 @@ 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 through the published tag in one call: the tagged group is the + // editable node and spreads the string across its digit boxes. Selecting the boxes + // positionally out of onAllNodes(hasSetTextAction()) — as this did — depended on which + // nodes happen to accept text and in what order. + composeTestRule.waitForIdle() + composeTestRule + .onNodeWithTag(FirebaseAuthTestTags.VerificationCode.CODE_FIELD) + .performTextInput(phoneCode) + composeTestRule.waitForIdle() composeTestRule.onNodeWithText(stringProvider.verifyPhoneNumber.uppercase()) .performScrollTo() From 69d6d5ccb2786b25cbc41f888d688b763ed49772 Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 18 Aug 2026 11:09:09 +0100 Subject: [PATCH 08/16] fix(auth): enforce semantics owner coverage and tighten code field semantics --- .../components/VerificationCodeInputField.kt | 41 ++- .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 10 +- .../auth/ui/screens/MfaEnrollmentDefaults.kt | 14 +- .../ui/auth/ui/MainSourceTestTagUsageTest.kt | 275 ++++++++++++++++-- .../ui/auth/ui/TestTagsAsResourceIdsTest.kt | 28 ++ ...VerificationCodeInputFieldSemanticsTest.kt | 107 ++++++- .../firebase/ui/auth/ui/AccessibilityTest.kt | 1 - .../ui/screens/CredentialLinkingScreenTest.kt | 1 - .../ui/auth/ui/screens/PhoneAuthScreenTest.kt | 1 - 9 files changed, 435 insertions(+), 43 deletions(-) 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 f20815b3b..141658a8b 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 @@ -95,9 +95,11 @@ import com.firebase.ui.auth.configuration.validators.FieldValidator * That is accepted: it is more verbose, but the alternative leaves the code unreachable by every * tool the tags exist for. * - * Both actions refuse input they cannot represent — a non-digit, or more digits than there are - * remaining boxes — rather than silently truncating it, so a caller sees a failed action instead of - * a half-entered code. + * Both actions refuse input they cannot represent — a character no digit box can show, or more digits + * than there are remaining boxes — rather than silently truncating it, so a caller sees a failed + * action instead of a half-entered code. "Digit" is `Character.isDigit`'s definition rather than ASCII + * `0`-`9`, so a code typed in Arabic-Indic or fullwidth digits is accepted and normalised to its ASCII + * value, exactly as the per-box keyboard path already treats it; see the comment on `digitsOf`. * * @param modifier Applied to the group. A [androidx.compose.ui.platform.testTag] passed here names * the node that accepts the code, so it is the handle both Compose tests and resource-id lookups @@ -180,6 +182,14 @@ fun VerificationCodeInputField( // The digits of [text], or null when this field cannot hold them. Rejecting is deliberate: a // caller that asked to enter "12a456" is better served by a failed action than by a code that // silently lost a character. + // + // "Digit" here is Character.isDigit's definition, not ASCII 0-9: isDigitsOnly() and digitToInt() + // both accept Arabic-Indic ("١٢٣٤٥٦") and fullwidth ("123") digits and yield the same values as + // their ASCII counterparts, so such input is normalised rather than rejected. That is kept + // deliberately, because the per-box keyboard path below does exactly the same thing — narrowing + // only these two actions to ASCII would make a code typed on a localised keypad enterable by hand + // and refused through ACTION_SET_TEXT. Nothing malformed gets through either way: the length and + // digit checks still hold, so the outcome is a well-formed code in ASCII, never a partial one. fun digitsOf(text: String, availableSlots: Int): List? = when { text.isEmpty() -> emptyList() text.length > availableSlots -> null @@ -197,9 +207,27 @@ fun VerificationCodeInputField( // layout node as these actions, which is what lets one resource-id lookup both find // this node and type into it. .semantics { + // TODO: this group is an important, EditText-classed accessibility node with no + // label of its own, so TalkBack announces it as an unlabelled edit box. The label + // belongs here, on the node that owns the text-input semantics, and not on the + // digit boxes below — but it has to come from AuthUIStringProvider rather than be + // hardcoded, and adding a string to that public interface is tracked separately. It + // must be set as a `contentDescription`, not as `text`: Compose prefers Text over + // EditableText when deriving className, so a `text` property here would flip this + // node from EditText to TextView and stop a Robo crawler treating it as typeable + // (asserted in TestTagsAsResourceIdsTest.setTextByResourceId). isEditable = true maxTextLength = codeLength - editableText = AnnotatedString(code.value.mapNotNull { it }.joinToString("")) + // The digits before the first empty box, not every digit entered. Boxes fill + // non-contiguously — tapping box 3 moves the focus there directly — so compacting + // the whole list would report [1, 2, null, 4] as "124" and claim a digit at a + // position that is actually empty. A prefix is always positionally true, and it is + // what insertTextAtCursor appends to, so the two agree. It can understate: with only + // box 3 filled this reads as empty. Note that the onCodeChange callback below is + // deliberately left compacted — screens depend on that contract. + editableText = AnnotatedString( + code.value.takeWhile { it != null }.joinToString("") + ) setText { newCode -> val digits = digitsOf(newCode.text, codeLength) ?: return@setText false @@ -209,12 +237,15 @@ fun VerificationCodeInputField( } 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 - if (digits.isEmpty()) return@insertTextAtCursor true code.value = code.value.toMutableList().also { updated -> digits.forEachIndexed { offset, digit -> 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 5de4411e9..72fd1befe 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 @@ -244,7 +244,10 @@ fun FirebaseAuthScreen( customMethodPickerLayout(configuration.providers, onProviderSelected) } } else { - Scaffold { innerPadding -> + // Flagged because the library creates the owner, not because a tag needs it + // here — see exposeTestTagsAsResourceIds. This one sits under the flagged + // Surface above, so it is redundant today and cheap to keep uniform. + Scaffold(modifier = Modifier.exposeTestTagsAsResourceIds()) { innerPadding -> AuthMethodPicker( modifier = Modifier .padding(innerPadding), @@ -1009,7 +1012,10 @@ private fun ReauthSheetContent( customMethodPickerLayout(reauthConfig.providers, onProviderSelected) } } else { - Scaffold { innerPadding -> + // Flagged for the same reason as its counterpart in FirebaseAuthScreen: the owner is + // ours, so it carries the flag whether or not anything under it is tagged today. The + // enclosing ModalBottomSheet already flags 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/MfaEnrollmentDefaults.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaEnrollmentDefaults.kt index 2488cc83d..9758f55f3 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 @@ -131,11 +131,15 @@ internal fun DefaultMfaEnrollmentContent( ) } - // Each step below composes its own root. They are siblings rather than nested, so the flag one - // of them applies reaches none of the others and each has to carry - // Modifier.exposeTestTagsAsResourceIds() itself — which they all do, whether or not they tag - // anything today. The steps that delegate to a shared screen (EnterPhoneNumberUI, - // EnterVerificationCodeUI) get it from that screen. + // Each step below composes its own root, and each carries Modifier.exposeTestTagsAsResourceIds() + // itself whether or not it tags anything today. Not because an ancestor's flag would fail to + // reach them — a flag is inherited by walking semantics ancestors, and that walk crosses + // composable boundaries freely, so inside this library every step is already covered by the + // Surface that wraps the NavHost in FirebaseAuthScreen. The flags are load-bearing because + // MfaEnrollmentScreen is public: a host application can call it standalone, with no flagged + // ancestor of ours above it, and the resource ids have to work there too. The steps that + // delegate to a shared screen (EnterPhoneNumberUI, EnterVerificationCodeUI) get it from that + // screen. Box(modifier = Modifier.fillMaxSize()) { when (state.step) { MfaEnrollmentStep.SelectFactor -> { 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 index 7690c713b..820d8c4d6 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/MainSourceTestTagUsageTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/MainSourceTestTagUsageTest.kt @@ -45,11 +45,34 @@ import org.junit.Test * the file is clean. Indirection through a local wrapper function remains out of reach and is * accepted: it costs more to write than to review, and the reviewer sees the wrapper. * - * The same scan answers a second, adjacent question that nothing else could: whether a file applying - * a tag also flags a semantics owner, so the tag can become a resource id at all. Both failures are - * invisible to every Compose assertion in the suite, which is why a lexical check is worth its + * The same scan answers two adjacent questions that nothing else could. Whether every semantics + * owner the library creates carries [exposeTestTagsAsResourceIds] — the rule that file declares, + * stated as owners rather than tags — and, as a cheaper backstop for owner shapes this scan does not + * recognise, whether a file applying a tag flags an owner anywhere in itself. All of these failures + * are invisible to every Compose assertion in the suite, which is why a lexical check is worth its * limitations here. * + * ## What the owner check cannot see + * + * The owner check is lexical, so four gaps are known and accepted rather than papered over: + * + * * It recognises the owner-creating shapes in [SEMANTICS_OWNER_SHAPES] by name. A `Dialog` + * subclassed or wrapped under a different name, or a future Compose owner shape, is invisible to + * it. That is what the weaker per-file check below still covers. + * * Lexical presence cannot tell an *applied* modifier from one that is built and discarded. + * `Scaffold(modifier = Modifier.exposeTestTagsAsResourceIds().let { Modifier })` reads as flagged. + * Requiring the call inside the owner's own argument list rather than merely nearby is as close as + * a scan gets; the remaining shapes are not ones anybody writes by accident. + * * The converse is a false positive: a flag reached through a local, as in + * `val m = Modifier.exposeTestTagsAsResourceIds()` followed by `Scaffold(modifier = m)`, is not in + * the argument list and reads as unflagged. The fix — inline it, or flag in the argument list too + * — is harmless, because the property is `isImportantForAccessibility = false` and setting it + * twice changes nothing. + * * `@Preview` composables are skipped. They are private, are never composed in a shipped + * application, and Android Studio's preview host is not a crawler, so an owner inside one has + * nothing to expose. This is the only exemption, and it is a rule rather than an allowlist so that + * adding a preview cannot redden an unrelated build. + * * It lives in its own class rather than in [FirebaseAuthTestTagsTest] because it tests a different * thing by a different mechanism: the registry test asserts properties of compiled constants, while * this one reads files off disk and therefore carries a failure mode of its own — a scan that @@ -96,25 +119,64 @@ class MainSourceTestTagUsageTest { } /** - * Catches the other half of the same problem: a tag that goes through the registry but never - * becomes a resource id because the semantics owner around it was never flagged. + * The rule [exposeTestTagsAsResourceIds] declares, checked as declared: **every semantics owner + * the library creates carries the flag, whether or not anything inside it is tagged today.** + * + * Stating the rule over owners rather than over tags is the whole point of it. A flag on an owner + * that holds no tags costs nothing, while a tag added inside an owner that was skipped costs a + * silent regression — `onNodeWithTag(...).assertExists()` still passes and only a Robo directive + * or `By.res()`, neither of which runs in CI, can tell. So a check keyed on "this file applies a + * tag" cannot enforce it: the four owners that had to be fixed by hand while this branch was + * written — the manage-MFA tooltip and the three sibling `Scaffold`s in the MFA enrollment steps + * — hold no tags at all, and no tag-keyed check would ever have named them. + * + * An owner is recognised by construction shape ([SEMANTICS_OWNER_SHAPES]) and is required to carry + * the flag *in its own argument list*, which ties the flag to that owner rather than to its + * neighbourhood. A shape invoked with a trailing lambda and no argument list at all — `Scaffold { + * … }` — cannot be passed a modifier and so cannot be flagged; it is reported for the same + * reason, and the fix is to give it one. The gaps this cannot see are listed on the class. + */ + @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() + } + + /** + * The cheaper backstop for the owner check above: a file that applies a tag must flag an owner + * somewhere in itself, whatever shape that owner has. * * Nothing in the type system ties a `testTag` call to an enclosing - * [exposeTestTagsAsResourceIds]. The flag is read at runtime by walking a node's semantics - * ancestors, so omitting it leaves `onNodeWithTag(...).assertExists()` passing, the suite green, - * and only `By.res()` — which does not run in CI — able to tell. [TestTagsAsResourceIdsTest] - * covers the tags that exist today, but a tag added on a screen it has no fixture for would slip - * through both classes. + * [exposeTestTagsAsResourceIds]. The check above covers the owner shapes it knows by name; this + * one covers the case where a tag is applied under something it does not recognise, at the cost + * of being coarser — it requires the flag *somewhere in the file*, not that it encloses that + * particular tag. * - * The check this can make lexically is coarser than the real rule: it requires every file that - * applies a tag to also apply the flag *somewhere*, not that the flag encloses that particular - * tag. Two things it therefore cannot see are a file that flags one owner and tags a node under - * a second, unflagged one, and a tag placed in a file with no owner of its own that relies on a - * flag applied by its caller. The first is what [TestTagsAsResourceIdsTest] is for. The second - * would be a false positive here, and the fix for it — apply the flag in the file too — is - * harmless rather than wrong: the property is `isImportantForAccessibility = false`, so setting - * it again where it is already set changes nothing. A coarse check that costs a redundant - * one-liner is worth more than no check. + * Three things it therefore cannot see: a file that flags one owner and tags a node under a + * second, unflagged one; a tag placed in a file with no owner of its own that relies on a flag + * applied by its caller; and, as everywhere in this class, a flag that is built and never + * applied. The first is what the owner check and [TestTagsAsResourceIdsTest] are for. The second + * would be a false positive, and the fix for it — apply the flag in the file too — is harmless + * rather than wrong: the property is `isImportantForAccessibility = false`, so setting it again + * where it is already set changes nothing. A coarse check that costs a redundant one-liner is + * worth more than no check. */ @Test fun `every main source file that applies a test tag also flags a semantics owner`() { @@ -183,6 +245,26 @@ class MainSourceTestTagUsageTest { "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) } /** @@ -222,15 +304,27 @@ class MainSourceTestTagUsageTest { 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/java` without trusting the working directory. + * Locates `auth/src/main` without trusting the working directory. * * Gradle runs unit tests with the module directory as the working directory, but that is a * default rather than a guarantee, and the same test may be launched from an IDE or from the * repository root. So the module-relative and repository-relative paths are both tried at the * working directory and at each of its ancestors, and a candidate only counts once * [FirebaseAuthTestTags] itself is found inside it. Using the registry file as the sentinel - * means resolution cannot silently land on some unrelated `src/main/java`. + * means resolution cannot silently land on some unrelated `src/main`. + * + * The whole of `src/main` is resolved rather than `src/main/java` specifically, because a Kotlin + * file is not required to live under `java/`: an `src/main/kotlin` directory is a source root + * Gradle compiles and would have been invisible to this scan. Walking `src/main` covers every + * source directory in it, present and future, and only `.kt` files are read from it. The + * sentinel is looked for under either directory for the same reason. */ private fun mainSourceRoot(): File { val workingDirectory = File(System.getProperty("user.dir") ?: ".").absoluteFile @@ -246,13 +340,14 @@ class MainSourceTestTagUsageTest { } val resolved = candidates.firstOrNull { candidate -> - File(candidate, REGISTRY_RELATIVE_PATH).isFile + 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_PATH under \"$MODULE_RELATIVE_MAIN_SOURCES\" and " + + "${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." @@ -298,6 +393,70 @@ class MainSourceTestTagUsageTest { }.toList() } + /** + * Finds the semantics owner constructions in [file] and says which of them flag themselves. + * + * Matching runs over [maskCommentsAndLiterals] output for the same reason the tag scan does: the + * KDoc in these files names `Scaffold`, `ModalBottomSheet` and the rest constantly, and a + * documented shape is not a constructed one. The flag is then looked for inside the owner's own + * argument list — balanced over the masked text, so a paren in a string cannot unbalance it — + * rather than within some number of surrounding lines, so the flag is tied to that owner and not + * to a sibling that happens to sit above it. An owner invoked with a trailing lambda and no + * argument list has no argument list to search and is reported: it cannot be handed a modifier at + * all. + */ + 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. + * + * Resolution is by nearest preceding function declaration and the annotation block directly above + * it, which is exact for the top-level composables these sources are written as. A preview is + * private, is never composed in a shipped application, and is rendered by Android Studio rather + * than by a crawler, so an owner inside one has nothing to expose as a resource id. + */ + 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 the tokens that merely spell `testTag` without applying one. * @@ -466,6 +625,14 @@ class MainSourceTestTagUsageTest { /** 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 the number of `testTag` applications in `auth/src/main`, so a scan that @@ -474,6 +641,13 @@ class MainSourceTestTagUsageTest { */ const val MINIMUM_TAG_CALL_SITES = 35 + /** + * Lower bound on the number of recognised semantics owner constructions outside `@Preview` + * functions, so a scan that stops recognising a call shape announces itself rather than + * reporting a clean tree. A floor for the same reason as [MINIMUM_TAG_CALL_SITES]. + */ + 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. */ @@ -485,13 +659,57 @@ class MainSourceTestTagUsageTest { */ val FLAG_APPLICATION_PATTERN = Regex("""\b$FLAG_FUNCTION_NAME\s*\(""") - const val REGISTRY_RELATIVE_PATH = "com/firebase/ui/auth/ui/FirebaseAuthTestTags.kt" + /** + * The Compose call shapes that create a semantics owner the library is responsible for + * flagging. Dialogs, popups and bottom sheets are each hosted in their own window with their + * own semantics root; a `Scaffold` is a subtree root that a sibling `Scaffold`'s flag does not + * reach. Longest-first is not needed — `\b` already stops `Dialog` from matching inside + * `AlertDialog` — but alphabetical order keeps `AlertDialog` ahead of it anyway. + */ + val SEMANTICS_OWNER_SHAPES = listOf( + "AlertDialog", + "Dialog", + "ModalBottomSheet", + "PlainTooltip", + "Popup", + "Scaffold", + ) + + /** + * One of [SEMANTICS_OWNER_SHAPES] being constructed. `{` is accepted as the delimiter as well + * as `(` so that a trailing-lambda-only call, which can carry no modifier and therefore no + * flag, is caught rather than missed. + */ + 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 as the sentinel that a candidate directory really is the auth main + * source tree. Both conventional Kotlin source directories are tried, so moving the registry + * from `java/` to `kotlin/` does not disarm the scan. + */ + 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/java" + 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/java" + const val REPOSITORY_RELATIVE_MAIN_SOURCES = "auth/src/main" const val MAX_ANCESTOR_WALK = 6 @@ -509,7 +727,10 @@ class MainSourceTestTagUsageTest { val CLOSING_BRACKETS = setOf(')', ']', '}') - /** `fun testTag(` declares one rather than calling it. */ + /** + * `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. */ 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 index c70e898cb..e238a8c6b 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/TestTagsAsResourceIdsTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/TestTagsAsResourceIdsTest.kt @@ -228,6 +228,13 @@ class TestTagsAsResourceIdsTest { * under test. `performTextInput` on a tag shows that a Compose test can type; it says nothing * about whether a crawler holding only a resource name can, which is the gap issue #2050 is * about. + * + * Two properties are asserted before the text is sent, because a crawler reads both and either + * one alone can be satisfied by a node it will never type into. `className` has to be + * [EDIT_TEXT_CLASS_NAME] — the field a crawler consults to decide a node accepts text at all, + * and the only class Robo's `inputText` directives are documented for — and `ACTION_SET_TEXT` + * has to be offered. They fail independently: a plain container can advertise the action, and a + * node with the action can lose the class to an unrelated semantics property. */ private fun setTextByResourceId(resourceName: String, text: String) { val (provider, virtualViewId) = accessibilityNodePublishing(resourceName) @@ -237,6 +244,20 @@ class TestTagsAsResourceIdsTest { "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 " + @@ -871,6 +892,13 @@ class TestTagsAsResourceIdsTest { /** Six digits, the length both code screens expect. */ const val VERIFICATION_CODE = "123456" + /** + * The class a crawler requires before it will type into a node. Robo's `inputText` directives + * are documented for `EditText` only, and the class is what a crawler reads to decide a node + * accepts text — not the action list, which a plain container can also carry. + */ + 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. 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 index 260d1fd20..9d7d8bf48 100644 --- 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 @@ -18,8 +18,10 @@ 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 @@ -73,6 +75,14 @@ class VerificationCodeInputFieldSemanticsTest { /** 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. @@ -136,7 +146,7 @@ class VerificationCodeInputFieldSemanticsTest { invokeSetText("12") - assertWithMessage("A non-digit cannot be shown in a numeric box.") + 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() @@ -189,6 +199,92 @@ class VerificationCodeInputFieldSemanticsTest { assertThat(currentCode()).isEqualTo("654321") } + /** + * Inserting nothing is a no-op, and a no-op reports success — including on a full code, where + * there is no empty box to insert into. The two guards in `insertTextAtCursor` are ordered for + * this: the emptiness check runs before the "nowhere to insert" check, so an empty insert cannot + * be reported as a failed action. + */ + @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 the actions use, which is `Character.isDigit`'s rather than ASCII + * `0`-`9`: a code in Arabic-Indic or fullwidth digits is accepted and normalised to its ASCII + * value. + * + * This is behaviour worth pinning rather than narrowing. The per-box keyboard path normalises the + * same way, so restricting only these two actions to ASCII would leave a code that can be typed by + * hand on a localised keypad refused through `ACTION_SET_TEXT`. Nothing malformed gets in either + * way — the length and digit checks still hold, so what arrives is a complete, well-formed code. + */ + @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 — tapping the third box moves focus straight to it — so + * compacting the whole list would report `[1, 2, null, 4]` as `"124"` and place a digit at a + * position that is empty. A prefix understates instead, which is what a text field with a cursor + * means anyway and what `insertTextAtCursor` appends to. `editableText` is read back by anything + * holding the node, a crawler included, so it has to be positionally honest. + */ + @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)[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)[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`() { @@ -207,5 +303,14 @@ class VerificationCodeInputFieldSemanticsTest { * stands in for that too. */ const val CODE_FIELD_TAG = "verification_code_group_under_test" + + /** The content description each digit box carries, used to reach one box directly. */ + 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/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..1ed7ab3ee 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 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 e9d432017..32eb8ffec 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 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 044802fb6..6fbda9f6d 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 From aa017015225f21f3dc6e608f41cef6e7c5758739 Mon Sep 17 00:00:00 2001 From: demolaf Date: Wed, 19 Aug 2026 09:35:45 +0100 Subject: [PATCH 09/16] docs(auth): document test tags for Firebase Test Lab and Play pre-launch --- auth/README.md | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) 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:** From 525bdcaab0d824268d4095b89a37b33e9e04417c Mon Sep 17 00:00:00 2001 From: demolaf Date: Wed, 19 Aug 2026 10:04:48 +0100 Subject: [PATCH 10/16] feat(auth): give verification code digits distinct localized descriptions --- .../string_provider/AuthUIStringProvider.kt | 8 +++ .../DefaultAuthUIStringProvider.kt | 7 +++ .../components/VerificationCodeInputField.kt | 9 +++- auth/src/main/res/values/strings.xml | 1 + ...VerificationCodeInputFieldSemanticsTest.kt | 31 +++++++++--- .../firebase/ui/auth/ui/AccessibilityTest.kt | 49 +++++++++++++++++-- 6 files changed, 91 insertions(+), 14 deletions(-) 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..546a81856 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,14 @@ interface AuthUIStringProvider { /** Label for verification code input fields. */ val verificationCodeLabel: String + /** + * Content description for a single box in the verification code input, announced + * positionally by assistive technology (for example, "Verification code digit 3 of 6") so + * that each box is distinguishable from the others. [position] is the box's 1-indexed + * position; [total] is the total number of boxes. + */ + fun verificationCodeDigitDescription(position: Int, total: Int): String + /** 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/components/VerificationCodeInputField.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/VerificationCodeInputField.kt index 141658a8b..be3262a33 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 @@ -64,6 +64,7 @@ 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 @@ -126,6 +127,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("") } @@ -279,6 +281,10 @@ fun VerificationCodeInputField( .aspectRatio(1f), number = number, isError = showError, + digitContentDescription = stringProvider.verificationCodeDigitDescription( + position = index + 1, + total = codeLength + ), focusRequester = focusRequesters[index], onFocusChanged = { isFocused -> if (isFocused) { @@ -328,6 +334,7 @@ private fun SingleDigitField( modifier: Modifier = Modifier, number: Int?, isError: Boolean = false, + digitContentDescription: String, focusRequester: FocusRequester, onFocusChanged: (Boolean) -> Unit, onNumberChanged: (Int?) -> Unit, @@ -390,7 +397,7 @@ private fun SingleDigitField( .fillMaxSize() .wrapContentSize() .semantics { - contentDescription = "Verification code digit" + contentDescription = digitContentDescription } .focusRequester(focusRequester) .onFocusChanged { 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/components/VerificationCodeInputFieldSemanticsTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/components/VerificationCodeInputFieldSemanticsTest.kt index 9d7d8bf48..8b7f2659d 100644 --- 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 @@ -14,6 +14,8 @@ 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 @@ -26,6 +28,9 @@ 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 @@ -61,14 +66,20 @@ class VerificationCodeInputFieldSemanticsTest { 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 { - VerificationCodeInputField( - modifier = Modifier.testTag(CODE_FIELD_TAG), - codeLength = codeLength, - onCodeChange = { entered += it } - ) + CompositionLocalProvider( + LocalAuthUIStringProvider provides stringProvider + ) { + VerificationCodeInputField( + modifier = Modifier.testTag(CODE_FIELD_TAG), + codeLength = codeLength, + onCodeChange = { entered += it } + ) + } } } @@ -263,7 +274,7 @@ class VerificationCodeInputFieldSemanticsTest { assertThat(editableText()).isEqualTo("12") // A gap: the fourth box is filled directly, as tapping it and typing would. - composeTestRule.onAllNodesWithContentDescription(DIGIT_BOX_DESCRIPTION)[3] + composeTestRule.onAllNodesWithContentDescription(DIGIT_BOX_DESCRIPTION, substring = true)[3] .performTextInput("4") composeTestRule.waitForIdle() @@ -278,7 +289,7 @@ class VerificationCodeInputFieldSemanticsTest { ).that(currentCode()).isEqualTo("124") // Filling the gap makes the prefix the whole code again. - composeTestRule.onAllNodesWithContentDescription(DIGIT_BOX_DESCRIPTION)[2] + composeTestRule.onAllNodesWithContentDescription(DIGIT_BOX_DESCRIPTION, substring = true)[2] .performTextInput("3") composeTestRule.waitForIdle() @@ -304,7 +315,11 @@ class VerificationCodeInputFieldSemanticsTest { */ const val CODE_FIELD_TAG = "verification_code_group_under_test" - /** The content description each digit box carries, used to reach one box directly. */ + /** + * The shared prefix of every digit box's content description (each box's full + * description is positional, e.g. "Verification code digit 3 of 6"), used with a + * substring match to reach one box directly by its index in the row. + */ const val DIGIT_BOX_DESCRIPTION = "Verification code digit" /** `123456` in Arabic-Indic digits. */ 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 1ed7ab3ee..7751ad7d2 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 @@ -63,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 @@ -75,6 +79,41 @@ class AccessibilityTest { composeTestRule.waitForIdle() } + @Test + fun verificationCodeInputField_digitBoxesHaveDistinctPositionalDescriptions() { + val codeLength = 6 + + composeTestRule.setContent { + CompositionLocalProvider( + LocalAuthUIStringProvider provides stringProvider + ) { + VerificationCodeInputField( + codeLength = codeLength, + onCodeComplete = {}, + onCodeChange = {} + ) + } + } + + // Every box's description is distinct and positional (e.g. "digit 3 of 6"), not the + // single shared literal all six boxes used to carry. Asserting each expected positional + // string resolves to exactly one node proves both properties: the descriptions differ + // from each other and each one names its own position. + 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 { From c61b1176a731c0665aa0ccf2dce5fa09e4528643 Mon Sep 17 00:00:00 2001 From: demolaf Date: Wed, 19 Aug 2026 10:05:07 +0100 Subject: [PATCH 11/16] feat(auth): tag remaining secondary auth surfaces as resource ids --- .../ui/auth/ui/FirebaseAuthTestTags.kt | 136 ++++++++++ .../ui/auth/ui/components/AuthTextField.kt | 7 + .../auth/ui/components/ErrorRecoveryDialog.kt | 8 +- .../auth/ui/components/TermsAndPrivacyForm.kt | 11 +- .../auth/ui/screens/MfaChallengeDefaults.kt | 8 +- .../auth/ui/screens/MfaEnrollmentDefaults.kt | 51 +++- .../auth/ui/screens/email/ResetPasswordUI.kt | 7 +- .../ui/screens/email/SignInEmailLinkUI.kt | 8 +- .../ui/auth/ui/screens/email/SignInUI.kt | 10 +- .../ui/auth/ui/screens/email/SignUpUI.kt | 15 +- .../ui/screens/phone/EnterPhoneNumberUI.kt | 5 +- .../screens/phone/EnterVerificationCodeUI.kt | 7 +- .../ui/auth/ui/MainSourceTestTagUsageTest.kt | 2 +- .../ui/auth/ui/TestTagsAsResourceIdsTest.kt | 243 +++++++++++++++++- 14 files changed, 494 insertions(+), 24 deletions(-) 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 index dcdb2b6bc..09af0c8dd 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/FirebaseAuthTestTags.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/FirebaseAuthTestTags.kt @@ -84,6 +84,12 @@ object FirebaseAuthTestTags { /** 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. */ @@ -106,6 +112,16 @@ object FirebaseAuthTestTags { /** 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. */ @@ -122,6 +138,9 @@ object FirebaseAuthTestTags { /** 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. */ @@ -138,6 +157,12 @@ object FirebaseAuthTestTags { /** 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. */ @@ -151,6 +176,9 @@ object FirebaseAuthTestTags { /** 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. */ @@ -177,6 +205,9 @@ object FirebaseAuthTestTags { /** 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" } /** @@ -200,6 +231,19 @@ object FirebaseAuthTestTags { /** 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 "use a different method" control and the TOTP "dismiss" control: the + * two are rendered by an `if (isSms) {...} else {...}` branch in + * [com.firebase.ui.auth.ui.screens.DefaultMfaChallengeContent] and so never compose at the + * same time, and both are bound to the same `onCancelClick` callback. + */ + const val CANCEL_BUTTON = "fui_mfa_challenge_cancel_button" } /** @@ -230,4 +274,96 @@ object FirebaseAuthTestTags { /** The button that dismisses the dialog without re-authenticating. */ const val DISMISS_BUTTON = "fui_reauth_dismiss_button" } + + /** + * Tags on the error recovery dialog shown by [com.firebase.ui.auth.ui.components.TopLevelDialogController]. + * + * The dialog is rendered from exactly one call site — [com.firebase.ui.auth.ui.components.TopLevelDialogController.CurrentDialog], + * itself composed once at the root of a flow — so a shared value for the retry action is safe + * even though the button's label text changes with the [com.firebase.ui.auth.AuthException] + * subtype being recovered from: only one instance of the dialog is ever composed at a time. + */ + object ErrorRecovery { + + /** + * The recovery/retry action, shown when the error is recoverable. Its label varies with the + * exception type (retry, sign in, continue, dismiss, ...), but it is always the same button + * instance, so one constant addresses it regardless of which error is showing. + */ + 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 [com.firebase.ui.auth.ui.components.TermsAndPrivacyForm], the terms-of-service and + * privacy-policy links shown at the bottom of several screens. + * + * Component-scoped rather than duplicated per screen, for the same reason as [MethodPicker] and + * [CountrySelector]: every call site renders behind a mutually exclusive `when` or `if` in its + * screen's parent (see [com.firebase.ui.auth.ui.screens.email.EmailAuthScreen] and + * [com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen]), so no two instances are ever composed + * at once and a shared value still resolves to exactly one node. + */ + 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 — the screens a user configures a second factor + * through, as opposed to [MfaChallenge], which is the second factor they are asked for during an + * ordinary sign-in. + * + * Two shapes here render more than one instance of the same node at once, and are keyed by + * factor rather than given one shared value: [SelectFactor][com.firebase.ui.auth.ui.screens.MfaEnrollmentDefaults] + * lists one enroll button per not-yet-enrolled factor, and one remove button per already-enrolled + * factor, so a user who has enrolled neither factor sees two enroll buttons at once, and a user + * enrolled in both sees two remove buttons 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. Distinct from [CONFIGURE_TOTP_BACK_BUTTON]'s step, which only displays + * the secret and QR code and collects nothing. + */ + 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/components/AuthTextField.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthTextField.kt index 253a6e260..cdd5645b3 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,11 @@ 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 applied to the password visibility toggle button + * shown when [isSecureTextField] is `true`. Distinct from [modifier], which applies to the field + * itself: the toggle is a separate node, so a caller that needs to address it — for example to + * apply a test tag — passes one here rather than expecting [modifier] to reach it. Defaults to + * [Modifier], leaving the toggle untagged for callers that don't need to address it. */ @Composable fun AuthTextField( @@ -103,6 +108,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 +173,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/ErrorRecoveryDialog.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt index b7fe2415d..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,11 @@ 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 @@ -100,6 +102,7 @@ fun ErrorRecoveryDialog( confirmButton = { if (isRecoverable(error)) { TextButton( + modifier = Modifier.testTag(FirebaseAuthTestTags.ErrorRecovery.RETRY_BUTTON), onClick = { onRecover?.invoke(error) ?: onRetry(error) } @@ -112,7 +115,10 @@ fun ErrorRecoveryDialog( } }, dismissButton = { - TextButton(onClick = onDismiss) { + TextButton( + modifier = Modifier.testTag(FirebaseAuthTestTags.ErrorRecovery.DISMISS_BUTTON), + onClick = onDismiss + ) { Text( text = stringProvider.dismissAction, style = MaterialTheme.typography.labelLarge 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..26d8e8234 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,14 @@ fun TermsAndPrivacyForm( ) { val uriHandler = LocalUriHandler.current Row( - modifier = modifier, + // This component is never composed as its own semantics owner — every call site sits + // inside a screen Scaffold that already flags itself — but the flag is applied here as + // well so the tags above remain exposed even for a future caller that hosts this + // component with no flagged ancestor of its own. Setting the property twice is a no-op. + modifier = modifier.exposeTestTagsAsResourceIds(), ) { TextButton( + modifier = Modifier.testTag(FirebaseAuthTestTags.TermsAndPrivacy.TOS_LINK), onClick = { tosUrl?.let { uriHandler.openUri(it) @@ -57,6 +65,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/screens/MfaChallengeDefaults.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeDefaults.kt index 28ff214ad..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 @@ -119,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 ) { @@ -135,6 +138,7 @@ internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) { } TextButton( + modifier = Modifier.testTag(FirebaseAuthTestTags.MfaChallenge.CANCEL_BUTTON), onClick = state.onCancelClick, enabled = !state.isLoading ) { @@ -145,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) } 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 9758f55f3..06cf0f028 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,6 +58,7 @@ 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 @@ -322,11 +324,22 @@ private fun SelectFactorUI( modifier = Modifier.fillMaxWidth() ) + // One button per not-yet-enrolled factor renders in this loop, so a user enrolled + // in neither factor sees both buttons at once. A shared tag would collide exactly + // the way the registry's uniqueness test cannot catch on its own, so each factor + // gets its own constant, keyed off the same `when` branch used for the label. 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) @@ -346,6 +359,7 @@ private fun SelectFactorUI( onSkipClick?.let { TextButton( + modifier = Modifier.testTag(FirebaseAuthTestTags.MfaEnrollment.SKIP_BUTTON), onClick = it, enabled = !isLoading ) { @@ -405,9 +419,20 @@ private fun EnrolledFactorItem( color = MaterialTheme.colorScheme.onSurfaceVariant ) } + // This item is rendered once per enrolled factor by a `forEach` in the caller, so a + // user enrolled in both SMS and TOTP composes two of these at once. The same collision + // risk as the enroll buttons above, resolved the same way: key the tag off the factor + // type rather than sharing one value. 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 ) @@ -490,7 +515,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) } @@ -498,7 +525,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) } @@ -556,7 +585,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( @@ -566,7 +597,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) } @@ -574,7 +607,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) } @@ -642,7 +677,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 feb6c0240..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 @@ -127,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 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 321df5b0b..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 @@ -138,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 @@ -171,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() }, 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 2956abaeb..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 @@ -159,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 @@ -201,7 +204,10 @@ fun SignInUI( }, onValueChange = { text -> onPasswordChange(text) - } + }, + visibilityToggleModifier = Modifier.testTag( + FirebaseAuthTestTags.SignIn.PASSWORD_VISIBILITY_TOGGLE + ) ) Spacer(modifier = Modifier.height(8.dp)) TextButton( 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 72c97fdce..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 @@ -114,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 @@ -171,7 +174,10 @@ fun SignUpUI( }, onValueChange = { text -> onPasswordChange(text) - } + }, + visibilityToggleModifier = Modifier.testTag( + FirebaseAuthTestTags.SignUp.PASSWORD_VISIBILITY_TOGGLE + ) ) Spacer(modifier = Modifier.height(16.dp)) AuthTextField( @@ -185,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( 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 83acc2749..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 @@ -93,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 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 4a114183a..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 @@ -97,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 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 index 820d8c4d6..be25c9c5c 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/MainSourceTestTagUsageTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/MainSourceTestTagUsageTest.kt @@ -639,7 +639,7 @@ class MainSourceTestTagUsageTest { * stops reaching files announces itself. A floor rather than an exact count: tagging more * nodes is the expected direction of travel and should not redden an unrelated build. */ - const val MINIMUM_TAG_CALL_SITES = 35 + const val MINIMUM_TAG_CALL_SITES = 63 /** * Lower bound on the number of recognised semantics owner constructions outside `@Preview` 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 index e238a8c6b..91272be8f 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/TestTagsAsResourceIdsTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/TestTagsAsResourceIdsTest.kt @@ -53,11 +53,14 @@ import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringPro 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 @@ -70,6 +73,9 @@ 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 @@ -387,7 +393,7 @@ class TestTagsAsResourceIdsTest { isLoading = false, emailSignInLinkSent = false, email = "", - password = "", + password = "password123", onEmailChange = { }, onPasswordChange = { }, onRetrievedCredential = { }, @@ -395,6 +401,7 @@ class TestTagsAsResourceIdsTest { onGoToSignUp = { }, onGoToResetPassword = { }, onGoToEmailLinkSignIn = { }, + onNavigateBack = { }, ) } @@ -404,6 +411,37 @@ class TestTagsAsResourceIdsTest { 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()) + } + + /** + * The password visibility toggle on [AuthTextField] is a shared, low-level component reused by + * every password-holding screen, so it needs a distinct tag per call site — this pins the + * end-to-end path for the sign-in screen's toggle: [AuthTextField]'s new + * `visibilityToggleModifier` parameter reaching a real Android resource id. + */ + @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 @@ -414,14 +452,15 @@ class TestTagsAsResourceIdsTest { isLoading = false, displayName = "", email = "", - password = "", - confirmPassword = "", + password = "password123", + confirmPassword = "password123", onDisplayNameChange = { }, onEmailChange = { }, onPasswordChange = { }, onConfirmPasswordChange = { }, onGoToSignIn = { }, onSignUpClick = { }, + onNavigateBack = { }, ) } @@ -431,6 +470,12 @@ class TestTagsAsResourceIdsTest { 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 @@ -444,12 +489,14 @@ class TestTagsAsResourceIdsTest { 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 @@ -464,12 +511,15 @@ class TestTagsAsResourceIdsTest { 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 @@ -483,12 +533,14 @@ class TestTagsAsResourceIdsTest { 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 @@ -504,6 +556,7 @@ class TestTagsAsResourceIdsTest { onVerifyCodeClick = { }, onResendCodeClick = { }, onChangeNumberClick = { }, + onNavigateBack = { }, ) } @@ -517,6 +570,7 @@ class TestTagsAsResourceIdsTest { FirebaseAuthTestTags.VerificationCode.CHANGE_PHONE_NUMBER_BUTTON, button() ) + assertExposedAsResourceId(FirebaseAuthTestTags.VerificationCode.BACK_BUTTON, button()) } // ============================================================================================= @@ -643,6 +697,31 @@ class TestTagsAsResourceIdsTest { 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 controls that render in + * mutually exclusive branches of [DefaultMfaChallengeContent] — the SMS "use a different + * method" button and the TOTP "dismiss" button. This pins the TOTP side of that sharing: the + * same tag resolves to exactly one node here too, distinct from the SMS fixture 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 @@ -773,6 +852,25 @@ class TestTagsAsResourceIdsTest { 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 // ============================================================================================= @@ -802,6 +900,145 @@ class TestTagsAsResourceIdsTest { ) } + // ============================================================================================= + // MFA enrollment: the factor-selection step, and the collision it is built to avoid + // ============================================================================================= + + /** + * [DefaultMfaEnrollmentContent] renders one enroll button per not-yet-enrolled factor in a + * `forEach`, so a user enrolled in neither factor composes both buttons at once — the exact + * shape of collision this whole tag registry exists to prevent (see + * [com.firebase.ui.auth.ui.FirebaseAuthTestTags.MfaEnrollment]). This proves both are + * addressable *at the same time*, not merely that each works in isolation: a fixture that + * rendered them one at a time would not catch 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()) + } + + /** + * The mirror case on [com.firebase.ui.auth.ui.screens.MfaEnrollmentDefaults]'s + * `EnrolledFactorItem`: a user enrolled in both SMS and TOTP composes one remove button per + * factor in the same `forEach`, so the two need to be addressable at once as well. + */ + @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 step's code field is the highest-value new node in the enrollment + * flow — it is the one place a Robo directive has to type a real value rather than merely tap + * a button — so it gets the same `ACTION_SET_TEXT` treatment as the phone verification code + * field above, rather than only an existence check. + */ + @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 // ============================================================================================= From 16b38efe47d6b1bf061eaede580c163566c4d294 Mon Sep 17 00:00:00 2001 From: demolaf Date: Wed, 19 Aug 2026 11:09:42 +0100 Subject: [PATCH 12/16] docs(auth): record why AuthTextField exposes internal elements via Modifier params --- .../firebase/ui/auth/ui/components/AuthTextField.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 cdd5645b3..3f82217e5 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 @@ -91,6 +91,17 @@ import com.firebase.ui.auth.configuration.validators.PasswordValidator * itself: the toggle is a separate node, so a caller that needs to address it — for example to * apply a test tag — passes one here rather than expecting [modifier] to reach it. Defaults to * [Modifier], leaving the toggle untagged for callers that don't need to address it. + * + * This is the template for exposing a shared component's internal, non-root elements to a + * specific screen: a plain `Modifier` parameter per addressable internal node, rather than a + * `String` tag threaded in and applied with `.testTag(it)` inside the component. A `String` here + * would defeat [com.firebase.ui.auth.ui.FirebaseAuthTestTags]'s registry-provenance guard, which + * requires every `testTag(...)` argument in main sources to be a literal + * `FirebaseAuthTestTags..` reference — a variable would either bypass that check or + * fail it outright. Screens instead build the tag from the registry and pass it down as a + * `Modifier.testTag(...)`, keeping the literal reference at the call site that owns it. Follow + * this shape for any future internal element on a shared component that a screen needs to + * address individually. */ @Composable fun AuthTextField( From 7cc0d07ebf905a2dc319d0e71cc168b79322fd51 Mon Sep 17 00:00:00 2001 From: demolaf Date: Wed, 19 Aug 2026 11:56:28 +0100 Subject: [PATCH 13/16] docs(auth): trim visibilityToggleModifier KDoc to one line --- .../ui/auth/ui/components/AuthTextField.kt | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) 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 3f82217e5..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,22 +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 applied to the password visibility toggle button - * shown when [isSecureTextField] is `true`. Distinct from [modifier], which applies to the field - * itself: the toggle is a separate node, so a caller that needs to address it — for example to - * apply a test tag — passes one here rather than expecting [modifier] to reach it. Defaults to - * [Modifier], leaving the toggle untagged for callers that don't need to address it. - * - * This is the template for exposing a shared component's internal, non-root elements to a - * specific screen: a plain `Modifier` parameter per addressable internal node, rather than a - * `String` tag threaded in and applied with `.testTag(it)` inside the component. A `String` here - * would defeat [com.firebase.ui.auth.ui.FirebaseAuthTestTags]'s registry-provenance guard, which - * requires every `testTag(...)` argument in main sources to be a literal - * `FirebaseAuthTestTags..` reference — a variable would either bypass that check or - * fail it outright. Screens instead build the tag from the registry and pass it down as a - * `Modifier.testTag(...)`, keeping the literal reference at the call site that owns it. Follow - * this shape for any future internal element on a shared component that a screen needs to - * address individually. + * @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( From 1bb54f73b251dc7eb527a7f916c4c767ead6e456 Mon Sep 17 00:00:00 2001 From: demolaf Date: Wed, 19 Aug 2026 12:16:30 +0100 Subject: [PATCH 14/16] docs(auth): trim comments and KDoc across the test-tag branch to 2 lines --- .../string_provider/AuthUIStringProvider.kt | 6 +- .../ui/auth/ui/FirebaseAuthTestTags.kt | 120 ++-------- .../ui/auth/ui/TestTagsAsResourceIds.kt | 34 +-- .../auth/ui/components/AuthProviderButton.kt | 6 +- .../auth/ui/components/TermsAndPrivacyForm.kt | 6 +- .../components/VerificationCodeInputField.kt | 83 ++----- .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 32 +-- .../auth/ui/screens/MfaEnrollmentDefaults.kt | 23 +- .../auth/ui/screens/phone/PhoneAuthScreen.kt | 16 +- .../ui/auth/ui/FirebaseAuthTestTagsTest.kt | 20 +- .../ui/auth/ui/MainSourceTestTagUsageTest.kt | 219 +++-------------- .../ui/auth/ui/TestTagsAsResourceIdsTest.kt | 226 ++++-------------- .../ui/components/AuthProviderButtonTest.kt | 45 +--- .../auth/ui/components/CountrySelectorTest.kt | 8 +- ...VerificationCodeInputFieldSemanticsTest.kt | 52 +--- .../ui/method_picker/AuthMethodPickerTest.kt | 4 +- .../screens/FirebaseAuthScreenModifierTest.kt | 52 +--- .../email/SignInEmailLinkUIModifierTest.kt | 7 +- .../ui/auth/ui/screens/email/SignInUITest.kt | 9 +- .../phone/PhoneAuthScreenModifierTest.kt | 10 +- .../firebase/ui/auth/ui/AccessibilityTest.kt | 6 +- .../ui/screens/CredentialLinkingScreenTest.kt | 6 +- .../ui/auth/ui/screens/PhoneAuthScreenTest.kt | 12 +- 23 files changed, 199 insertions(+), 803 deletions(-) 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 546a81856..58b48e4d2 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 @@ -420,10 +420,8 @@ interface AuthUIStringProvider { val verificationCodeLabel: String /** - * Content description for a single box in the verification code input, announced - * positionally by assistive technology (for example, "Verification code digit 3 of 6") so - * that each box is distinguishable from the others. [position] is the box's 1-indexed - * position; [total] is the total number of boxes. + * 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 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 index 09af0c8dd..81c5cd819 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/FirebaseAuthTestTags.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/FirebaseAuthTestTags.kt @@ -15,33 +15,8 @@ package com.firebase.ui.auth.ui /** - * Stable Compose test tags applied by the FirebaseUI Auth screens. - * - * These values are **public API**. Host applications reference them from their own UI tests, and - * they are intended to be surfaced as Android resource ids so that Firebase Test Lab Robo - * directives and Play pre-launch reports can target the auth surfaces. Renaming or removing a - * constant — or changing the string a constant resolves to — is a **breaking change**, not an - * internal refactor. - * - * Constants are grouped by the screen or surface that owns them, and every value repeats that - * surface as a segment so a `By.res` prefix match selects exactly one surface. Within a group the - * element type is the **last** token of both the constant name and the value, so sibling nodes sort - * and complete together. - * - * Values are lowercase `snake_case` with a `fui_` prefix. The prefix is not decoration: once these - * tags are exposed as resource ids they land in the host application's `id` namespace, so it - * namespaces our tags away from the host app's own resource ids and keeps `By.res()` lookups - * unambiguous. - * - * ## Usage Example: - * - * ```kotlin - * composeTestRule - * .onNodeWithTag(FirebaseAuthTestTags.MethodPicker.PROVIDER_LIST) - * .performScrollToNode(hasText("Sign in with Google")) - * ``` - * - * @since 10.0.0 + * 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 { @@ -185,15 +160,8 @@ object FirebaseAuthTestTags { object VerificationCode { /** - * The verification code input. - * - * The code is drawn as one box per digit, and this names the group rather than any one box. - * The group is the editable node: it accepts a whole code in a single `ACTION_SET_TEXT` or - * `performTextInput` and spreads it across the boxes, so one Robo directive - * (`{"resourceName": "fui_verification_code_code_field", "inputText": "123456"}`) enters the - * whole thing. See - * [com.firebase.ui.auth.ui.components.VerificationCodeInputField] for why the boxes - * themselves are not addressable. + * 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" @@ -211,22 +179,12 @@ object FirebaseAuthTestTags { } /** - * Tags on the multi-factor sign-in challenge screen — the second factor a user is asked for - * after their password, which is part of a plain sign-in and not of MFA enrollment. - * - * Separate from [VerificationCode] even though both screens show the same code input: the SMS - * step of a phone sign-in and the second-factor challenge are different screens reached by - * different routes, and giving them one value would leave a `By.res` match unable to say which - * screen it landed on. + * 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. - * - * Accepts a whole code in one action, exactly as - * [VerificationCode.CODE_FIELD] does — the two screens share the input widget. - */ + /** 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. */ @@ -236,32 +194,15 @@ object FirebaseAuthTestTags { 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 "use a different method" control and the TOTP "dismiss" control: the - * two are rendered by an `if (isSms) {...} else {...}` branch in - * [com.firebase.ui.auth.ui.screens.DefaultMfaChallengeContent] and so never compose at the - * same time, and both are bound to the same `onCancelClick` callback. + * 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. - * - * Deliberately a group of its own rather than reusing [SignIn]: the re-authentication surface - * and the flow behind it are composed at the same time while the dialog is open, so a shared - * value would match two nodes and neither could be addressed. - * - * That reasoning covers the tags declared here, but not everything the re-authentication surface - * can show. The default re-authentication bottom sheet re-enters the ordinary email flow to - * collect a password, so [SignIn.EMAIL_FIELD] and its siblings appear *inside* the sheet, under - * the same values they carry on the sign-in screen. Nothing collides today, because the sheet is - * only raised from the post-sign-in surface and no sign-in screen is composed behind it — so - * each value still resolves to one node. What a crawler cannot do is tell from the resource id - * alone whether it is looking at the sign-in screen or at the re-authentication sheet; a test - * that needs to distinguish them has to key off something else on the surface, and anything - * that raises the sheet over a live sign-in screen would turn this into a real collision. + * 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 { @@ -275,21 +216,10 @@ object FirebaseAuthTestTags { const val DISMISS_BUTTON = "fui_reauth_dismiss_button" } - /** - * Tags on the error recovery dialog shown by [com.firebase.ui.auth.ui.components.TopLevelDialogController]. - * - * The dialog is rendered from exactly one call site — [com.firebase.ui.auth.ui.components.TopLevelDialogController.CurrentDialog], - * itself composed once at the root of a flow — so a shared value for the retry action is safe - * even though the button's label text changes with the [com.firebase.ui.auth.AuthException] - * subtype being recovered from: only one instance of the dialog is ever composed at a time. - */ + /** Tags on the error recovery dialog. Only one instance is ever composed at a time. */ object ErrorRecovery { - /** - * The recovery/retry action, shown when the error is recoverable. Its label varies with the - * exception type (retry, sign in, continue, dismiss, ...), but it is always the same button - * instance, so one constant addresses it regardless of which error is showing. - */ + /** 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. */ @@ -297,14 +227,8 @@ object FirebaseAuthTestTags { } /** - * Tags on [com.firebase.ui.auth.ui.components.TermsAndPrivacyForm], the terms-of-service and - * privacy-policy links shown at the bottom of several screens. - * - * Component-scoped rather than duplicated per screen, for the same reason as [MethodPicker] and - * [CountrySelector]: every call site renders behind a mutually exclusive `when` or `if` in its - * screen's parent (see [com.firebase.ui.auth.ui.screens.email.EmailAuthScreen] and - * [com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen]), so no two instances are ever composed - * at once and a shared value still resolves to exactly one node. + * 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 { @@ -316,15 +240,8 @@ object FirebaseAuthTestTags { } /** - * Tags on the multi-factor enrollment flow — the screens a user configures a second factor - * through, as opposed to [MfaChallenge], which is the second factor they are asked for during an - * ordinary sign-in. - * - * Two shapes here render more than one instance of the same node at once, and are keyed by - * factor rather than given one shared value: [SelectFactor][com.firebase.ui.auth.ui.screens.MfaEnrollmentDefaults] - * lists one enroll button per not-yet-enrolled factor, and one remove button per already-enrolled - * factor, so a user who has enrolled neither factor sees two enroll buttons at once, and a user - * enrolled in both sees two remove buttons at once. + * 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 { @@ -352,8 +269,7 @@ object FirebaseAuthTestTags { /** * The input for the code generated by the user's authenticator app, on the TOTP - * verification step. Distinct from [CONFIGURE_TOTP_BACK_BUTTON]'s step, which only displays - * the secret and QR code and collects nothing. + * verification step. */ const val VERIFY_TOTP_CODE_FIELD = "fui_mfa_enrollment_verify_totp_code_field" 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 index 452b43703..d4eb3fb6f 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/TestTagsAsResourceIds.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/TestTagsAsResourceIds.kt @@ -19,38 +19,8 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId /** - * Publishes the [FirebaseAuthTestTags] applied beneath this node as Android resource ids, so that - * they appear as `viewIdResourceName` on the accessibility node. - * - * This is what makes the tags addressable from outside a Compose test: Firebase Test Lab Robo - * directives take resource names, Google Play pre-launch reports drive the same Robo crawler, and - * UiAutomator's `By.res()` matches on the same field. Without it a tag is visible only to Compose's - * own test APIs, which is no help to a crawler — the gap that made the 9.x `@id/email` and - * `@id/password` targets unreachable after the Compose rewrite. - * - * Apply it once per **semantics owner**, not once per screen. The flag is read by walking a node's - * semantics ancestors, and that walk stops at the root of the window the node lives in. Every dialog - * and bottom sheet Compose shows is hosted in its own window with its own semantics root, so it - * inherits nothing from the composable that opened it and has to carry the flag itself. Sibling - * branches within one window are a quieter version of the same trap: a `Scaffold` per step of a - * wizard inherits nothing from the `Scaffold` of the step beside it. - * - * ## The rule: flag the owner, tags or not - * - * **Every semantics owner the library creates carries this flag, whether or not anything inside it - * is tagged today.** Flagging is applied to owners rather than to tags on purpose, and the reason is - * asymmetry of failure. Flagging an owner that holds no tags costs nothing — the property is - * `isImportantForAccessibility = false`, so it changes no accessibility behaviour and is invisible - * until a tag appears beneath it. Adding a tag inside an owner that was skipped costs a silent - * regression: `onNodeWithTag(...).assertExists()` still passes, the suite stays green, and only a - * Robo directive or `By.res()` — neither of which runs in CI — can tell that the tag never became a - * resource id. - * - * So the condition for applying it is "the library creates a semantics owner here", never "there is - * a tag under here worth exposing". Owners currently flagged with nothing tagged inside them are - * deliberate and should not be pruned as dead code: the loading dialog, the default - * re-authentication bottom sheet, the manage-MFA tooltip, and the TOTP enrollment steps are all in - * that state, and each of them is one added tag away from needing it. + * 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 face71d97..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 @@ -67,10 +67,8 @@ import com.firebase.ui.auth.configuration.theme.ProviderStyleDefaults * ) * ``` * - * @param modifier Applied to the button itself, and to nothing else — the content row sizes - * independently. Note that the content row fills the available width unconditionally, so the button - * renders full-width whatever width this modifier asks for; constrain it from the parent layout - * instead (for example by placing the button in a fixed-width container). + * @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. 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 26d8e8234..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 @@ -41,10 +41,8 @@ fun TermsAndPrivacyForm( ) { val uriHandler = LocalUriHandler.current Row( - // This component is never composed as its own semantics owner — every call site sits - // inside a screen Scaffold that already flags itself — but the flag is applied here as - // well so the tags above remain exposed even for a future caller that hosts this - // component with no flagged ancestor of its own. Setting the property twice is a no-op. + // 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( 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 be3262a33..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 @@ -69,42 +69,11 @@ 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. + * 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). * - * ## Why the group, and not the boxes, is the editable node - * - * Visually this is one field; structurally it is [codeLength] separate `BasicTextField`s, each of - * which rejects anything longer than a single digit. That split is invisible to a user and fatal to - * anything driving the screen from outside Compose. A Firebase Test Lab Robo directive, a Play - * pre-launch crawl, and UiAutomator all type by issuing `ACTION_SET_TEXT` against one accessibility - * node, and there was no node that could accept a whole code: the boxes take one character each and - * are indistinguishable from one another in the accessibility tree, while the container that a - * caller can address by test tag carried no text-input action at all. A directive naming it resolved - * a node and typed nothing — the shape of issue #2050, where Robo found the login field but could - * not fill the password. - * - * So the container declares the text-input semantics itself: [setText] replaces the whole code and - * [insertTextAtCursor] fills forward from the first empty box, both spreading the string they are - * given across the boxes. One `ACTION_SET_TEXT` carrying `"123456"` therefore enters the entire - * code, and `performTextInput` works on the container in a host application's own Compose tests. - * [editableText] and [isEditable] are declared alongside them because they are what makes the - * platform describe this node as an editable field rather than a plain container, which is how a - * crawler decides a node is worth typing into at all. - * - * The cost is that the group is now a node accessibility services will visit, sitting above - * [codeLength] boxes they also visit, so a screen reader reads the code once and then the boxes. - * That is accepted: it is more verbose, but the alternative leaves the code unreachable by every - * tool the tags exist for. - * - * Both actions refuse input they cannot represent — a character no digit box can show, or more digits - * than there are remaining boxes — rather than silently truncating it, so a caller sees a failed - * action instead of a half-entered code. "Digit" is `Character.isDigit`'s definition rather than ASCII - * `0`-`9`, so a code typed in Arabic-Indic or fullwidth digits is accepted and normalised to its ASCII - * value, exactly as the per-box keyboard path already treats it; see the comment on `digitsOf`. - * - * @param modifier Applied to the group. A [androidx.compose.ui.platform.testTag] passed here names - * the node that accepts the code, so it is the handle both Compose tests and resource-id lookups - * should use. + * @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]. @@ -181,17 +150,8 @@ fun VerificationCodeInputField( errorMessage } - // The digits of [text], or null when this field cannot hold them. Rejecting is deliberate: a - // caller that asked to enter "12a456" is better served by a failed action than by a code that - // silently lost a character. - // - // "Digit" here is Character.isDigit's definition, not ASCII 0-9: isDigitsOnly() and digitToInt() - // both accept Arabic-Indic ("١٢٣٤٥٦") and fullwidth ("123") digits and yield the same values as - // their ASCII counterparts, so such input is normalised rather than rejected. That is kept - // deliberately, because the per-box keyboard path below does exactly the same thing — narrowing - // only these two actions to ASCII would make a code typed on a localised keypad enterable by hand - // and refused through ACTION_SET_TEXT. Nothing malformed gets through either way: the length and - // digit checks still hold, so the outcome is a well-formed code in ASCII, never a partial one. + // 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 @@ -204,29 +164,15 @@ fun VerificationCodeInputField( Column( modifier = modifier - // Makes the group the node that accepts a whole code — see this composable's KDoc. - // Applied after [modifier] so that a testTag the caller passed lands on the same - // layout node as these actions, which is what lets one resource-id lookup both find - // this node and type into it. + // Applied after [modifier] so a caller-supplied testTag lands on the same node + // these text-input actions are declared on. .semantics { - // TODO: this group is an important, EditText-classed accessibility node with no - // label of its own, so TalkBack announces it as an unlabelled edit box. The label - // belongs here, on the node that owns the text-input semantics, and not on the - // digit boxes below — but it has to come from AuthUIStringProvider rather than be - // hardcoded, and adding a string to that public interface is tracked separately. It - // must be set as a `contentDescription`, not as `text`: Compose prefers Text over - // EditableText when deriving className, so a `text` property here would flip this - // node from EditText to TextView and stop a Robo crawler treating it as typeable - // (asserted in TestTagsAsResourceIdsTest.setTextByResourceId). + // TODO: give this group a proper label via AuthUIStringProvider (tracked + // separately). Must be contentDescription, not text — text breaks Robo detection. isEditable = true maxTextLength = codeLength - // The digits before the first empty box, not every digit entered. Boxes fill - // non-contiguously — tapping box 3 moves the focus there directly — so compacting - // the whole list would report [1, 2, null, 4] as "124" and claim a digit at a - // position that is actually empty. A prefix is always positionally true, and it is - // what insertTextAtCursor appends to, so the two agree. It can understate: with only - // box 3 filled this reads as empty. Note that the onCodeChange callback below is - // deliberately left compacted — screens depend on that contract. + // 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("") ) @@ -258,9 +204,8 @@ fun VerificationCodeInputField( true } - // Focus is moved by writing the index the widget already watches rather than by - // touching a FocusRequester directly, so this cannot throw if it is invoked before - // the boxes have been attached. + // 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 } 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 72fd1befe..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 @@ -104,21 +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] that hosts the flow's navigation graph. It is - * not forwarded to individual destinations, so it decorates whichever destination is showing rather - * than any single screen. Its reach stops at this composable's own window: content that Compose - * hosts in a separate semantics owner — every dialog and bottom sheet the flow shows, including the - * default reauthentication sheet and the phone number country selector — is not a descendant of - * this [Surface], so a modifier passed here does not decorate it. - * - * That boundary no longer costs you the resource ids, though. Exposing - * [com.firebase.ui.auth.ui.FirebaseAuthTestTags] as Android resource ids is not something a caller - * has to arrange: the library sets `testTagsAsResourceId` itself at each semantics owner it creates - * — every screen root, and every dialog, bottom sheet, and popup, including the ones holding no tag - * of their own — so Firebase Test Lab Robo directives and `By.res()` resolve every published tag - * without any modifier being passed here. Owners are flagged as a standing rule rather than a - * case-by-case judgement, so a tag added inside one later cannot quietly fail to become a resource - * id. + * @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). @@ -244,9 +231,8 @@ fun FirebaseAuthScreen( customMethodPickerLayout(configuration.providers, onProviderSelected) } } else { - // Flagged because the library creates the owner, not because a tag needs it - // here — see exposeTestTagsAsResourceIds. This one sits under the flagged - // Surface above, so it is redundant today and cheap to keep uniform. + // Redundant under the flagged Surface above, but kept uniform per + // exposeTestTagsAsResourceIds. Scaffold(modifier = Modifier.exposeTestTagsAsResourceIds()) { innerPadding -> AuthMethodPicker( modifier = Modifier @@ -865,9 +851,8 @@ private fun AuthSuccessContent( TooltipAnchorPosition.Above ), tooltip = { - // The tooltip is shown in a popup, which is its own semantics owner and so - // inherits nothing from the Surface above. Nothing here is tagged yet; the flag - // is applied because the owner exists — see exposeTestTagsAsResourceIds. + // 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) } @@ -1012,9 +997,8 @@ private fun ReauthSheetContent( customMethodPickerLayout(reauthConfig.providers, onProviderSelected) } } else { - // Flagged for the same reason as its counterpart in FirebaseAuthScreen: the owner is - // ours, so it carries the flag whether or not anything under it is tagged today. The - // enclosing ModalBottomSheet already flags this subtree. + // 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), 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 06cf0f028..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 @@ -133,15 +133,8 @@ internal fun DefaultMfaEnrollmentContent( ) } - // Each step below composes its own root, and each carries Modifier.exposeTestTagsAsResourceIds() - // itself whether or not it tags anything today. Not because an ancestor's flag would fail to - // reach them — a flag is inherited by walking semantics ancestors, and that walk crosses - // composable boundaries freely, so inside this library every step is already covered by the - // Surface that wraps the NavHost in FirebaseAuthScreen. The flags are load-bearing because - // MfaEnrollmentScreen is public: a host application can call it standalone, with no flagged - // ancestor of ours above it, and the resource ids have to work there too. The steps that - // delegate to a shared screen (EnterPhoneNumberUI, EnterVerificationCodeUI) get it from that - // screen. + // 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 -> { @@ -324,10 +317,8 @@ private fun SelectFactorUI( modifier = Modifier.fillMaxWidth() ) - // One button per not-yet-enrolled factor renders in this loop, so a user enrolled - // in neither factor sees both buttons at once. A shared tag would collide exactly - // the way the registry's uniqueness test cannot catch on its own, so each factor - // gets its own constant, keyed off the same `when` branch used for the label. + // 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) }, @@ -419,10 +410,8 @@ private fun EnrolledFactorItem( color = MaterialTheme.colorScheme.onSurfaceVariant ) } - // This item is rendered once per enrolled factor by a `forEach` in the caller, so a - // user enrolled in both SMS and TOTP composes two of these at once. The same collision - // risk as the enroll buttons above, resolved the same way: key the tag off the factor - // type rather than sharing one value. + // 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, 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 6a4ccac88..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 @@ -112,10 +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). It contributes no UI of its own beyond the layout node - * that hosts the rendered content. + * 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. @@ -123,9 +121,8 @@ 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 Applied once, to the [Box] that hosts the rendered content — the [content] slot - * when one is supplied, otherwise the default per-step UI. That box propagates its incoming minimum - * constraints, so it does not change how the content is measured. + * @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, the default UI for the current step is rendered. */ @@ -413,9 +410,8 @@ fun PhoneAuthScreen( } ) - // The caller's modifier is applied exactly once, here, to this composable's outermost node. - // `propagateMinConstraints` keeps the box layout-neutral: the content is measured with the same - // constraints it received before the box existed. + // 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) 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 index c09ee7a48..7de5ef061 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/FirebaseAuthTestTagsTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/FirebaseAuthTestTagsTest.kt @@ -20,11 +20,8 @@ import java.lang.reflect.Modifier import org.junit.Test /** - * Enforces the invariants [FirebaseAuthTestTags] documents, by reflecting over the whole registry - * — the root object and every nested grouping object — rather than over a hand-maintained list. - * - * The registry is a plain Kotlin object of [String] constants, so these tests need no Android - * runtime and run on plain JUnit. + * Enforces [FirebaseAuthTestTags] invariants by reflecting over the whole registry rather than a + * hand-maintained list. * * @suppress Internal test class */ @@ -84,9 +81,7 @@ class FirebaseAuthTestTagsTest { registeredTags().forEach { (path, value) -> val segments = path.split('.') - // Exactly one grouping level, in both directions: the surface prefix asserted below - // is read from segments[1], so a second nesting level would leave it ambiguous which - // of the two enclosing groups a value has to repeat. + // 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 " + @@ -140,9 +135,7 @@ class FirebaseAuthTestTagsTest { into["$path.${field.name}"] = field.get(null) as String } - // A computed tag (`val TAG: String get() = …`) has no backing field, so the scan above - // would miss it and it would escape every invariant in this class. Read it through its - // getter instead, skipping the getters that back the fields already collected. + // 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 } @@ -178,9 +171,8 @@ class FirebaseAuthTestTagsTest { } /** - * Converts a grouping object name to the `snake_case` surface segment its tag values repeat. - * A run of capitals stays one segment, so `OAuthProvider` becomes `oauth_provider` rather than - * `o_auth_provider`. + * 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() 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 index be25c9c5c..073f5e249 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/MainSourceTestTagUsageTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/MainSourceTestTagUsageTest.kt @@ -19,65 +19,8 @@ import java.io.File import org.junit.Test /** - * Closes the one-sided gap left by [FirebaseAuthTestTagsTest]. - * - * That class reflects over the registry and so only polices tags that already opted in. A - * contributor writing `Modifier.testTag("Email Field")` inline in a main source file satisfies the - * compiler, keeps the `:auth` suite green, and still ships a tag that cannot be addressed as an - * Android resource id — which is the entire reason the registry exists. This class scans the - * `auth/src/main` Kotlin sources instead of the compiled registry and fails the build when a tag is - * applied without going through [FirebaseAuthTestTags]. - * - * A source scan is used rather than a custom lint rule because it needs no new module, no lint API - * surface, and no UAST plumbing to answer a purely lexical question, and it runs inside the unit - * test task that already gates every change. The cost of that choice is that the scan has to do its - * own tokenising: matching `testTag` with a bare regex over raw file text reports commented-out - * code, `val testTag = "…"` bindings, and call shapes quoted inside string literals, all of which - * are correct code. So [maskCommentsAndLiterals] runs first and blanks comments and literal - * contents — length-preservingly, so offsets and line numbers still line up with the original text - * — and matches are then required to be a member access or an assignment rather than any token - * spelled `testTag`. Arguments are still read from the original text, so violations are reported - * with the literal a contributor actually wrote. - * - * What a lexical scan cannot see, it is required to refuse rather than wave through. An aliased - * import (`import …testTag as composeTestTag`) renames the call out of this scan's reach for one - * line of effort, so a separate test in this class fails on the alias itself instead of pretending - * the file is clean. Indirection through a local wrapper function remains out of reach and is - * accepted: it costs more to write than to review, and the reviewer sees the wrapper. - * - * The same scan answers two adjacent questions that nothing else could. Whether every semantics - * owner the library creates carries [exposeTestTagsAsResourceIds] — the rule that file declares, - * stated as owners rather than tags — and, as a cheaper backstop for owner shapes this scan does not - * recognise, whether a file applying a tag flags an owner anywhere in itself. All of these failures - * are invisible to every Compose assertion in the suite, which is why a lexical check is worth its - * limitations here. - * - * ## What the owner check cannot see - * - * The owner check is lexical, so four gaps are known and accepted rather than papered over: - * - * * It recognises the owner-creating shapes in [SEMANTICS_OWNER_SHAPES] by name. A `Dialog` - * subclassed or wrapped under a different name, or a future Compose owner shape, is invisible to - * it. That is what the weaker per-file check below still covers. - * * Lexical presence cannot tell an *applied* modifier from one that is built and discarded. - * `Scaffold(modifier = Modifier.exposeTestTagsAsResourceIds().let { Modifier })` reads as flagged. - * Requiring the call inside the owner's own argument list rather than merely nearby is as close as - * a scan gets; the remaining shapes are not ones anybody writes by accident. - * * The converse is a false positive: a flag reached through a local, as in - * `val m = Modifier.exposeTestTagsAsResourceIds()` followed by `Scaffold(modifier = m)`, is not in - * the argument list and reads as unflagged. The fix — inline it, or flag in the argument list too - * — is harmless, because the property is `isImportantForAccessibility = false` and setting it - * twice changes nothing. - * * `@Preview` composables are skipped. They are private, are never composed in a shipped - * application, and Android Studio's preview host is not a crawler, so an owner inside one has - * nothing to expose. This is the only exemption, and it is a rule rather than an allowlist so that - * adding a preview cannot redden an unrelated build. - * - * It lives in its own class rather than in [FirebaseAuthTestTagsTest] because it tests a different - * thing by a different mechanism: the registry test asserts properties of compiled constants, while - * this one reads files off disk and therefore carries a failure mode of its own — a scan that - * finds nothing and passes. Keeping them apart keeps that vacuity guard from reading as noise - * inside the registry invariants. + * 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 */ @@ -93,10 +36,8 @@ class MainSourceTestTagUsageTest { } /** - * Renaming `testTag` on import puts every call through it beyond a lexical scan, so the alias - * is rejected outright. The check is deliberately blunt: there is no legitimate reason for auth - * main sources to import `testTag` under another name, and "the guard cannot analyse this" must - * not resolve to "the guard passes". + * 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`() { @@ -119,22 +60,8 @@ class MainSourceTestTagUsageTest { } /** - * The rule [exposeTestTagsAsResourceIds] declares, checked as declared: **every semantics owner - * the library creates carries the flag, whether or not anything inside it is tagged today.** - * - * Stating the rule over owners rather than over tags is the whole point of it. A flag on an owner - * that holds no tags costs nothing, while a tag added inside an owner that was skipped costs a - * silent regression — `onNodeWithTag(...).assertExists()` still passes and only a Robo directive - * or `By.res()`, neither of which runs in CI, can tell. So a check keyed on "this file applies a - * tag" cannot enforce it: the four owners that had to be fixed by hand while this branch was - * written — the manage-MFA tooltip and the three sibling `Scaffold`s in the MFA enrollment steps - * — hold no tags at all, and no tag-keyed check would ever have named them. - * - * An owner is recognised by construction shape ([SEMANTICS_OWNER_SHAPES]) and is required to carry - * the flag *in its own argument list*, which ties the flag to that owner rather than to its - * neighbourhood. A shape invoked with a trailing lambda and no argument list at all — `Scaffold { - * … }` — cannot be passed a modifier and so cannot be flagged; it is reported for the same - * reason, and the fix is to give it one. The gaps this cannot see are listed on the class. + * 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`() { @@ -160,23 +87,8 @@ class MainSourceTestTagUsageTest { } /** - * The cheaper backstop for the owner check above: a file that applies a tag must flag an owner - * somewhere in itself, whatever shape that owner has. - * - * Nothing in the type system ties a `testTag` call to an enclosing - * [exposeTestTagsAsResourceIds]. The check above covers the owner shapes it knows by name; this - * one covers the case where a tag is applied under something it does not recognise, at the cost - * of being coarser — it requires the flag *somewhere in the file*, not that it encloses that - * particular tag. - * - * Three things it therefore cannot see: a file that flags one owner and tags a node under a - * second, unflagged one; a tag placed in a file with no owner of its own that relies on a flag - * applied by its caller; and, as everywhere in this class, a flag that is built and never - * applied. The first is what the owner check and [TestTagsAsResourceIdsTest] are for. The second - * would be a false positive, and the fix for it — apply the flag in the file too — is harmless - * rather than wrong: the property is `isImportantForAccessibility = false`, so setting it again - * where it is already set changes nothing. A coarse check that costs a redundant one-liner is - * worth more than no check. + * 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`() { @@ -210,10 +122,8 @@ class MainSourceTestTagUsageTest { } /** - * Guards this class against the failure mode that would make it worthless: resolving no source - * tree, or the wrong one, scanning nothing, and reporting success. Every step of the scan is - * pinned, so a moved module or a relocated source set breaks the build loudly instead of - * quietly disarming the assertion above. + * 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`() { @@ -311,20 +221,8 @@ class MainSourceTestTagUsageTest { } /** - * Locates `auth/src/main` without trusting the working directory. - * - * Gradle runs unit tests with the module directory as the working directory, but that is a - * default rather than a guarantee, and the same test may be launched from an IDE or from the - * repository root. So the module-relative and repository-relative paths are both tried at the - * working directory and at each of its ancestors, and a candidate only counts once - * [FirebaseAuthTestTags] itself is found inside it. Using the registry file as the sentinel - * means resolution cannot silently land on some unrelated `src/main`. - * - * The whole of `src/main` is resolved rather than `src/main/java` specifically, because a Kotlin - * file is not required to live under `java/`: an `src/main/kotlin` directory is a source root - * Gradle compiles and would have been invisible to this scan. Walking `src/main` covers every - * source directory in it, present and future, and only `.kt` files are read from it. The - * sentinel is looked for under either directory for the same reason. + * 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 @@ -363,12 +261,8 @@ class MainSourceTestTagUsageTest { .toList() /** - * Finds `testTag(...)` calls and `testTag = ...` semantics assignments in [file]. - * - * Matching runs over [maskCommentsAndLiterals] output, so nothing inside a comment — of either - * form, including a trailing comment on a line of live code — and nothing inside a string - * literal is treated as source. Arguments are then sliced out of the original text at the same - * offsets, because the argument is what the contributor needs to see in the failure message. + * 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, '/') @@ -394,16 +288,8 @@ class MainSourceTestTagUsageTest { } /** - * Finds the semantics owner constructions in [file] and says which of them flag themselves. - * - * Matching runs over [maskCommentsAndLiterals] output for the same reason the tag scan does: the - * KDoc in these files names `Scaffold`, `ModalBottomSheet` and the rest constantly, and a - * documented shape is not a constructed one. The flag is then looked for inside the owner's own - * argument list — balanced over the masked text, so a paren in a string cannot unbalance it — - * rather than within some number of surrounding lines, so the flag is tied to that owner and not - * to a sibling that happens to sit above it. An owner invoked with a trailing lambda and no - * argument list has no argument list to search and is reported: it cannot be handed a modifier at - * all. + * 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, '/') @@ -432,12 +318,8 @@ class MainSourceTestTagUsageTest { } /** - * Whether the code at [index] sits in a `@Preview` composable. - * - * Resolution is by nearest preceding function declaration and the annotation block directly above - * it, which is exact for the top-level composables these sources are written as. A preview is - * private, is never composed in a shipped application, and is rendered by Android Studio rather - * than by a crawler, so an owner inside one has nothing to expose as a resource id. + * 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 @@ -458,13 +340,8 @@ class MainSourceTestTagUsageTest { } /** - * Rejects the tokens that merely spell `testTag` without applying one. - * - * A call only counts when it is reached through a receiver (`Modifier.testTag(`) or is a bare - * call rather than a declaration of a function by that name; an assignment only counts when it - * is an assignment (`semantics { testTag = … }`) rather than a `val`/`var` binding or an - * equality comparison. Both were live false positives: a `val testTag = "…"` local was - * reported as a registry violation, with fix advice that made no sense for it. + * 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 @@ -496,10 +373,8 @@ class MainSourceTestTagUsageTest { } /** - * Reads the [original] text between the paren at [openIndex] in [masked] and its match. - * - * Balancing runs over the masked text, where a paren inside a string literal has already been - * blanked, so a tag value such as `"(unused)"` cannot unbalance the scan. + * 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 @@ -519,12 +394,8 @@ class MainSourceTestTagUsageTest { } /** - * Reads the right-hand side of a `testTag =` assignment whose `=` sits at [equalsIndex]. - * - * The expression may start on the following line, so leading whitespace is skipped before - * reading, and it ends at the first newline or closing bracket reached at nesting depth zero. - * Taking only the remainder of the `=` line — as this did previously — yielded an empty - * argument for a wrapped assignment, and an empty argument fails the registry-prefix check. + * 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 @@ -548,13 +419,8 @@ class MainSourceTestTagUsageTest { } /** - * Replaces the contents of comments and of string and character literals with spaces, keeping - * the result the same length as [source] and its newlines in place so offsets and line numbers - * carry over unchanged. Literal delimiters are kept so a blanked literal still reads as one. - * - * Block comments nest, as they do in Kotlin. A string template containing a nested string - * literal (`"${'$'}{f("x")}"`) is the one shape this mis-tokenises; it garbles the region - * rather than failing, and no auth source writes one. + * 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) @@ -635,17 +501,12 @@ class MainSourceTestTagUsageTest { private companion object { /** - * Lower bound on the number of `testTag` applications in `auth/src/main`, so a scan that - * stops reaching files announces itself. A floor rather than an exact count: tagging more - * nodes is the expected direction of travel and should not redden an unrelated build. + * 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 the number of recognised semantics owner constructions outside `@Preview` - * functions, so a scan that stops recognising a call shape announces itself rather than - * reporting a clean tree. A floor for the same reason as [MINIMUM_TAG_CALL_SITES]. - */ + /** 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." @@ -660,11 +521,8 @@ class MainSourceTestTagUsageTest { val FLAG_APPLICATION_PATTERN = Regex("""\b$FLAG_FUNCTION_NAME\s*\(""") /** - * The Compose call shapes that create a semantics owner the library is responsible for - * flagging. Dialogs, popups and bottom sheets are each hosted in their own window with their - * own semantics root; a `Scaffold` is a subtree root that a sibling `Scaffold`'s flag does not - * reach. Longest-first is not needed — `\b` already stops `Dialog` from matching inside - * `AlertDialog` — but alphabetical order keeps `AlertDialog` ahead of it anyway. + * 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", @@ -675,11 +533,7 @@ class MainSourceTestTagUsageTest { "Scaffold", ) - /** - * One of [SEMANTICS_OWNER_SHAPES] being constructed. `{` is accepted as the delimiter as well - * as `(` so that a trailing-lambda-only call, which can carry no modifier and therefore no - * flag, is caught rather than missed. - */ + /** One of [SEMANTICS_OWNER_SHAPES] being constructed, including trailing-lambda-only calls. */ val OWNER_CONSTRUCTION_PATTERN = Regex("""\b(${SEMANTICS_OWNER_SHAPES.joinToString("|")})\s*[({]""") @@ -696,9 +550,7 @@ class MainSourceTestTagUsageTest { const val PREVIEW_ANNOTATION_PREFIX = "@Preview" /** - * The registry, used as the sentinel that a candidate directory really is the auth main - * source tree. Both conventional Kotlin source directories are tried, so moving the registry - * from `java/` to `kotlin/` does not disarm the scan. + * 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", @@ -737,9 +589,8 @@ class MainSourceTestTagUsageTest { val BINDING_KEYWORDS = setOf("val", "var") /** - * `testTag(` as a call, or `testTag =` as a `SemanticsPropertyReceiver` assignment. Both - * apply a tag, so both are checked; the trailing delimiter says which form matched. - * [isTagApplication] then discards the matches that are neither. + * `testTag(` as a call, or `testTag =` as an assignment; [isTagApplication] filters false + * matches from either. */ val TAG_APPLICATION_PATTERN = Regex("""\btestTag\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 index 91272be8f..abb71c87d 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/TestTagsAsResourceIdsTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/TestTagsAsResourceIdsTest.kt @@ -90,54 +90,8 @@ import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode /** - * The end-to-end check for the reason [FirebaseAuthTestTags] exists: that each published tag is - * applied to a real node on its screen, and that the node carries the tag as an Android **resource - * id** rather than only as a Compose test tag. - * - * That distinction is the whole point, and it is why these tests read `viewIdResourceName` off the - * accessibility node instead of calling `assertExists()` on a `testTag` matcher. A `testTag` is - * visible only to Compose's own test APIs. Firebase Test Lab Robo directives, the Play pre-launch - * report's crawler, and UiAutomator's `By.res()` all match on the resource name, and they see it - * only when [exposeTestTagsAsResourceIds] has been applied at the enclosing semantics owner. An - * `onNodeWithTag(...).assertExists()` passes identically with and without that modifier, so it - * cannot tell the fixed state from the bug — issue #2050, where Robo could type a username but - * never reach the password field. - * - * Two harness details are load-bearing: - * - * * `@GraphicsMode(NATIVE)`. Compose builds its accessibility node tree by subtracting each node's - * bounds from an `android.graphics.Region` of unaccounted space. Under Robolectric's legacy - * graphics that `Region` is inert, every node below the root is treated as covered, and - * `createAccessibilityNodeInfo` returns an empty node for which `viewIdResourceName` is always - * `null` — the assertions here would fail for a reason that has nothing to do with the library. - * * Scrolling a node into the window before reading it. Nodes lying outside the root's bounds are - * culled from that same tree, and these screens are taller than the test window, so a tag near - * the bottom of a screen has no accessibility node until it is scrolled to. Enlarging the window - * is the obvious alternative and is deliberately not used: at `h2400dp` an `AlertDialog` - * containing a text field — the shape [com.firebase.ui.auth.ui.components.ReauthenticationDialog] - * has — sends Robolectric's text measurement into a runaway allocation and the suite dies with an - * `OutOfMemoryError` that has nothing to do with test tags. It reproduces with stock Material 3 - * components and no test tags involved, so it is a harness limit, not a library one. - * - * Dialogs and bottom sheets are covered explicitly rather than incidentally. Each is hosted in its - * own window with its own semantics root, so it inherits nothing from the composable that opened it - * — the case with no coverage before this class existed, and the one where forgetting the modifier - * is invisible in a Compose-only assertion. - * - * ## What is deliberately not covered here - * - * [exposeTestTagsAsResourceIds] is applied to every semantics owner the library creates, including - * the ones that hold no tag today, and those flags have no test of their own. That is intentional - * twice over. There is nothing to assert — the property is only observable through a tag beneath it, - * so a test would have to plant its own tag and would then be testing Compose. And the flags exist - * precisely because no test can see them missing: the whole reason for flagging owners rather than - * tags is that a tag added inside an unflagged owner keeps every Compose assertion green. The loading - * dialog, the default re-authentication sheet, the manage-MFA tooltip, and the TOTP enrollment steps - * are all in that state. - * - * The one case where a flag-without-tags *is* asserted is `error recovery dialog exposes a caller - * supplied tag`, which stands in for all of them: it plants a caller tag in an owner the library - * flags but does not tag, and so proves the mechanism works the moment a tag arrives. + * 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 */ @@ -158,22 +112,11 @@ class TestTagsAsResourceIdsTest { stringProvider = DefaultAuthUIStringProvider(applicationContext) } - // ============================================================================================= - // The assertion this class is built around - // ============================================================================================= + // ---- The assertion this class is built around ---- /** - * Asserts that exactly one node carries [tag], that it is the kind of node [expected] describes, - * and that the platform sees the tag as a resource id. - * - * The node kind is asserted alongside the tag because "the tag exists somewhere" is not the - * claim worth making — a tag parked on a `Spacer` next to the password field would satisfy it - * while leaving Robo with nothing to type into. So a field is required to accept text and a - * button to be clickable. - * - * `node.root` is read rather than assumed, so the same helper works for a dialog or bottom - * sheet: those live in a different window, and this resolves whichever window actually hosts - * the node. + * 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)) @@ -207,11 +150,8 @@ class TestTagsAsResourceIdsTest { } /** - * Scrolls the node tagged [tag] into the window when it sits inside a scrollable, because a node - * outside the root's bounds has no accessibility node to read a resource id from. - * - * The scrollable ancestor is looked for rather than the scroll being attempted unconditionally, - * so that a genuine `performScrollTo` failure still fails the test instead of being swallowed. + * 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 @@ -225,22 +165,8 @@ class TestTagsAsResourceIdsTest { } /** - * Enters [text] into whatever node the platform publishes under the resource id [resourceName], - * the way something outside Compose would: the node is found by scanning the accessibility tree - * for that resource id, and the text is delivered through `ACTION_SET_TEXT` — the action behind - * a Robo `inputText` directive and UiAutomator's `setText`. - * - * The Compose test tag is deliberately not used to locate the node, because that is the claim - * under test. `performTextInput` on a tag shows that a Compose test can type; it says nothing - * about whether a crawler holding only a resource name can, which is the gap issue #2050 is - * about. - * - * Two properties are asserted before the text is sent, because a crawler reads both and either - * one alone can be satisfied by a node it will never type into. `className` has to be - * [EDIT_TEXT_CLASS_NAME] — the field a crawler consults to decide a node accepts text at all, - * and the only class Robo's `inputText` directives are documented for — and `ACTION_SET_TEXT` - * has to be offered. They fail independently: a plain container can advertise the action, and a - * node with the action can lose the class to an unrelated semantics property. + * 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) @@ -291,12 +217,8 @@ class TestTagsAsResourceIdsTest { } /** - * The accessibility node whose `viewIdResourceName` is [resourceName], together with the - * provider that owns it, asserting that exactly one node claims the id. - * - * Every semantics node in the tree is examined rather than the one carrying a matching test tag, - * so this resolves the id the same way `By.res()` does — and so a tag that never reached the - * accessibility tree fails here instead of being found by the back door. + * 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 @@ -329,9 +251,7 @@ class TestTagsAsResourceIdsTest { private fun button(): SemanticsMatcher = hasClickAction() - // ============================================================================================= - // Fixtures - // ============================================================================================= + // ---- Fixtures ---- private fun setContent(content: @Composable () -> Unit) { composeTestRule.setContent { @@ -381,9 +301,7 @@ class TestTagsAsResourceIdsTest { } } - // ============================================================================================= - // In-window screen roots - // ============================================================================================= + // ---- In-window screen roots ---- @Test fun `sign in screen exposes its credential fields and actions`() { @@ -417,10 +335,8 @@ class TestTagsAsResourceIdsTest { } /** - * The password visibility toggle on [AuthTextField] is a shared, low-level component reused by - * every password-holding screen, so it needs a distinct tag per call site — this pins the - * end-to-end path for the sign-in screen's toggle: [AuthTextField]'s new - * `visibilityToggleModifier` parameter reaching a real Android resource id. + * 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`() { @@ -560,9 +476,8 @@ class TestTagsAsResourceIdsTest { ) } - // The code is drawn as one box per digit, but the tag names the group and the group is the - // node that takes text — so `field()` here, not `hasAnyDescendant(field())`. See the typing - // tests below for why that distinction is the whole point. + // 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()) @@ -573,22 +488,11 @@ class TestTagsAsResourceIdsTest { assertExposedAsResourceId(FirebaseAuthTestTags.VerificationCode.BACK_BUTTON, button()) } - // ============================================================================================= - // The code fields, which have to accept a code and not merely carry a tag - // ============================================================================================= + // ---- The code fields, which have to accept a code and not merely carry a tag ---- /** - * The finding this section exists for: `fui_verification_code_code_field` used to name a bare - * container whose entire semantics config was its test tag. A Robo directive - * `{"resourceName": "fui_verification_code_code_field", "inputText": "123456"}` resolved that - * node and typed nothing, and the six real digit boxes were unaddressable — no resource id of - * their own, and six identical content descriptions between them. The constant promised a code - * input and delivered a `Column`. - * - * So this drives the code in the way that failed: locate the node by resource id, issue - * `ACTION_SET_TEXT`, and then require the code to have arrived where the screen keeps it. The - * verify button being enabled afterwards is the part that matters — it is the screen agreeing - * that it holds a complete, valid code, which is as far as a crawler needs to get. + * 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`() { @@ -626,10 +530,8 @@ class TestTagsAsResourceIdsTest { } /** - * The same claim for a host application's own Compose tests, which the registry KDoc invites: - * `performTextInput` against the published tag enters the whole code. It exercises a different - * semantics action from the test above — `InsertTextAtCursor` rather than `SetText` — so both - * are covered. + * The same claim for a host app's own Compose tests: `performTextInput` on the published tag + * enters the whole code, exercising `InsertTextAtCursor` rather than `SetText`. */ @Test fun `performTextInput on the verification code tag enters the whole code`() { @@ -663,10 +565,8 @@ class TestTagsAsResourceIdsTest { } /** - * The multi-factor challenge screen shares the code input with the phone screen and had no tags - * at all, so it had the same hole in a place a user reaches during an ordinary sign-in rather - * than during enrollment. It is covered by the same assertions rather than by a weaker one, - * because "reachable by a crawler" means the same thing on both screens. + * 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`() { @@ -703,10 +603,8 @@ class TestTagsAsResourceIdsTest { } /** - * [FirebaseAuthTestTags.MfaChallenge.CANCEL_BUTTON] is shared by two controls that render in - * mutually exclusive branches of [DefaultMfaChallengeContent] — the SMS "use a different - * method" button and the TOTP "dismiss" button. This pins the TOTP side of that sharing: the - * same tag resolves to exactly one node here too, distinct from the SMS fixture above. + * [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`() { @@ -742,10 +640,8 @@ class TestTagsAsResourceIdsTest { } /** - * The "Continue as …" button only exists when a previous sign-in preference is stored, so it - * needs a fixture of its own and had no coverage without one. It is worth having: for a - * returning user it is the first control on the flow's first screen, so it is what a crawl - * reaches before anything else. + * 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`() { @@ -769,9 +665,7 @@ class TestTagsAsResourceIdsTest { assertExposedAsResourceId(FirebaseAuthTestTags.MethodPicker.CONTINUE_AS_BUTTON, button()) } - // ============================================================================================= - // Separate semantics owners: dialogs - // ============================================================================================= + // ---- Separate semantics owners: dialogs ---- /** * The "reset link sent" dialog. Its dismiss button is the only way forward for a crawler that @@ -833,9 +727,7 @@ class TestTagsAsResourceIdsTest { /** * [ErrorRecoveryDialog] publishes no tag of its own, so this pins the flag from the caller's - * side: a host application's tag passed in through the public `modifier` becomes a resource id. - * Without the library's own flag inside the dialog it would not, because the dialog's window is - * a fresh semantics root and the flag is read by walking ancestors. + * 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`() { @@ -871,16 +763,11 @@ class TestTagsAsResourceIdsTest { assertExposedAsResourceId(FirebaseAuthTestTags.ErrorRecovery.DISMISS_BUTTON, button()) } - // ============================================================================================= - // Separate semantics owners: bottom sheets - // ============================================================================================= + // ---- Separate semantics owners: bottom sheets ---- /** - * The country selector sheet, and the case that made this work necessary rather than merely - * tidy: [FirebaseAuthScreenModifierTest] shows that a modifier passed to the flow's root does - * not reach this sheet at all, so before the library set the flag here itself, - * `By.res("fui_country_selector_country_list")` could not resolve however the caller was - * configured. + * 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`() { @@ -900,17 +787,11 @@ class TestTagsAsResourceIdsTest { ) } - // ============================================================================================= - // MFA enrollment: the factor-selection step, and the collision it is built to avoid - // ============================================================================================= + // ---- MFA enrollment: the factor-selection step, and the collision it is built to avoid ---- /** - * [DefaultMfaEnrollmentContent] renders one enroll button per not-yet-enrolled factor in a - * `forEach`, so a user enrolled in neither factor composes both buttons at once — the exact - * shape of collision this whole tag registry exists to prevent (see - * [com.firebase.ui.auth.ui.FirebaseAuthTestTags.MfaEnrollment]). This proves both are - * addressable *at the same time*, not merely that each works in isolation: a fixture that - * rendered them one at a time would not catch a shared tag. + * [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`() { @@ -933,9 +814,8 @@ class TestTagsAsResourceIdsTest { } /** - * The mirror case on [com.firebase.ui.auth.ui.screens.MfaEnrollmentDefaults]'s - * `EnrolledFactorItem`: a user enrolled in both SMS and TOTP composes one remove button per - * factor in the same `forEach`, so the two need to be addressable at once as well. + * 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`() { @@ -959,9 +839,7 @@ class TestTagsAsResourceIdsTest { assertExposedAsResourceId(FirebaseAuthTestTags.MfaEnrollment.REMOVE_TOTP_BUTTON, button()) } - // ============================================================================================= - // MFA enrollment: TOTP setup and verification - // ============================================================================================= + // ---- MFA enrollment: TOTP setup and verification ---- @Test fun `mfa enrollment totp setup step exposes its back and continue buttons`() { @@ -986,10 +864,8 @@ class TestTagsAsResourceIdsTest { } /** - * The TOTP verification step's code field is the highest-value new node in the enrollment - * flow — it is the one place a Robo directive has to type a real value rather than merely tap - * a button — so it gets the same `ACTION_SET_TEXT` treatment as the phone verification code - * field above, rather than only an existence check. + * 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`() { @@ -1039,16 +915,11 @@ class TestTagsAsResourceIdsTest { ) } - // ============================================================================================= - // The flow root - // ============================================================================================= + // ---- The flow root ---- /** - * The root [androidx.compose.material3.Surface] inside [FirebaseAuthScreen] carries the flag as - * well, so content the flow hosts directly — rather than through one of the screen composables - * that flags itself — is covered too. The custom method-picker slot is used because it renders - * under that `Surface` without going through [AuthMethodPicker], which would supply a flag of - * its own and make this pass either way. + * 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`() { @@ -1089,9 +960,7 @@ class TestTagsAsResourceIdsTest { assertExposedAsResourceId(CALLER_TAG, hasText("Custom Picker")) } - // ============================================================================================= - // FirebaseAuthScreen needs a live FirebaseApp - // ============================================================================================= + // ---- FirebaseAuthScreen needs a live FirebaseApp ---- private val authUI: FirebaseAuthUI get() { @@ -1130,9 +999,8 @@ class TestTagsAsResourceIdsTest { const val VERIFICATION_CODE = "123456" /** - * The class a crawler requires before it will type into a node. Robo's `inputText` directives - * are documented for `EditText` only, and the class is what a crawler reads to decide a node - * accepts text — not the action list, which a plain container can also carry. + * 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" 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 8a994ea09..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 @@ -75,9 +75,7 @@ class AuthProviderButtonTest { clickedProvider = null } - // ============================================================================================= - // Basic UI Tests - // ============================================================================================= + // ---- Basic UI Tests ---- @Test fun `AuthProviderButton displays Google provider correctly`() { @@ -328,9 +326,7 @@ class AuthProviderButtonTest { .assertIsEnabled() } - // ============================================================================================= - // Click Interaction Tests - // ============================================================================================= + // ---- Click Interaction Tests ---- @Test fun `AuthProviderButton onClick is called when clicked`() { @@ -372,9 +368,7 @@ class AuthProviderButtonTest { assertThat(clickedProvider).isNull() } - // ============================================================================================= - // Style Resolution Tests - // ============================================================================================= + // ---- Style Resolution Tests ---- @Test fun `AuthProviderButton uses custom style when provided`() { @@ -477,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`() { @@ -555,20 +547,11 @@ class AuthProviderButtonTest { assertThat(resolvedStyle.contentColor).isEqualTo(AuthUITheme.ProviderStyle.Empty.contentColor) } - // ============================================================================================= - // Modifier contract tests - // ============================================================================================= + // ---- Modifier contract tests ---- /** - * A composable must apply its `modifier` to exactly one node, its outermost one. This button - * used to hand the same instance to both the [androidx.compose.material3.Button] and the inner - * content [androidx.compose.foundation.layout.Row], which duplicated everything the caller - * passed: a `testTag` landed on two nodes, and padding was applied twice. - * - * The unmerged tree is what matters here. `TestTag`'s merge policy keeps the ancestor's value, - * so the duplicate collapses to a single node in the merged tree and is invisible to an - * ordinary `onNodeWithTag` lookup — while still being two real nodes, and so two Android - * resource ids once `testTagsAsResourceId` is enabled. + * 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`() { @@ -589,13 +572,8 @@ class AuthProviderButtonTest { } /** - * The one node the caller's modifier reaches is the button itself, not the content row: the - * tagged node has to be the clickable one. - * - * This queries the merged tree, where `TestTag`'s merge policy keeps the ancestor's value, so - * the duplicated tag resolved to the button before the fix as well and this assertion held - * either way. It is a guard on which node owns the tag, not a pin on the change; the unmerged - * count above is what pins it. + * 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`() { @@ -616,9 +594,8 @@ class AuthProviderButtonTest { } /** - * The content row now owns its own width instead of inheriting the caller's, so a full-width - * button still lays its icon and label out from the start edge rather than centring them. - * This pins the rendered layout that the duplicated modifier used to produce by accident. + * 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`() { 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 index e15b047b4..d9c60368c 100644 --- 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 @@ -82,9 +82,7 @@ class CountrySelectorTest { } } - // ============================================================================================= - // Test Tag Tests - // ============================================================================================= + // ---- Test Tag Tests ---- @Test fun `CountrySelector tags the country list once the bottom sheet is open`() { @@ -107,9 +105,7 @@ class CountrySelectorTest { .assertDoesNotExist() } - // ============================================================================================= - // Selection Tests - // ============================================================================================= + // ---- Selection Tests ---- @Test fun `CountrySelector reports the country picked from the tagged list`() { 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 index 8b7f2659d..022ba6360 100644 --- 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 @@ -40,21 +40,8 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config /** - * The text-input contract [VerificationCodeInputField] declares on the group that holds its digit - * boxes. - * - * That contract is the reason the group carries semantics at all. The boxes accept one character - * each, so nothing could be handed a whole code, and the node a caller can address by test tag had - * no text-input action — a Firebase Test Lab Robo `inputText` directive naming it resolved a node and - * typed nothing, which is issue #2050 reproduced on the verification screen. - * [com.firebase.ui.auth.ui.TestTagsAsResourceIdsTest] proves the code arrives through the resource id - * a crawler would use; this class pins the behaviour underneath that — how a string is spread across - * the boxes, and what happens to input the boxes cannot hold. - * - * The rejection cases matter as much as the accepting ones. Both actions return a `Boolean`, and - * silently truncating over-long or non-numeric input would hand a caller a half-entered code and a - * success. So the actions are invoked directly here rather than through `performTextReplacement`, - * which discards the result. + * 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 */ @@ -211,10 +198,8 @@ class VerificationCodeInputFieldSemanticsTest { } /** - * Inserting nothing is a no-op, and a no-op reports success — including on a full code, where - * there is no empty box to insert into. The two guards in `insertTextAtCursor` are ordered for - * this: the emptiness check runs before the "nowhere to insert" check, so an empty insert cannot - * be reported as a failed action. + * 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`() { @@ -233,14 +218,8 @@ class VerificationCodeInputFieldSemanticsTest { } /** - * Pins the digit definition the actions use, which is `Character.isDigit`'s rather than ASCII - * `0`-`9`: a code in Arabic-Indic or fullwidth digits is accepted and normalised to its ASCII - * value. - * - * This is behaviour worth pinning rather than narrowing. The per-box keyboard path normalises the - * same way, so restricting only these two actions to ASCII would leave a code that can be typed by - * hand on a localised keypad refused through `ACTION_SET_TEXT`. Nothing malformed gets in either - * way — the length and digit checks still hold, so what arrives is a complete, well-formed code. + * 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`() { @@ -257,13 +236,8 @@ class VerificationCodeInputFieldSemanticsTest { } /** - * The group's `editableText` is the digits before the first empty box, not every digit entered. - * - * Boxes can be filled out of order — tapping the third box moves focus straight to it — so - * compacting the whole list would report `[1, 2, null, 4]` as `"124"` and place a digit at a - * position that is empty. A prefix understates instead, which is what a text field with a cursor - * means anyway and what `insertTextAtCursor` appends to. `editableText` is read back by anything - * holding the node, a crawler included, so it has to be positionally honest. + * 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`() { @@ -309,16 +283,14 @@ class VerificationCodeInputFieldSemanticsTest { private companion object { /** - * A fixture-local tag. The registry values belong to screens, and this exercises the widget - * on its own — a host application tagging our node with its own value is supported and this - * stands in for that too. + * 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" /** - * The shared prefix of every digit box's content description (each box's full - * description is positional, e.g. "Verification code digit 3 of 6"), used with a - * substring match to reach one box directly by its index in the row. + * 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" 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 c7fd3c418..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 @@ -495,9 +495,7 @@ class AuthMethodPickerTest { ) } - // assertHasClickAction is the only check in this suite that catches the tag drifting onto a - // non-clickable wrapper around the button: the click tests inject raw touch events, which - // still hit-test through an untagged wrapper to the button beneath. Keep it. + // Load-bearing: catches the tag drifting onto a non-clickable wrapper around the button. composeTestRule .onNodeWithTag(FirebaseAuthTestTags.MethodPicker.CONTINUE_AS_BUTTON) .assertIsDisplayed() 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 index dd06b5459..48146ea07 100644 --- 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 @@ -46,20 +46,8 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config /** - * Tests that [FirebaseAuthScreen] honours the Compose modifier contract: the caller's `modifier` - * is applied once, to the composable's own outermost node. - * - * The screen used to ignore its `modifier` at the root — the hosting `Surface` hardcoded - * `Modifier.fillMaxSize()` — and forward the caller's instance into individual `NavHost` - * destinations instead. That made the parameter mean "decorate whichever screen happens to be - * showing", which is both surprising and incomplete: destinations other than the method picker - * never received it at all, so a caller could not decorate the flow as a whole. In particular a - * host application could not attach `semantics { testTagsAsResourceId = true }` through the public - * API, because no destination-level modifier reaches the root. - * - * One test here asserts the opposite of the rest on purpose. "Reaches the root" is not the same as - * "reaches everything the flow shows", and the last test in this class pins where the difference - * lies so the boundary is recorded rather than assumed away. + * 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 */ @@ -164,10 +152,8 @@ class FirebaseAuthScreenModifierTest { } /** - * The decisive case. When the flow starts somewhere other than the method picker, the old code - * dropped the caller's modifier entirely — there was no `modifier` forwarding on any route - * except `MethodPicker`, and the root `Surface` used a fresh `Modifier`. So this found zero - * nodes before the fix and finds exactly one after it. + * 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`() { @@ -197,10 +183,8 @@ class FirebaseAuthScreenModifierTest { } /** - * Regression guard for the method-picker route, which is the one route that did receive the - * caller's modifier. It must now be tagged once, at the root, rather than on the picker's own - * `Column`. This asserts the count only, so it held before the fix as well; the routing case - * above is what pins the change. + * Regression guard for the method-picker route: the tag must land once, at the root, not on + * the picker's own `Column`. This held before the fix too; the routing case above pins it. */ @Test fun `caller modifier is applied once on the method picker route`() { @@ -212,10 +196,8 @@ class FirebaseAuthScreenModifierTest { } /** - * The custom method-picker slot used to take the caller's modifier on its wrapping `Box`; it - * now sits under the tagged root instead. Both arrangements satisfy these assertions, so this - * is a guard against the modifier being duplicated or dropped on this path rather than a pin - * on the change itself. + * 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`() { @@ -240,22 +222,8 @@ class FirebaseAuthScreenModifierTest { } /** - * Pins the documented edge of the modifier's reach, and is expected to keep passing: a - * bottom sheet is a **separate semantics owner**, so its content is not a descendant of the - * root the caller's modifier lands on. - * - * This is a boundary, not a bug, and it is asserted rather than left implicit because the - * obvious reading of "the modifier reaches the root of the flow" is that it therefore covers - * everything the flow shows. It does not — no modifier passed to [FirebaseAuthScreen] decorates - * anything inside this sheet. - * - * What that boundary no longer implies is that the sheet's tags are unreachable as resource ids. - * The library now sets `testTagsAsResourceId` at each semantics owner it creates, this sheet - * included, so [FirebaseAuthTestTags.CountrySelector.COUNTRY_LIST] does resolve under - * `By.res("fui_country_selector_country_list")` — by the library's own doing rather than by - * inheritance from the caller's modifier. That exposure is asserted in - * [TestTagsAsResourceIdsTest]; the assertions here stay about the modifier's reach, which is - * unchanged. + * 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`() { 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 index 90edfeee6..1427bb244 100644 --- 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 @@ -37,11 +37,8 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config /** - * Modifier-contract tests for [SignInEmailLinkUI]. - * - * This screen carried the same defect as [SignInUI]: the screen-level `modifier` was correctly - * applied to the `Scaffold` and then applied a second time to the "trouble signing in" label, so a - * caller's sizing, padding or tag reached a leaf it was never meant to touch. + * 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 */ 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 61cca5e3e..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 @@ -176,9 +176,7 @@ class SignInUITest { composeTestRule.onNodeWithText("user@example.com").assertDoesNotExist() } - // ============================================================================================= - // Modifier contract tests - // ============================================================================================= + // ---- Modifier contract tests ---- private fun setSignInUIContent(modifier: Modifier) { val provider = AuthProvider.Email( @@ -212,9 +210,8 @@ class SignInUITest { } /** - * The screen's `modifier` used to be handed to the "trouble signing in" `Text` as well as to - * the `Scaffold`, so a caller's sizing, padding or tag silently landed on a single label deep - * inside the layout. It must reach the screen root and nothing else. + * 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`() { 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 index 2a4156e08..4d50327f6 100644 --- 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 @@ -41,14 +41,8 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config /** - * Tests that [PhoneAuthScreen] honours the Compose modifier contract. - * - * The screen declared a `modifier` parameter and documented it as "Optional [Modifier] for the - * composable", but never applied it: the composable rendered either the caller's `content` slot, - * which takes no modifier, or the default per-step UI, which had no `modifier` parameter. The - * parameter was therefore dead, and any caller sizing, padding or tagging the screen through it was - * silently ignored. The screen now applies it once, to the outermost node it introduces for the - * rendered content, so the same instance takes effect on both branches. + * 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 */ 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 7751ad7d2..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 @@ -95,10 +95,8 @@ class AccessibilityTest { } } - // Every box's description is distinct and positional (e.g. "digit 3 of 6"), not the - // single shared literal all six boxes used to carry. Asserting each expected positional - // string resolves to exactly one node proves both properties: the descriptions differ - // from each other and each one names its own position. + // 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) } 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 32eb8ffec..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 @@ -246,10 +246,8 @@ class CredentialLinkingScreenTest { // Step 6: Enter verification code println("TEST: Entering verification code: $phoneCode") - // The whole code goes in through the published tag in one call: the tagged group is the - // editable node and spreads the string across its digit boxes. Selecting the boxes - // positionally out of onAllNodes(hasSetTextAction()) — as this did — depended on which - // nodes happen to accept text and in what order. + // 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) 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 6fbda9f6d..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 @@ -226,10 +226,8 @@ class PhoneAuthScreenTest { // Check current page is Verify Phone Number & Enter verification code composeTestRule.onNodeWithText(stringProvider.verifyPhoneNumber) - // The whole code goes in through the published tag in one call: the tagged group is the - // editable node and spreads the string across its digit boxes. Selecting the boxes - // positionally out of onAllNodes(hasSetTextAction()) — as this did — depended on which - // nodes happen to accept text and in what order. + // 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) @@ -499,10 +497,8 @@ class PhoneAuthScreenTest { // Step 5: enter verification code println("TEST: Entering verification code: $phoneCode") - // The whole code goes in through the published tag in one call: the tagged group is the - // editable node and spreads the string across its digit boxes. Selecting the boxes - // positionally out of onAllNodes(hasSetTextAction()) — as this did — depended on which - // nodes happen to accept text and in what order. + // 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) From ea79b30ef25c2d2df88825a8ac2f8cf2862349d4 Mon Sep 17 00:00:00 2001 From: demolaf Date: Wed, 19 Aug 2026 12:47:01 +0100 Subject: [PATCH 15/16] test(auth): remove three redundant tests confirmed by mutation-check --- .../ui/auth/ui/TestTagsAsResourceIdsTest.kt | 35 ------------------- .../auth/ui/components/CountrySelectorTest.kt | 12 ------- .../screens/FirebaseAuthScreenModifierTest.kt | 13 ------- 3 files changed, 60 deletions(-) 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 index abb71c87d..37f0ac793 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/TestTagsAsResourceIdsTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/TestTagsAsResourceIdsTest.kt @@ -529,41 +529,6 @@ class TestTagsAsResourceIdsTest { .assertIsEnabled() } - /** - * The same claim for a host app's own Compose tests: `performTextInput` on the published tag - * enters the whole code, exercising `InsertTextAtCursor` rather than `SetText`. - */ - @Test - fun `performTextInput on the verification code tag enters the whole code`() { - 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 = { }, - ) - } - - composeTestRule - .onNodeWithTag(FirebaseAuthTestTags.VerificationCode.CODE_FIELD) - .performTextInput(VERIFICATION_CODE) - - // The code reaches the caller from a LaunchedEffect keyed on the digits, so the recomposition - // the action triggers has to settle before the callback has run. - composeTestRule.waitForIdle() - - assertWithMessage( - "performTextInput on the published verification code tag did not enter the code." - ).that(entered.value).isEqualTo(VERIFICATION_CODE) - } - /** * 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. 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 index d9c60368c..24d9b1185 100644 --- 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 @@ -84,18 +84,6 @@ class CountrySelectorTest { // ---- Test Tag Tests ---- - @Test - fun `CountrySelector tags the country list once the bottom sheet is open`() { - setCountrySelectorContent() - - composeTestRule.onNodeWithContentDescription("Country selector").performClick() - composeTestRule.waitForIdle() - - composeTestRule - .onNodeWithTag(FirebaseAuthTestTags.CountrySelector.COUNTRY_LIST) - .assertIsDisplayed() - } - @Test fun `CountrySelector does not tag a country list while the bottom sheet is closed`() { setCountrySelectorContent() 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 index 48146ea07..53fcbff71 100644 --- 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 @@ -182,19 +182,6 @@ class FirebaseAuthScreenModifierTest { .assertCountEquals(1) } - /** - * Regression guard for the method-picker route: the tag must land once, at the root, not on - * the picker's own `Column`. This held before the fix too; the routing case above pins it. - */ - @Test - fun `caller modifier is applied once on the method picker route`() { - setContent(methodPickerConfiguration(), Modifier.testTag(CALLER_TAG)) - - composeTestRule - .onAllNodesWithTag(CALLER_TAG, 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. From d442657dfaee6cfb8dec40271290651c1158c77a Mon Sep 17 00:00:00 2001 From: demolaf Date: Wed, 19 Aug 2026 13:50:41 +0100 Subject: [PATCH 16/16] fix(auth): default verificationCodeDigitDescription to preserve source compatibility --- .../auth/configuration/string_provider/AuthUIStringProvider.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 58b48e4d2..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 @@ -423,7 +423,8 @@ interface AuthUIStringProvider { * 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 + fun verificationCodeDigitDescription(position: Int, total: Int): String = + "Verification code digit $position of $total" /** Generic identity verified confirmation message. */ val identityVerifiedMessage: String