From 5f2ffd33585ce1db4b989ea4fa87c09b5d5e4de4 Mon Sep 17 00:00:00 2001 From: Brian Yencho Date: Wed, 31 Jan 2024 23:29:41 -0600 Subject: [PATCH] BIT-1683: Show master password reprompts on Search Screen (#925) --- .../platform/feature/search/SearchContent.kt | 38 +- .../feature/search/SearchViewModel.kt | 90 ++++- .../search/util/SearchTypeDataExtensions.kt | 2 +- .../feature/search/SearchScreenTest.kt | 341 +++++++++++++++--- .../feature/search/SearchViewModelTest.kt | 61 +++- .../bitwarden/ui/util/ComposeTestHelpers.kt | 32 ++ 6 files changed, 488 insertions(+), 76 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchContent.kt index c65a28c2b4..05688f2fde 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchContent.kt @@ -133,6 +133,34 @@ fun SearchContent( showConfirmationDialog = option } + is ListingItemOverflowAction.VaultAction.EditClick -> { + if (it.shouldDisplayMasterPasswordReprompt) { + masterPasswordRepromptData = + MasterPasswordRepromptData( + cipherId = it.id, + type = MasterPasswordRepromptData.Type.Edit, + ) + } else { + searchHandlers.onOverflowItemClick(option) + } + } + + is ListingItemOverflowAction.VaultAction.CopyPasswordClick -> { + if (it.shouldDisplayMasterPasswordReprompt) { + masterPasswordRepromptData = + MasterPasswordRepromptData( + cipherId = it.id, + type = MasterPasswordRepromptData + .Type + .CopyPassword( + password = option.password, + ), + ) + } else { + searchHandlers.onOverflowItemClick(option) + } + } + else -> searchHandlers.onOverflowItemClick(option) } }, @@ -179,13 +207,15 @@ private fun AutofillSelectionDialog( ) } else { when (type) { - MasterPasswordRepromptData.Type.AUTOFILL -> { + MasterPasswordRepromptData.Type.Autofill -> { onAutofillItemClick(item.id) } - MasterPasswordRepromptData.Type.AUTOFILL_AND_SAVE -> { + MasterPasswordRepromptData.Type.AutofillAndSave -> { onAutofillAndSaveItemClick(item.id) } + + else -> Unit } } } @@ -199,7 +229,7 @@ private fun AutofillSelectionDialog( onClick = { selectionCallback( displayItem, - MasterPasswordRepromptData.Type.AUTOFILL, + MasterPasswordRepromptData.Type.Autofill, ) }, ) @@ -210,7 +240,7 @@ private fun AutofillSelectionDialog( onClick = { selectionCallback( displayItem, - MasterPasswordRepromptData.Type.AUTOFILL_AND_SAVE, + MasterPasswordRepromptData.Type.AutofillAndSave, ) }, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt index f54dff659f..2c0d03f7d2 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt @@ -517,25 +517,53 @@ class SearchViewModel @Inject constructor( } return } + handleMasterPasswordRepromptData(data = action.masterPasswordRepromptData) + } + } + } - // Complete the deferred actions - when (action.masterPasswordRepromptData.type) { - MasterPasswordRepromptData.Type.AUTOFILL -> { - trySendAction( - SearchAction.AutofillItemClick( - itemId = action.masterPasswordRepromptData.cipherId, - ), - ) - } + private fun handleMasterPasswordRepromptData( + data: MasterPasswordRepromptData, + ) { + // Complete the deferred actions + val cipherId = data.cipherId + when (val type = data.type) { + MasterPasswordRepromptData.Type.Autofill -> { + trySendAction( + SearchAction.AutofillItemClick( + itemId = cipherId, + ), + ) + } - MasterPasswordRepromptData.Type.AUTOFILL_AND_SAVE -> { - trySendAction( - SearchAction.AutofillAndSaveItemClick( - itemId = action.masterPasswordRepromptData.cipherId, + MasterPasswordRepromptData.Type.AutofillAndSave -> { + trySendAction( + SearchAction.AutofillAndSaveItemClick( + itemId = cipherId, + ), + ) + } + + MasterPasswordRepromptData.Type.Edit -> { + trySendAction( + SearchAction.OverflowOptionClick( + overflowAction = ListingItemOverflowAction.VaultAction.EditClick( + cipherId = cipherId, + ), + ), + ) + } + + is MasterPasswordRepromptData.Type.CopyPassword -> { + trySendAction( + SearchAction.OverflowOptionClick( + overflowAction = ListingItemOverflowAction + .VaultAction + .CopyPasswordClick( + password = type.password, ), - ) - } - } + ), + ) } } } @@ -1100,8 +1128,32 @@ data class MasterPasswordRepromptData( /** * The type of action that requires the prompt. */ - enum class Type { - AUTOFILL, - AUTOFILL_AND_SAVE, + sealed class Type : Parcelable { + + /** + * Autofill was selected. + */ + @Parcelize + data object Autofill : Type() + + /** + * Autofill-and-save was selected. + */ + @Parcelize + data object AutofillAndSave : Type() + + /** + * Edit was selected. + */ + @Parcelize + data object Edit : Type() + + /** + * Copy password was selected. + */ + @Parcelize + data class CopyPassword( + val password: String, + ) : Type() } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensions.kt index 5629900259..e8da7d98a9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensions.kt @@ -190,7 +190,7 @@ private fun CipherView.toDisplayItem( .filter { this.login != null || (it != AutofillSelectionOption.AUTOFILL_AND_SAVE) }, - shouldDisplayMasterPasswordReprompt = isAutofill && reprompt == CipherRepromptType.PASSWORD, + shouldDisplayMasterPasswordReprompt = reprompt == CipherRepromptType.PASSWORD, ) private fun CipherView.toIconData( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt index 4d9cf8ed9b..d88e7b962e 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.test.onChildren import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performScrollToNode import androidx.compose.ui.test.performTextInput import androidx.core.net.toUri @@ -23,6 +24,7 @@ import com.x8bit.bitwarden.ui.platform.feature.search.model.AutofillSelectionOpt import com.x8bit.bitwarden.ui.platform.feature.search.util.createMockDisplayItemForCipher import com.x8bit.bitwarden.ui.platform.feature.search.util.createMockDisplayItemForSend import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +import com.x8bit.bitwarden.ui.util.assertMasterPasswordDialogDisplayed import com.x8bit.bitwarden.ui.util.assertNoDialogExists import com.x8bit.bitwarden.ui.util.isProgressBar import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction @@ -285,29 +287,7 @@ class SearchScreenTest : BaseComposeTest() { .assert(hasAnyAncestor(isDialog())) .performClick() - composeTestRule - .onAllNodesWithText(text = "Master password confirmation") - .filterToOne(hasAnyAncestor(isDialog())) - .assertIsDisplayed() - composeTestRule - .onAllNodesWithText( - text = "This action is protected, to continue please re-enter your master " + - "password to verify your identity.", - ) - .filterToOne(hasAnyAncestor(isDialog())) - .assertIsDisplayed() - composeTestRule - .onAllNodesWithText(text = "Master password") - .filterToOne(hasAnyAncestor(isDialog())) - .assertIsDisplayed() - composeTestRule - .onAllNodesWithText(text = "Cancel") - .filterToOne(hasAnyAncestor(isDialog())) - .assertIsDisplayed() - composeTestRule - .onAllNodesWithText(text = "Submit") - .filterToOne(hasAnyAncestor(isDialog())) - .assertIsDisplayed() + composeTestRule.assertMasterPasswordDialogDisplayed() } @Suppress("MaxLineLength") @@ -342,29 +322,7 @@ class SearchScreenTest : BaseComposeTest() { .assert(hasAnyAncestor(isDialog())) .performClick() - composeTestRule - .onAllNodesWithText(text = "Master password confirmation") - .filterToOne(hasAnyAncestor(isDialog())) - .assertIsDisplayed() - composeTestRule - .onAllNodesWithText( - text = "This action is protected, to continue please re-enter your master " + - "password to verify your identity.", - ) - .filterToOne(hasAnyAncestor(isDialog())) - .assertIsDisplayed() - composeTestRule - .onAllNodesWithText(text = "Master password") - .filterToOne(hasAnyAncestor(isDialog())) - .assertIsDisplayed() - composeTestRule - .onAllNodesWithText(text = "Cancel") - .filterToOne(hasAnyAncestor(isDialog())) - .assertIsDisplayed() - composeTestRule - .onAllNodesWithText(text = "Submit") - .filterToOne(hasAnyAncestor(isDialog())) - .assertIsDisplayed() + composeTestRule.assertMasterPasswordDialogDisplayed() } @Suppress("MaxLineLength") @@ -433,7 +391,7 @@ class SearchScreenTest : BaseComposeTest() { password = "password", masterPasswordRepromptData = MasterPasswordRepromptData( cipherId = "mockId-1", - type = MasterPasswordRepromptData.Type.AUTOFILL, + type = MasterPasswordRepromptData.Type.Autofill, ), ), ) @@ -469,7 +427,7 @@ class SearchScreenTest : BaseComposeTest() { password = "password", masterPasswordRepromptData = MasterPasswordRepromptData( cipherId = "mockId-1", - type = MasterPasswordRepromptData.Type.AUTOFILL_AND_SAVE, + type = MasterPasswordRepromptData.Type.AutofillAndSave, ), ), ) @@ -518,6 +476,293 @@ class SearchScreenTest : BaseComposeTest() { composeTestRule.onNodeWithText(text = "Search mockName").assertIsDisplayed() } + @Test + fun `on cipher item overflow click should display options dialog`() { + val number = 1 + mutableStateFlow.update { + it.copy( + viewState = SearchState.ViewState.Content( + displayItems = listOf(createMockDisplayItemForCipher(number = number)), + ), + ) + } + composeTestRule.assertNoDialogExists() + + composeTestRule + .onNodeWithContentDescription("Options") + .assertIsDisplayed() + .performClick() + + composeTestRule + .onNode(isDialog()) + .onChildren() + .filterToOne(hasText("mockName-$number")) + .assertIsDisplayed() + } + + @Test + fun `on cipher item overflow option click should emit the appropriate action`() { + mutableStateFlow.update { + it.copy( + viewState = SearchState.ViewState.Content( + displayItems = listOf(createMockDisplayItemForCipher(number = 1)), + ), + ) + } + + composeTestRule.assertNoDialogExists() + + composeTestRule + .onNodeWithContentDescription("Options") + .assertIsDisplayed() + .performClick() + composeTestRule + .onNodeWithText("View") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + verify(exactly = 1) { + viewModel.trySendAction( + SearchAction.OverflowOptionClick( + overflowAction = ListingItemOverflowAction.VaultAction.ViewClick( + cipherId = "mockId-1", + ), + ), + ) + } + + composeTestRule + .onNodeWithContentDescription("Options") + .assertIsDisplayed() + .performClick() + composeTestRule + .onNodeWithText("Edit") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + verify(exactly = 1) { + viewModel.trySendAction( + SearchAction.OverflowOptionClick( + overflowAction = ListingItemOverflowAction.VaultAction.EditClick( + cipherId = "mockId-1", + ), + ), + ) + } + + composeTestRule + .onNodeWithContentDescription("Options") + .assertIsDisplayed() + .performClick() + composeTestRule + .onNodeWithText("Copy username") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + verify(exactly = 1) { + viewModel.trySendAction( + SearchAction.OverflowOptionClick( + overflowAction = ListingItemOverflowAction.VaultAction.CopyUsernameClick( + username = "mockUsername-1", + ), + ), + ) + } + + composeTestRule + .onNodeWithContentDescription("Options") + .assertIsDisplayed() + .performClick() + composeTestRule + .onNodeWithText("Copy password") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + verify(exactly = 1) { + viewModel.trySendAction( + SearchAction.OverflowOptionClick( + overflowAction = ListingItemOverflowAction.VaultAction.CopyPasswordClick( + password = "mockPassword-1", + ), + ), + ) + } + + composeTestRule + .onNodeWithContentDescription("Options") + .assertIsDisplayed() + .performClick() + composeTestRule + .onNodeWithText("Launch") + .assert(hasAnyAncestor(isDialog())) + .performScrollTo() + .assertIsDisplayed() + .performClick() + verify(exactly = 1) { + viewModel.trySendAction( + SearchAction.OverflowOptionClick( + overflowAction = ListingItemOverflowAction.VaultAction.LaunchClick( + url = "www.mockuri1.com", + ), + ), + ) + } + + composeTestRule.assertNoDialogExists() + } + + @Suppress("MaxLineLength") + @Test + fun `on cipher item overflow edit click when reprompt required should show the master password dialog`() { + mutableStateFlow.update { + it.copy( + viewState = SearchState.ViewState.Content( + displayItems = listOf( + createMockDisplayItemForCipher(number = 1) + .copy(shouldDisplayMasterPasswordReprompt = true), + ), + ), + ) + } + composeTestRule + .onNodeWithContentDescription("Options") + .assertIsDisplayed() + .performClick() + + composeTestRule + .onNodeWithText("Edit") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + + verify(exactly = 0) { viewModel.trySendAction(any()) } + + composeTestRule.assertMasterPasswordDialogDisplayed() + } + + @Suppress("MaxLineLength") + @Test + fun `clicking submit on the master password dialog for edit should close the dialog and send MasterPasswordRepromptSubmit`() { + mutableStateFlow.update { + it.copy( + viewState = SearchState.ViewState.Content( + displayItems = listOf( + createMockDisplayItemForCipher(number = 1) + .copy(shouldDisplayMasterPasswordReprompt = true), + ), + ), + ) + } + composeTestRule + .onNodeWithContentDescription("Options") + .assertIsDisplayed() + .performClick() + composeTestRule + .onNodeWithText("Edit") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + + composeTestRule + .onAllNodesWithText(text = "Master password") + .filterToOne(hasAnyAncestor(isDialog())) + .performTextInput("password") + composeTestRule + .onAllNodesWithText(text = "Submit") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + + verify { + viewModel.trySendAction( + SearchAction.MasterPasswordRepromptSubmit( + password = "password", + masterPasswordRepromptData = MasterPasswordRepromptData( + cipherId = "mockId-1", + type = MasterPasswordRepromptData.Type.Edit, + ), + ), + ) + } + composeTestRule.assertNoDialogExists() + } + + @Suppress("MaxLineLength") + @Test + fun `on cipher item overflow copy password click when reprompt required should show the master password dialog`() { + mutableStateFlow.update { + it.copy( + viewState = SearchState.ViewState.Content( + displayItems = listOf( + createMockDisplayItemForCipher(number = 1) + .copy(shouldDisplayMasterPasswordReprompt = true), + ), + ), + ) + } + composeTestRule + .onNodeWithContentDescription("Options") + .assertIsDisplayed() + .performClick() + + composeTestRule + .onNodeWithText("Copy password") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + + verify(exactly = 0) { viewModel.trySendAction(any()) } + + composeTestRule.assertMasterPasswordDialogDisplayed() + } + + @Suppress("MaxLineLength") + @Test + fun `clicking submit on the master password dialog for copy password should close the dialog and send MasterPasswordRepromptSubmit`() { + mutableStateFlow.update { + it.copy( + viewState = SearchState.ViewState.Content( + displayItems = listOf( + createMockDisplayItemForCipher(number = 1) + .copy(shouldDisplayMasterPasswordReprompt = true), + ), + ), + ) + } + composeTestRule + .onNodeWithContentDescription("Options") + .assertIsDisplayed() + .performClick() + composeTestRule + .onNodeWithText("Copy password") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + + composeTestRule + .onAllNodesWithText(text = "Master password") + .filterToOne(hasAnyAncestor(isDialog())) + .performTextInput("password") + composeTestRule + .onAllNodesWithText(text = "Submit") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + + verify { + viewModel.trySendAction( + SearchAction.MasterPasswordRepromptSubmit( + password = "password", + masterPasswordRepromptData = MasterPasswordRepromptData( + cipherId = "mockId-1", + type = MasterPasswordRepromptData.Type.CopyPassword( + password = "mockPassword-1", + ), + ), + ), + ) + } + composeTestRule.assertNoDialogExists() + } + @Test fun `on send item overflow click should display dialog`() { val number = 1 diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt index 4c11cf9882..31b962d1dc 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt @@ -330,7 +330,7 @@ class SearchViewModelTest : BaseViewModelTest() { password = password, masterPasswordRepromptData = MasterPasswordRepromptData( cipherId = cipherId, - type = MasterPasswordRepromptData.Type.AUTOFILL, + type = MasterPasswordRepromptData.Type.Autofill, ), ), ) @@ -368,7 +368,7 @@ class SearchViewModelTest : BaseViewModelTest() { password = password, masterPasswordRepromptData = MasterPasswordRepromptData( cipherId = cipherId, - type = MasterPasswordRepromptData.Type.AUTOFILL, + type = MasterPasswordRepromptData.Type.Autofill, ), ), ) @@ -403,7 +403,7 @@ class SearchViewModelTest : BaseViewModelTest() { password = password, masterPasswordRepromptData = MasterPasswordRepromptData( cipherId = cipherId, - type = MasterPasswordRepromptData.Type.AUTOFILL, + type = MasterPasswordRepromptData.Type.Autofill, ), ), ) @@ -447,7 +447,7 @@ class SearchViewModelTest : BaseViewModelTest() { password = password, masterPasswordRepromptData = MasterPasswordRepromptData( cipherId = cipherId, - type = MasterPasswordRepromptData.Type.AUTOFILL_AND_SAVE, + type = MasterPasswordRepromptData.Type.AutofillAndSave, ), ), ) @@ -460,6 +460,59 @@ class SearchViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") + @Test + fun `MasterPasswordRepromptSubmit for a request Success with a valid password for edit should emit NavigateToEditCipher`() = + runTest { + val cipherId = "cipherId-1234" + val password = "password" + val viewModel = createViewModel() + coEvery { + authRepository.validatePassword(password = password) + } returns ValidatePasswordResult.Success(isValid = true) + + viewModel.eventFlow.test { + viewModel.trySendAction( + SearchAction.MasterPasswordRepromptSubmit( + password = password, + masterPasswordRepromptData = MasterPasswordRepromptData( + cipherId = cipherId, + type = MasterPasswordRepromptData.Type.Edit, + ), + ), + ) + assertEquals(SearchEvent.NavigateToEditCipher(cipherId), awaitItem()) + } + } + + @Suppress("MaxLineLength") + @Test + fun `MasterPasswordRepromptSubmit for a request Success with a valid password for copy password should call setText on the ClipboardManager`() = + runTest { + val cipherId = "cipherId-1234" + val password = "password" + val viewModel = createViewModel() + coEvery { + authRepository.validatePassword(password = password) + } returns ValidatePasswordResult.Success(isValid = true) + + viewModel.trySendAction( + SearchAction.MasterPasswordRepromptSubmit( + password = password, + masterPasswordRepromptData = MasterPasswordRepromptData( + cipherId = cipherId, + type = MasterPasswordRepromptData.Type.CopyPassword( + password = password, + ), + ), + ), + ) + + verify(exactly = 1) { + clipboardManager.setText(password) + } + } + @Test fun `OverflowOptionClick Send EditClick should emit NavigateToEditSend`() = runTest { val sendId = "sendId" diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/util/ComposeTestHelpers.kt b/app/src/test/java/com/x8bit/bitwarden/ui/util/ComposeTestHelpers.kt index 48371d1d23..96420fae02 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/util/ComposeTestHelpers.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/util/ComposeTestHelpers.kt @@ -5,6 +5,9 @@ import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.SemanticsNodeInteractionCollection +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasScrollToNodeAction import androidx.compose.ui.test.hasText @@ -44,6 +47,35 @@ fun ComposeContentTestRule.assertNoDialogExists() { .assertDoesNotExist() } +/** + * Asserts that the master password reprompt dialog is displayed. + */ +fun ComposeContentTestRule.assertMasterPasswordDialogDisplayed() { + this + .onAllNodesWithText(text = "Master password confirmation") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + this + .onAllNodesWithText( + text = "This action is protected, to continue please re-enter your master " + + "password to verify your identity.", + ) + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + this + .onAllNodesWithText(text = "Master password") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + this + .onAllNodesWithText(text = "Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + this + .onAllNodesWithText(text = "Submit") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() +} + /** * A helper that asserts that the node does not exist in the scrollable list. */