diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:34:50 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:34:50 +0000 |
commit | def92d1b8e9d373e2f6f27c366d578d97d8960c6 (patch) | |
tree | 2ef34b9ad8bb9a9220e05d60352558b15f513894 /mobile/android/focus-android/app/src/test | |
parent | Adding debian version 125.0.3-1. (diff) | |
download | firefox-def92d1b8e9d373e2f6f27c366d578d97d8960c6.tar.xz firefox-def92d1b8e9d373e2f6f27c366d578d97d8960c6.zip |
Merging upstream version 126.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'mobile/android/focus-android/app/src/test')
38 files changed, 4118 insertions, 0 deletions
diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/BrowserFragmentTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/BrowserFragmentTest.kt new file mode 100644 index 0000000000..dd89505a94 --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/BrowserFragmentTest.kt @@ -0,0 +1,108 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.focus + +import android.content.Context +import android.graphics.Bitmap +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.EngineView +import mozilla.components.concept.engine.selection.SelectionActionDelegate +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoMoreInteractions +import org.mozilla.focus.databinding.FragmentBrowserBinding +import org.mozilla.focus.widget.ResizableKeyboardCoordinatorLayout +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class BrowserFragmentTest { + @Test + fun testEngineViewInflationAndParentInteraction() { + val layoutInflater = LayoutInflater.from(testContext) + + // Intercept the inflation process + layoutInflater.factory2 = object : LayoutInflater.Factory2 { + override fun onCreateView( + parent: View?, + name: String, + context: Context, + attrs: AttributeSet, + ): View? { + // Inflate a DummyEngineView when trying to inflate an EngineView + if (name == EngineView::class.java.name) { + return DummyEngineView(testContext) + } + + // For other types of views, let the system handle it + return null + } + + override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? { + return onCreateView(null, name, context, attrs) + } + } + + val binding = FragmentBrowserBinding.inflate(LayoutInflater.from(testContext)) + val engineView: EngineView = binding.engineView + + assertNotNull(engineView) + + // Get the layout parent of the EngineView + val engineViewParent = spy( + (engineView as View).parent as ResizableKeyboardCoordinatorLayout, + ) + + assertNotNull(engineViewParent) + + engineViewParent.requestDisallowInterceptTouchEvent(true) + + // Verify that the EngineView's parent does not propagate requestDisallowInterceptTouchEvent + verify(engineViewParent).requestDisallowInterceptTouchEvent(true) + // If propagated, an additional ViewGroup.requestDisallowInterceptTouchEvent would have been registered. + verifyNoMoreInteractions(engineViewParent) + } +} + +/** + * Dummy implementation of the EngineView interface. + */ +class DummyEngineView(context: Context) : View(context), EngineView { + init { + id = R.id.engineView + } + + override fun render(session: EngineSession) { + // no-op + } + + override fun release() { + // no-op + } + + override fun captureThumbnail(onFinish: (Bitmap?) -> Unit) { + // no-op + } + + override fun setVerticalClipping(clippingHeight: Int) { + // no-op + } + + override fun setDynamicToolbarMaxHeight(height: Int) { + // no-op + } + + override fun setActivityContext(context: Context?) { + // no-op + } + + override var selectionActionDelegate: SelectionActionDelegate? = null +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/TestFocusApplication.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/TestFocusApplication.kt new file mode 100644 index 0000000000..7892c347b6 --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/TestFocusApplication.kt @@ -0,0 +1,95 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.focus + +import android.content.Context +import android.util.AttributeSet +import android.util.JsonReader +import android.util.JsonWriter +import mozilla.components.browser.engine.gecko.profiler.Profiler +import mozilla.components.concept.engine.DefaultSettings +import mozilla.components.concept.engine.Engine +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.EngineSessionState +import mozilla.components.concept.engine.EngineView +import mozilla.components.concept.engine.Settings +import mozilla.components.concept.engine.utils.EngineVersion +import mozilla.components.concept.engine.webextension.WebExtensionDelegate +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.Response +import org.json.JSONObject + +/** + * [FocusApplication] override for unit tests. This allows us to override some parameters and inputs + * since an application object gets created without much control otherwise. + */ +class TestFocusApplication : FocusApplication() { + override val components: Components by lazy { + Components(this, engineOverride = FakeEngine(), clientOverride = FakeClient()) + } + + override fun initializeNimbus() = Unit +} + +/** + * Empty [FocusApplication] override for unit tests. + */ +class EmptyFocusApplication : FocusApplication() { + override fun onCreate() { + // + } +} + +// Borrowed this from AC unit tests. This is something we should consider moving to support-test, so +// that everyone who needs an Engine in unit tests can use it. It also allows us to enhance this mock +// and maybe do some actual things that help in tests. :) +class FakeEngine : Engine { + override val version: EngineVersion + get() = throw NotImplementedError("Not needed for test") + + override fun createView(context: Context, attrs: AttributeSet?): EngineView = + throw UnsupportedOperationException() + + override fun createSession(private: Boolean, contextId: String?): EngineSession = + throw UnsupportedOperationException() + + override fun createSessionState(json: JSONObject) = FakeEngineSessionState() + + override fun createSessionStateFrom(reader: JsonReader): EngineSessionState { + reader.beginObject() + reader.endObject() + return FakeEngineSessionState() + } + + override fun name(): String = + throw UnsupportedOperationException() + + override fun speculativeConnect(url: String) = + throw UnsupportedOperationException() + + override val profiler: Profiler + get() = throw NotImplementedError("Not needed for test") + + override val settings: Settings = DefaultSettings() + + override fun registerWebExtensionDelegate(webExtensionDelegate: WebExtensionDelegate) { + // Intentionally empty to avoid "UnsupportedOperationException: Web extension support + // is not available in this engine" error in unit tests + } +} + +class FakeEngineSessionState : EngineSessionState { + override fun writeTo(writer: JsonWriter) { + writer.beginObject() + writer.endObject() + } +} + +class FakeClient : Client() { + override fun fetch(request: Request): Response { + throw UnsupportedOperationException() + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/animation/TransitionDrawableGroupTest.java b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/animation/TransitionDrawableGroupTest.java new file mode 100644 index 0000000000..d21307eb4b --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/animation/TransitionDrawableGroupTest.java @@ -0,0 +1,42 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.focus.animation; + +import android.graphics.drawable.TransitionDrawable; + +import org.junit.Test; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +public class TransitionDrawableGroupTest { + @Test + public void testStartIsCalledOnAllItems() { + final TransitionDrawable transitionDrawable1 = mock(TransitionDrawable.class); + final TransitionDrawable transitionDrawable2 = mock(TransitionDrawable.class); + + final TransitionDrawableGroup group = new TransitionDrawableGroup( + transitionDrawable1, transitionDrawable2); + + group.startTransition(2500); + + verify(transitionDrawable1).startTransition(2500); + verify(transitionDrawable2).startTransition(2500); + } + + @Test + public void testResetIsCalledOnAllItems() { + final TransitionDrawable transitionDrawable1 = mock(TransitionDrawable.class); + final TransitionDrawable transitionDrawable2 = mock(TransitionDrawable.class); + + final TransitionDrawableGroup group = new TransitionDrawableGroup( + transitionDrawable1, transitionDrawable2); + + group.resetTransition(); + + verify(transitionDrawable1).resetTransition(); + verify(transitionDrawable2).resetTransition(); + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/biometrics/BiometricAuthenticationFragmentTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/biometrics/BiometricAuthenticationFragmentTest.kt new file mode 100644 index 0000000000..7c19fcdb91 --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/biometrics/BiometricAuthenticationFragmentTest.kt @@ -0,0 +1,70 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.focus.biometrics + +import android.content.Context +import android.os.Build +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentTransaction +import mozilla.components.lib.auth.AuthenticationDelegate +import mozilla.components.lib.auth.BiometricPromptAuth +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.never +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify +import org.robolectric.annotation.Config + +class BiometricAuthenticationFragmentTest { + private lateinit var biometricPromptAuth: BiometricPromptAuth + private lateinit var fragment: BiometricAuthenticationFragment + private val activity: FragmentActivity = mock() + private val testContext: Context = mock() + + private val fragmentManger: FragmentManager = mock() + private val fragmentTransaction: FragmentTransaction = mock() + + @Before + fun setup() { + fragment = spy(BiometricAuthenticationFragment()) + doReturn(testContext).`when`(fragment).context + doReturn(activity).`when`(fragment).requireActivity() + doReturn(fragmentManger).`when`(activity).supportFragmentManager + doReturn(fragmentTransaction).`when`(fragmentManger).beginTransaction() + biometricPromptAuth = spy( + BiometricPromptAuth( + testContext, + fragment, + object : AuthenticationDelegate { + override fun onAuthError(errorText: String) { + } + override fun onAuthFailure() { + } + override fun onAuthSuccess() { + } + }, + ), + ) + } + + @Config(sdk = [Build.VERSION_CODES.LOLLIPOP]) + @Test + fun `GIVEN biometric authentication fragment WHEN show biometric prompt is called and can use feature returns false THEN request authentication is not called`() { + fragment.showBiometricPrompt(biometricPromptAuth, "title", "subtitle") + + verify(biometricPromptAuth, never()).requestAuthentication("title", "subtitle") + } + + @Test + fun `GIVEN biometric authentication fragment WHEN on Auth Error is called THEN biometricErrorText should be updated`() { + fragment.onAuthError("Fingerprint operation canceled by user.") + + assertEquals(fragment.biometricErrorText.value, "Fingerprint operation canceled by user.") + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/browser/integration/BrowserToolbarIntegrationTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/browser/integration/BrowserToolbarIntegrationTest.kt new file mode 100644 index 0000000000..179fafeb2c --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/browser/integration/BrowserToolbarIntegrationTest.kt @@ -0,0 +1,234 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.focus.browser.integration + +import android.view.View +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import mozilla.components.browser.state.action.ContentAction +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.state.SecurityInfoState +import mozilla.components.browser.state.state.TabSessionState +import mozilla.components.browser.state.state.createTab +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.browser.toolbar.BrowserToolbar +import mozilla.components.browser.toolbar.display.DisplayToolbar.Indicators +import mozilla.components.support.test.ext.joinBlocking +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.whenever +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.doNothing +import org.mockito.Mockito.spy +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations +import org.mozilla.focus.fragment.BrowserFragment +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class BrowserToolbarIntegrationTest { + private val testDispatcher = UnconfinedTestDispatcher() + private val selectedTab = createSecureTab() + + private lateinit var toolbar: BrowserToolbar + + @Mock + private lateinit var fragment: BrowserFragment + + private lateinit var browserToolbarIntegration: BrowserToolbarIntegration + + @Mock + private lateinit var fragmentView: View + + private lateinit var store: BrowserStore + + @Before + @ExperimentalCoroutinesApi + fun setUp() { + MockitoAnnotations.openMocks(this) + Dispatchers.setMain(testDispatcher) + store = spy( + BrowserStore( + initialState = BrowserState( + tabs = listOf(selectedTab), + selectedTabId = selectedTab.id, + ), + ), + ) + + toolbar = BrowserToolbar(testContext) + + whenever(fragment.resources).thenReturn(testContext.resources) + whenever(fragment.context).thenReturn(testContext) + whenever(fragment.view).thenReturn(fragmentView) + whenever(fragment.requireContext()).thenReturn(testContext) + + browserToolbarIntegration = spy( + BrowserToolbarIntegration( + store = store, + toolbar = toolbar, + fragment = fragment, + controller = mock(), + sessionUseCases = mock(), + customTabsUseCases = mock(), + onUrlLongClicked = { false }, + eraseActionListener = {}, + tabCounterListener = {}, + inTesting = true, + ), + ) + } + + @After + @ExperimentalCoroutinesApi + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `WHEN starting THEN observe security changes`() { + doNothing().`when`(browserToolbarIntegration).observerSecurityIndicatorChanges() + + browserToolbarIntegration.start() + + verify(browserToolbarIntegration).observerSecurityIndicatorChanges() + } + + @Test + fun `WHEN start method is called THEN observe erase tabs CFR changes`() { + doNothing().`when`(browserToolbarIntegration).observeEraseCfr() + + browserToolbarIntegration.start() + + verify(browserToolbarIntegration).observeEraseCfr() + } + + @Test + fun `WHEN start method is called THEN observe tracking protection CFR changes`() { + doNothing().`when`(browserToolbarIntegration).observeTrackingProtectionCfr() + + browserToolbarIntegration.start() + + verify(browserToolbarIntegration).observeTrackingProtectionCfr() + } + + @Test + fun `WHEN stopping THEN stop tracking protection CFR changes`() { + doNothing().`when`(browserToolbarIntegration).stopObserverTrackingProtectionCfrChanges() + + browserToolbarIntegration.stop() + + verify(browserToolbarIntegration).stopObserverTrackingProtectionCfrChanges() + } + + @Test + fun `WHEN stopping THEN stop erase tabs CFR changes`() { + doNothing().`when`(browserToolbarIntegration).stopObserverEraseTabsCfrChanges() + + browserToolbarIntegration.stop() + + verify(browserToolbarIntegration).stopObserverEraseTabsCfrChanges() + } + + @Test + fun `WHEN stopping THEN stop observe security changes`() { + doNothing().`when`(browserToolbarIntegration).stopObserverSecurityIndicatorChanges() + + browserToolbarIntegration.stop() + + verify(browserToolbarIntegration).stopObserverSecurityIndicatorChanges() + } + + @Test + fun `GIVEN an insecure site WHEN observing security changes THEN add the security icon`() { + browserToolbarIntegration.start() + + updateSecurityStatus(secure = false) + + verify(browserToolbarIntegration).addSecurityIndicator() + assertEquals(listOf(Indicators.SECURITY), toolbar.display.indicators) + } + + @Test + fun `GIVEN an about site WHEN observing security changes THEN DO NOT add the security icon`() { + browserToolbarIntegration.start() + + updateTabUrl("about:") + + verify(browserToolbarIntegration, times(0)).addSecurityIndicator() + assertEquals(listOf(Indicators.TRACKING_PROTECTION), toolbar.display.indicators) + } + + @Test + fun `GIVEN a secure site after a previous insecure site WHEN observing security changes THEN add the tracking protection icon`() { + browserToolbarIntegration.start() + + updateSecurityStatus(secure = false) + + verify(browserToolbarIntegration).addSecurityIndicator() + + updateSecurityStatus(secure = true) + + verify(browserToolbarIntegration).addTrackingProtectionIndicator() + assertEquals(listOf(Indicators.TRACKING_PROTECTION), toolbar.display.indicators) + } + + @Test + fun `WHEN the integration starts THEN start the toolbarController`() { + browserToolbarIntegration.toolbarController = mock() + + browserToolbarIntegration.start() + + verify(browserToolbarIntegration.toolbarController).start() + } + + @Test + fun `WHEN the integration stops THEN stop the toolbarController`() { + browserToolbarIntegration.toolbarController = mock() + + browserToolbarIntegration.stop() + + verify(browserToolbarIntegration.toolbarController).stop() + } + + private fun updateSecurityStatus(secure: Boolean) { + store.dispatch( + ContentAction.UpdateSecurityInfoAction( + selectedTab.id, + SecurityInfoState( + secure = secure, + host = "mozilla.org", + issuer = "Mozilla", + ), + ), + ).joinBlocking() + + testDispatcher.scheduler.advanceUntilIdle() + } + + private fun updateTabUrl(url: String) { + store.dispatch( + ContentAction.UpdateUrlAction(selectedTab.id, url), + ).joinBlocking() + + testDispatcher.scheduler.advanceUntilIdle() + } + + private fun createSecureTab(): TabSessionState { + val tab = createTab("https://www.mozilla.org", id = "1") + return tab.copy( + content = tab.content.copy(securityInfo = SecurityInfoState(secure = true)), + ) + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/browser/integration/FindInPageIntegrationTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/browser/integration/FindInPageIntegrationTest.kt new file mode 100644 index 0000000000..21222e9e8c --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/browser/integration/FindInPageIntegrationTest.kt @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.focus.browser.integration + +import androidx.core.view.isVisible +import mozilla.components.browser.state.state.ContentState +import mozilla.components.browser.state.state.EngineState +import mozilla.components.browser.state.state.SessionState +import mozilla.components.browser.toolbar.BrowserToolbar +import mozilla.components.feature.findinpage.view.FindInPageBar +import mozilla.components.support.test.mock +import mozilla.components.support.test.whenever +import org.junit.Test +import org.mockito.Mockito +import org.mockito.Mockito.spy + +internal class FindInPageIntegrationTest { + // For ease of tests naming "find in page bar" is referred to as FIPB. + val toolbar: BrowserToolbar = mock() + val findInPageBar: FindInPageBar = mock() + + @Test + fun `GIVEN FIPB not shown WHEN show is called THEN FIPB is shown`() { + val feature = spy(FindInPageIntegration(mock(), findInPageBar, toolbar, mock())) + val sessionState: SessionState = mock() + val contentState: ContentState = mock() + val engineState: EngineState = mock() + whenever(sessionState.content).thenReturn(contentState) + whenever(sessionState.engineState).thenReturn(engineState) + + feature.show(sessionState) + + Mockito.verify(findInPageBar).isVisible = true + } + + @Test + fun `GIVEN FIPB is shown WHEN hide is called THEN FIPB is hidden`() { + val feature = spy(FindInPageIntegration(mock(), findInPageBar, toolbar, mock())) + + feature.hide() + + Mockito.verify(findInPageBar).isVisible = false + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/browser/integration/FullScreenIntegrationTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/browser/integration/FullScreenIntegrationTest.kt new file mode 100644 index 0000000000..3f9d9270c4 --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/browser/integration/FullScreenIntegrationTest.kt @@ -0,0 +1,374 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.focus.browser.integration + +import android.app.Activity +import android.content.res.Resources +import android.view.View +import android.view.Window +import android.view.WindowManager +import androidx.core.view.isVisible +import mozilla.components.browser.engine.gecko.GeckoEngineView +import mozilla.components.browser.toolbar.BrowserToolbar +import mozilla.components.feature.prompts.dialog.FullScreenNotification +import mozilla.components.feature.session.FullScreenFeature +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.inOrder +import org.mockito.Mockito.never +import org.mockito.Mockito.spy +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mozilla.focus.ext.disableDynamicBehavior +import org.mozilla.focus.ext.enableDynamicBehavior +import org.mozilla.focus.ext.hide +import org.mozilla.focus.ext.showAsFixed +import org.mozilla.focus.utils.Settings +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class FullScreenIntegrationTest { + @Test + fun `WHEN the integration is started THEN start FullScreenFeature`() { + val feature: FullScreenFeature = mock() + val integration = FullScreenIntegration( + mock(), + mock(), + null, + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + ).apply { + this.feature = feature + } + + integration.start() + + verify(feature).start() + } + + @Test + fun `WHEN the integration is stopped THEN stop FullScreenFeature`() { + val feature: FullScreenFeature = mock() + val integration = FullScreenIntegration( + mock(), + mock(), + null, + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + ).apply { + this.feature = feature + } + + integration.stop() + + verify(feature).stop() + } + + @Test + fun `WHEN back is pressed THEN send this to the feature`() { + val feature: FullScreenFeature = mock() + val integration = FullScreenIntegration( + mock(), + mock(), + null, + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + ).apply { + this.feature = feature + } + + integration.onBackPressed() + + verify(feature).onBackPressed() + } + + @Test + fun `WHEN the viewport changes THEN update layoutInDisplayCutoutMode`() { + val windowAttributes = WindowManager.LayoutParams() + val activityWindow: Window = mock() + val activity: Activity = mock() + doReturn(activityWindow).`when`(activity).window + doReturn(windowAttributes).`when`(activityWindow).attributes + val integration = FullScreenIntegration( + activity, + mock(), + null, + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + ) + + integration.viewportFitChanged(33) + + assertEquals(33, windowAttributes.layoutInDisplayCutoutMode) + } + + @Test + @Suppress("DEPRECATION") + fun `WHEN entering immersive mode THEN hide all system bars`() { + val decorView: View = mock() + val activityWindow: Window = mock() + val activity: Activity = mock() + val layoutParams: WindowManager.LayoutParams = mock() + doReturn(activityWindow).`when`(activity).window + doReturn(decorView).`when`(activityWindow).decorView + doReturn(layoutParams).`when`(activityWindow).attributes + + val integration = FullScreenIntegration( + activity, + mock(), + null, + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + ) + + integration.switchToImmersiveMode() + + // verify entering immersive mode + verify(decorView).systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN + verify(decorView).systemUiVisibility = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + // verify that the immersive mode restoration is set as expected + verify(decorView).setOnApplyWindowInsetsListener(any()) + } + + @Test + @Suppress("DEPRECATION") + fun `GIVEN immersive mode WHEN exitImmersiveModeIfNeeded is called THEN show the system bars`() { + val decorView: View = mock() + val activityWindow: Window = mock() + val activity: Activity = mock() + val layoutParams: WindowManager.LayoutParams = mock() + doReturn(activityWindow).`when`(activity).window + doReturn(decorView).`when`(activityWindow).decorView + doReturn(layoutParams).`when`(activityWindow).attributes + val integration = FullScreenIntegration( + activity, + mock(), + null, + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + ) + + integration.exitImmersiveMode() + // Hiding the system bar hides the status and navigation bars. + // setSystemUiVisibility will be called twice by WindowInsetsControllerCompat + // once for setting the status bar and another for setting the navigation bar + verify(decorView, times(2)).systemUiVisibility + verify(decorView).setOnApplyWindowInsetsListener(null) + } + + @Test + fun `GIVEN a11y is enabled WHEN enterBrowserFullscreen THEN hide the toolbar`() { + val toolbar: BrowserToolbar = mock() + val engineView: GeckoEngineView = mock() + doReturn(mock<View>()).`when`(engineView).asView() + val settings: Settings = mock() + doReturn(true).`when`(settings).isAccessibilityEnabled() + val integration = FullScreenIntegration( + mock(), + mock(), + null, + mock(), + settings, + toolbar, + mock(), + engineView, + mock(), + ) + + integration.enterBrowserFullscreen() + + verify(toolbar).hide(engineView) + verify(toolbar, never()).collapse() + verify(toolbar, never()).disableDynamicBehavior(engineView) + } + + @Test + fun `GIVEN a11y is disabled WHEN enterBrowserFullscreen THEN collapse and disable the dynamic toolbar`() { + val toolbar: BrowserToolbar = mock() + val engineView: GeckoEngineView = mock() + doReturn(mock<View>()).`when`(engineView).asView() + val settings: Settings = mock() + doReturn(false).`when`(settings).isAccessibilityEnabled() + val integration = FullScreenIntegration( + mock(), + mock(), + null, + mock(), + settings, + toolbar, + mock(), + engineView, + mock(), + ) + + integration.enterBrowserFullscreen() + + verify(toolbar, never()).hide(engineView) + with(inOrder(toolbar)) { + verify(toolbar).collapse() + verify(toolbar).disableDynamicBehavior(engineView) + } + } + + @Test + fun `GIVEN a11y is enabled WHEN exitBrowserFullscreen THEN show the toolbar`() { + val toolbar: BrowserToolbar = mock() + val engineView: GeckoEngineView = mock() + doReturn(mock<View>()).`when`(engineView).asView() + val settings: Settings = mock() + doReturn(true).`when`(settings).isAccessibilityEnabled() + val resources: Resources = mock() + val activity: Activity = mock() + doReturn(resources).`when`(activity).resources + val integration = FullScreenIntegration( + activity, + mock(), + null, + mock(), + settings, + toolbar, + mock(), + engineView, + mock(), + ) + + integration.exitBrowserFullscreen() + + verify(toolbar).showAsFixed(activity, engineView) + verify(toolbar, never()).expand() + verify(toolbar, never()).enableDynamicBehavior(activity, engineView) + } + + @Test + fun `GIVEN a11y is disabled WHEN exitBrowserFullscreen THEN enable the dynamic toolbar and expand it`() { + val toolbar: BrowserToolbar = mock() + val engineView: GeckoEngineView = mock() + doReturn(mock<View>()).`when`(engineView).asView() + val settings: Settings = mock() + doReturn(false).`when`(settings).isAccessibilityEnabled() + val resources: Resources = mock() + val activity: Activity = mock() + doReturn(resources).`when`(activity).resources + val integration = FullScreenIntegration( + activity, + mock(), + null, + mock(), + settings, + toolbar, + mock(), + engineView, + mock(), + ) + + integration.exitBrowserFullscreen() + + verify(toolbar, never()).showAsFixed(activity, engineView) + with(inOrder(toolbar)) { + verify(toolbar).enableDynamicBehavior(activity, engineView) + verify(toolbar).expand() + } + } + + @Test + fun `WHEN entering fullscreen THEN put browser in fullscreen, hide system bars and enter immersive mode`() { + val toolbar = BrowserToolbar(testContext) + val engineView: GeckoEngineView = mock() + doReturn(mock<View>()).`when`(engineView).asView() + val settings: Settings = mock() + doReturn(false).`when`(settings).isAccessibilityEnabled() + val activity = Robolectric.buildActivity(Activity::class.java).get() + val statusBar: View = mock() + val integration = spy( + FullScreenIntegration( + activity, + mock(), + null, + mock(), + settings, + toolbar, + statusBar, + engineView, + mock(), + ), + ) + + val fullScreenNotification = mock<FullScreenNotification>() + integration.fullScreenChanged(true, fullScreenNotification) + + verify(integration).enterBrowserFullscreen() + verify(statusBar).isVisible = false + verify(fullScreenNotification).show(any()) + verify(integration).switchToImmersiveMode() + } + + @Test + fun `WHEN exiting fullscreen THEN put browser in fullscreen, hide system bars and enter immersive mode`() { + val toolbar: BrowserToolbar = mock() + val engineView: GeckoEngineView = mock() + doReturn(mock<View>()).`when`(engineView).asView() + val settings: Settings = mock() + doReturn(false).`when`(settings).isAccessibilityEnabled() + val resources: Resources = mock() + val activityWindow: Window = mock() + val decorView: View = mock() + val windowAttributes = WindowManager.LayoutParams() + val activity: Activity = mock() + doReturn(activityWindow).`when`(activity).window + doReturn(decorView).`when`(activityWindow).decorView + doReturn(windowAttributes).`when`(activityWindow).attributes + doReturn(resources).`when`(activity).resources + val statusBar: View = mock() + val integration = spy( + FullScreenIntegration( + activity, + mock(), + null, + mock(), + settings, + toolbar, + statusBar, + engineView, + mock(), + ), + ) + + integration.fullScreenChanged(false) + + verify(integration).exitBrowserFullscreen() + verify(integration).exitImmersiveMode() + verify(statusBar).isVisible = true + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/browser/integration/InputToolbarIntegrationTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/browser/integration/InputToolbarIntegrationTest.kt new file mode 100644 index 0000000000..c2ac323e70 --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/browser/integration/InputToolbarIntegrationTest.kt @@ -0,0 +1,82 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.focus.browser.integration + +import android.view.View +import kotlinx.coroutines.isActive +import mozilla.components.browser.toolbar.BrowserToolbar +import mozilla.components.support.test.ext.joinBlocking +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.whenever +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mozilla.focus.ext.components +import org.mozilla.focus.fragment.UrlInputFragment +import org.mozilla.focus.input.InputToolbarIntegration +import org.mozilla.focus.state.AppAction +import org.mozilla.focus.state.AppStore +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class InputToolbarIntegrationTest { + private lateinit var toolbar: BrowserToolbar + + @Mock + private lateinit var fragment: UrlInputFragment + + @Mock + private lateinit var fragmentView: View + + private lateinit var inputToolbarIntegration: InputToolbarIntegration + + private val appStore: AppStore = testContext.components.appStore + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + toolbar = BrowserToolbar(testContext) + whenever(fragment.resources).thenReturn(testContext.resources) + whenever(fragment.context).thenReturn(testContext) + whenever(fragment.view).thenReturn(fragmentView) + + inputToolbarIntegration = InputToolbarIntegration( + toolbar, + fragment, + mock(), + mock(), + ) + } + + @Test + fun `GIVEN app fresh install WHEN input toolbar integration is starting THEN start browsing scope is populated`() { + appStore.dispatch(AppAction.ShowStartBrowsingCfrChange(true)).joinBlocking() + + assertNull(inputToolbarIntegration.startBrowsingCfrScope) + + inputToolbarIntegration.start() + + assertNotNull(inputToolbarIntegration.startBrowsingCfrScope) + } + + @Test + fun `GIVEN app fresh install WHEN input toolbar integration is stoping THEN start browsing scope is canceled`() { + inputToolbarIntegration.start() + + assertTrue(inputToolbarIntegration.startBrowsingCfrScope?.isActive ?: true) + + inputToolbarIntegration.stop() + + assertFalse(inputToolbarIntegration.startBrowsingCfrScope?.isActive ?: false) + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/cfr/CfrMiddlewareTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/cfr/CfrMiddlewareTest.kt new file mode 100644 index 0000000000..72999a1748 --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/cfr/CfrMiddlewareTest.kt @@ -0,0 +1,127 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +package org.mozilla.focus.cfr + +import mozilla.components.browser.state.action.ContentAction +import mozilla.components.browser.state.action.TabListAction +import mozilla.components.browser.state.action.TrackingProtectionAction +import mozilla.components.browser.state.state.SecurityInfoState +import mozilla.components.browser.state.state.TabSessionState +import mozilla.components.browser.state.state.createTab +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.content.blocking.Tracker +import mozilla.components.support.test.ext.joinBlocking +import mozilla.components.support.test.libstate.ext.waitUntilIdle +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.MockitoAnnotations +import org.mozilla.focus.TestFocusApplication +import org.mozilla.focus.ext.components +import org.mozilla.focus.nimbus.FocusNimbus +import org.mozilla.focus.nimbus.Onboarding +import org.mozilla.focus.state.AppStore +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(application = TestFocusApplication::class) +class CfrMiddlewareTest { + private lateinit var onboardingExperiment: Onboarding + private val browserStore: BrowserStore = testContext.components.store + private val appStore: AppStore = testContext.components.appStore + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + onboardingExperiment = FocusNimbus.features.onboarding.value() + } + + @Test + fun `GIVEN shouldShowCfrForTrackingProtection is true WHEN UpdateSecurityInfoAction is intercepted THEN showTrackingProtectionCfr is changed to true`() { + if (onboardingExperiment.isCfrEnabled) { + val updateSecurityInfoAction = ContentAction.UpdateSecurityInfoAction( + "1", + SecurityInfoState( + secure = true, + host = "test.org", + issuer = "Test", + ), + ) + val trackerBlockedAction = TrackingProtectionAction.TrackerBlockedAction( + tabId = "1", + tracker = Tracker( + url = "test.org", + trackingCategories = listOf(EngineSession.TrackingProtectionPolicy.TrackingCategory.CRYPTOMINING), + cookiePolicies = listOf(EngineSession.TrackingProtectionPolicy.CookiePolicy.ACCEPT_NONE), + ), + ) + + browserStore.dispatch(updateSecurityInfoAction).joinBlocking() + browserStore.dispatch(trackerBlockedAction).joinBlocking() + appStore.waitUntilIdle() + + assertTrue(appStore.state.showTrackingProtectionCfrForTab.getOrDefault("1", false)) + } + } + + @Test + fun `GIVEN insecure tab WHEN UpdateSecurityInfoAction is intercepted THEN showTrackingProtectionCfr is not changed to true`() { + if (onboardingExperiment.isCfrEnabled) { + val insecureTab = createTab(isSecure = false) + val updateSecurityInfoAction = ContentAction.UpdateSecurityInfoAction( + "1", + SecurityInfoState( + secure = false, + host = "test.org", + issuer = "Test", + ), + ) + + browserStore.dispatch(TabListAction.AddTabAction(insecureTab)).joinBlocking() + browserStore.dispatch(updateSecurityInfoAction).joinBlocking() + appStore.waitUntilIdle() + + assertFalse(appStore.state.showTrackingProtectionCfrForTab.getOrDefault("1", false)) + } + } + + @Test + fun `GIVEN mozilla tab WHEN UpdateSecurityInfoAction is intercepted THEN showTrackingProtectionCfr is not changed to true`() { + if (onboardingExperiment.isCfrEnabled) { + val mozillaTab = createTab(id = "1", url = "https://www.mozilla.org") + val updateSecurityInfoAction = ContentAction.UpdateSecurityInfoAction( + "1", + SecurityInfoState( + secure = true, + host = "test.org", + issuer = "Test", + ), + ) + browserStore.dispatch(TabListAction.AddTabAction(mozillaTab)).joinBlocking() + browserStore.dispatch(updateSecurityInfoAction).joinBlocking() + appStore.waitUntilIdle() + + assertFalse(appStore.state.showTrackingProtectionCfrForTab.getOrDefault("1", false)) + } + } + + private fun createTab( + tabUrl: String = "https://www.test.org", + tabId: Int = 1, + isSecure: Boolean = true, + ): TabSessionState { + val tab = createTab(tabUrl, id = tabId.toString()) + return tab.copy( + content = tab.content.copy( + private = true, + securityInfo = SecurityInfoState(secure = isSecure), + ), + ) + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/contextmenu/ContextMenuCandidatesTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/contextmenu/ContextMenuCandidatesTest.kt new file mode 100644 index 0000000000..71ebb4ead3 --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/contextmenu/ContextMenuCandidatesTest.kt @@ -0,0 +1,81 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.focus.contextmenu + +import android.content.Context +import mozilla.components.support.test.mock +import mozilla.components.support.test.whenever +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentMatchers.anyInt + +class ContextMenuCandidatesTest { + private val testContext: Context = mock() + + @Before + fun setUp() { + whenever(testContext.getString(anyInt())).thenReturn("dummy label") + } + + @Test + fun `WHEN the tab is a custom tab THEN the proper context candidates are created `() { + // the expected list is the same as in a Fenix custom tab. + val expectedCandidatesIDs = listOf( + "mozac.feature.contextmenu.copy_link", + "mozac.feature.contextmenu.share_link", + "mozac.feature.contextmenu.save_image", + "mozac.feature.contextmenu.save_video", + "mozac.feature.contextmenu.copy_image_location", + ) + + val actualListIDs = ContextMenuCandidates.get( + testContext, + mock(), + mock(), + mock(), + mock(), + mock(), + true, + ).map { + it.id + } + + assertEquals(expectedCandidatesIDs, actualListIDs) + } + + @Test + fun `WHEN the tab is NOT a custom tab THEN the proper context candidates are created `() { + val expectedCandidatesIDs = listOf( + "mozac.feature.contextmenu.open_in_private_tab", + "mozac.feature.contextmenu.copy_link", + "mozac.feature.contextmenu.download_link", + "mozac.feature.contextmenu.share_link", + "mozac.feature.contextmenu.share_image", + "mozac.feature.contextmenu.open_image_in_new_tab", + "mozac.feature.contextmenu.save_image", + "mozac.feature.contextmenu.save_video", + "mozac.feature.contextmenu.copy_image_location", + "mozac.feature.contextmenu.add_to_contact", + "mozac.feature.contextmenu.share_email", + "mozac.feature.contextmenu.copy_email_address", + "mozac.feature.contextmenu.open_in_external_app", + ) + + val actualListIDs = ContextMenuCandidates.get( + testContext, + mock(), + mock(), + mock(), + mock(), + mock(), + false, + ).map { + it.id + } + + assertEquals(expectedCandidatesIDs, actualListIDs) + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/experiments/NimbusSetupTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/experiments/NimbusSetupTest.kt new file mode 100644 index 0000000000..b63db61fa9 --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/experiments/NimbusSetupTest.kt @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +package org.mozilla.focus.experiments + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mozilla.experiments.nimbus.internal.NimbusException + +class NimbusSetupTest { + @Test + fun `WHEN error is reportable THEN return true`() { + val error = NimbusException.IoException("IOException") + + assertTrue(error.isReportableError()) + } + + @Test + fun `WHEN error is non-reportable THEN return false`() { + val error = NimbusException.ClientException("ResponseException") + + assertFalse(error.isReportableError()) + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/ext/BrowserToolbarTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/ext/BrowserToolbarTest.kt new file mode 100644 index 0000000000..4407e03a76 --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/ext/BrowserToolbarTest.kt @@ -0,0 +1,80 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.focus.ext + +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.isVisible +import mozilla.components.browser.engine.gecko.GeckoEngineView +import mozilla.components.browser.toolbar.BrowserToolbar +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.ui.widgets.behavior.EngineViewClippingBehavior +import mozilla.components.ui.widgets.behavior.EngineViewScrollingBehavior +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify +import org.mozilla.focus.R +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class BrowserToolbarTest { + private val parent = CoordinatorLayout(testContext) + private val toolbar = spy(BrowserToolbar(testContext)) + private val engineView = spy(GeckoEngineView(testContext)) + private val toolbarHeight = testContext.resources.getDimensionPixelSize(R.dimen.browser_toolbar_height) + + init { + doReturn(toolbarHeight).`when`(toolbar).height + parent.addView(toolbar) + parent.addView(engineView) + } + + @Test + fun `GIVEN a BrowserToolbar WHEN enableDynamicBehavior THEN set custom behaviors for the toolbar and engineView`() { + // Simulate previously having a fixed toolbar + (engineView.layoutParams as? CoordinatorLayout.LayoutParams)?.topMargin = 222 + + toolbar.enableDynamicBehavior(testContext, engineView) + + assertTrue((toolbar.layoutParams as? CoordinatorLayout.LayoutParams)?.behavior is EngineViewScrollingBehavior) + assertTrue((engineView.layoutParams as? CoordinatorLayout.LayoutParams)?.behavior is EngineViewClippingBehavior) + assertEquals(0, (engineView.layoutParams as? CoordinatorLayout.LayoutParams)?.topMargin) + verify(engineView).setDynamicToolbarMaxHeight(toolbarHeight) + } + + @Test + fun `GIVEN a BrowserToolbar WHEN disableDynamicBehavior THEN set null behaviors for the toolbar and engineView`() { + engineView.asView().translationY = 123f + + toolbar.disableDynamicBehavior(engineView) + + assertNull((toolbar.layoutParams as? CoordinatorLayout.LayoutParams)?.behavior) + assertNull((engineView.layoutParams as? CoordinatorLayout.LayoutParams)?.behavior) + assertEquals(0f, engineView.asView().translationY) + verify(engineView).setDynamicToolbarMaxHeight(0) + } + + @Test + fun `GIVEN a BrowserToolbar WHEN showAsFixed is called THEN show the toolbar with the engineView below it`() { + toolbar.showAsFixed(testContext, engineView) + + verify(toolbar).isVisible = true + verify(engineView).setDynamicToolbarMaxHeight(0) + assertEquals(toolbarHeight, (engineView.layoutParams as? CoordinatorLayout.LayoutParams)?.topMargin) + } + + @Test + fun `GIVEN a BrowserToolbar WHEN hide is called THEN show the toolbar with the engineView below it`() { + toolbar.hide(engineView) + + verify(toolbar).isVisible = false + verify(engineView).setDynamicToolbarMaxHeight(0) + assertEquals(0, (engineView.layoutParams as? CoordinatorLayout.LayoutParams)?.topMargin) + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/ext/StringTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/ext/StringTest.kt new file mode 100644 index 0000000000..33090def9e --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/ext/StringTest.kt @@ -0,0 +1,80 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.focus.ext + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class StringTest { + @Test + fun testBeautifyUrl() { + assertEqualsBeautified( + "wikipedia.org/wiki/Adler_Planetarium", + "https://en.m.wikipedia.org/wiki/Adler_Planetarium", + ) + + assertEqualsBeautified( + "youtube.com/watch?v=WXqGDW7kuAk", + "https://youtube.com/watch?v=WXqGDW7kuAk", + ) + + assertEqualsBeautified( + "spotify.com/album/6JVdzwuTEaLj7Tga8DpFpz", + "http://open.spotify.com/album/6JVdzwuTEaLj7Tga8DpFpz", + ) + + assertEqualsBeautified( + "google.com/mail/…/0#inbox/15e34774924ddfb5", + "https://mail.google.com/mail/u/0/#inbox/15e34774924ddfb5", + ) + + assertEqualsBeautified( + "google.com/store/…/details?id=com.facebook.katana", + "https://play.google.com/store/apps/details?id=com.facebook.katana&hl=en", + ) + + assertEqualsBeautified( + "amazon.com/Mockingjay-Hunger-Games-Suzanne-Collins/…/ref=pd_sim_14_2?_encoding=UTF8", + "http://amazon.com/Mockingjay-Hunger-Games-Suzanne-Collins/dp/0545663261/ref=pd_sim_14_2?_encoding=UTF8&psc=1&refRID=90ZHE3V976TKBGDR9VAM", + ) + + assertEqualsBeautified( + "usbank.com/Auth/Login", + "https://onlinebanking.usbank.com/Auth/Login", + ) + + assertEqualsBeautified( + "wsj.com/articles/mexican-presidential-candidate-calls-for-nafta-talks-to-be-suspended-1504137175", + "https://www.wsj.com/articles/mexican-presidential-candidate-calls-for-nafta-talks-to-be-suspended-1504137175", + ) + + assertEqualsBeautified( + "nytimes.com/2017/…/princess-diana-death-anniversary.html?hp", + "https://www.nytimes.com/2017/08/30/world/europe/princess-diana-death-anniversary.html?hp&action=click&pgtype=Homepage&clickSource=story-heading&module=second-column-region®ion=top-news&WT.nav=top-news", + ) + + assertEqualsBeautified( + "yahoo.co.jp/hl?a=20170830-00000008-jct-soci", + "https://headlines.yahoo.co.jp/hl?a=20170830-00000008-jct-soci", + ) + + assertEqualsBeautified( + "tomshardware.co.uk/answers/…/running-guest-network-channel-interference.html", + "http://www.tomshardware.co.uk/answers/id-2025922/running-guest-network-channel-interference.html", + ) + + assertEqualsBeautified( + "github.com/mozilla-mobile/…/1231", + "https://github.com/mozilla-mobile/focus-android/issues/1231", + ) + } + + private fun assertEqualsBeautified(expected: String, url: String) { + assertEquals("beautify($url)", expected, url.beautifyUrl()) + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/ext/UriTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/ext/UriTest.kt new file mode 100644 index 0000000000..8559f251c8 --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/ext/UriTest.kt @@ -0,0 +1,82 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.focus.ext + +import androidx.core.net.toUri +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class UriTest { + @Test + fun testTruncatedHostWithCommonUrls() { + assertTruncatedHost("mozilla.org", "https://www.mozilla.org") + assertTruncatedHost("wikipedia.org", "https://en.m.wikipedia.org/wiki/") + assertTruncatedHost("example.org", "http://example.org") + assertTruncatedHost("youtube.com", "https://www.youtube.com/watch?v=oHg5SJYRHA0") + assertTruncatedHost("facebook.com", "https://www.facebook.com/Firefox/") + assertTruncatedHost("yahoo.com", "https://de.search.yahoo.com/search?p=mozilla&fr=yfp-t&fp=1&toggle=1&cop=mss&ei=UTF-8") + assertTruncatedHost("amazon.co.uk", "https://www.amazon.co.uk/Doctor-Who-10-Part-DVD/dp/B06XCMVY1H") + } + + @Test + fun testTruncatedHostWithEmptyHost() { + assertTruncatedHost("", "tel://") + } + + @Test + fun testTruncatedPathWithEmptySegments() { + assertTruncatedPath("", "https://www.mozilla.org") + assertTruncatedPath("", "https://www.mozilla.org/") + assertTruncatedPath("", "https://www.mozilla.org///") + } + + @Test + fun testTrunactedPathWithOneSegment() { + assertTruncatedPath("/space", "https://www.theverge.com/space") + } + + @Test + fun testTruncatedPathWithTwoSegments() { + assertTruncatedPath("/en-US/firefox", "https://www.mozilla.org/en-US/firefox/") + assertTruncatedPath("/mozilla-mobile/focus-android", "https://github.com/mozilla-mobile/focus-android") + } + + @Test + fun testTruncatedPathWithMultipleSegments() { + assertTruncatedPath("/en-US/…/fast", "https://www.mozilla.org/en-US/firefox/features/fast/") + + assertTruncatedPath( + "/2017/…/nasa-hi-seas-mars-analogue-mission-hawaii-mauna-loa", + "https://www.theverge.com/2017/9/24/16356876/nasa-hi-seas-mars-analogue-mission-hawaii-mauna-loa", + ) + } + + @Test + fun testTruncatedPathWithMultipleSegmentsAndFragment() { + assertTruncatedPath( + "/@bfrancis/the-story-of-firefox-os-cb5bf796e8fb", + "https://medium.com/@bfrancis/the-story-of-firefox-os-cb5bf796e8fb#931a", + ) + } + + private fun assertTruncatedHost(expectedTruncatedPath: String, url: String) { + assertEquals( + "truncatedHost($url)", + expectedTruncatedPath, + url.toUri().truncatedHost(), + ) + } + + private fun assertTruncatedPath(expectedTruncatedPath: String, url: String) { + assertEquals( + "truncatedPath($url)", + expectedTruncatedPath, + url.toUri().truncatedPath(), + ) + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/locale/LocalesTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/locale/LocalesTest.kt new file mode 100644 index 0000000000..7a7f37d14a --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/locale/LocalesTest.kt @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.focus.locale + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mozilla.focus.locale.Locales.getLanguage +import org.mozilla.focus.locale.Locales.getLanguageTag +import java.util.Locale + +class LocalesTest { + @Test + fun testLanguage() { + assertEquals("en", getLanguage(Locale.getDefault())) + } + + @Test + fun testHebrewIsrael() { + val locale = Locale("iw", "IL") + assertEquals("he", getLanguage(locale)) + assertEquals("he-IL", getLanguageTag(locale)) + } + + @Test + fun testIndonesianIndonesia() { + val locale = Locale("in", "ID") + assertEquals("id", getLanguage(locale)) + assertEquals("id-ID", getLanguageTag(locale)) + } + + @Test + fun testYiddishUnitedStates() { + val locale = Locale("ji", "US") + assertEquals("yi", getLanguage(locale)) + assertEquals("yi-US", getLanguageTag(locale)) + } + + @Test + fun testEmptyCountry() { + val locale = Locale("en") + assertEquals("en", getLanguage(locale)) + assertEquals("en", getLanguageTag(locale)) + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/menu/BrowserMenuControllerTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/menu/BrowserMenuControllerTest.kt new file mode 100644 index 0000000000..a5547b617d --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/menu/BrowserMenuControllerTest.kt @@ -0,0 +1,162 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.focus.menu + +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.state.createTab +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.feature.session.SessionUseCases +import mozilla.components.feature.top.sites.TopSitesUseCases +import mozilla.components.support.test.any +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.Mockito.doNothing +import org.mockito.Mockito.spy +import org.mockito.Mockito.times +import org.mockito.MockitoAnnotations +import org.mockito.Spy +import org.mozilla.focus.browser.integration.BrowserMenuController +import org.mozilla.focus.state.AppStore + +class BrowserMenuControllerTest { + private lateinit var browserMenuController: BrowserMenuController + + @Spy + private lateinit var sessionUseCases: SessionUseCases + + @Mock + private lateinit var appStore: AppStore + + @Mock + private lateinit var topSitesUseCases: TopSitesUseCases + + private val currentTabId: String = "1" + private val selectedTab = createTab("https://www.mozilla.org", id = "1") + private val shareCallback: () -> Unit = {} + + @Mock + private lateinit var requestDesktopCallback: (isChecked: Boolean) -> Unit + + @Mock + private lateinit var addToHomeScreenCallback: () -> Unit + + @Mock + private lateinit var showFindInPageCallback: () -> Unit + + @Mock + private lateinit var openInCallback: () -> Unit + + // NB: we should avoid mocking lambdas.. + @Mock + private lateinit var openInBrowser: () -> Unit + + @Mock + private lateinit var showShortcutAddedSnackBar: () -> Unit + + @Before + fun setup() { + val store = BrowserStore( + initialState = BrowserState( + tabs = listOf(selectedTab), + selectedTabId = selectedTab.id, + ), + ) + sessionUseCases = SessionUseCases(store) + MockitoAnnotations.openMocks(this) + + browserMenuController = spy( + BrowserMenuController( + sessionUseCases, + appStore, + store, + topSitesUseCases, + currentTabId, + shareCallback, + requestDesktopCallback, + addToHomeScreenCallback, + showFindInPageCallback, + openInCallback, + openInBrowser, + showShortcutAddedSnackBar, + ), + ) + + doNothing().`when`(browserMenuController).recordBrowserMenuTelemetry(any()) + } + + @Test + fun `GIVEN Back menu item WHEN the item is pressed THEN goBack use case is called`() { + val menuItem = ToolbarMenu.Item.Back + browserMenuController.handleMenuInteraction(menuItem) + Mockito.verify(sessionUseCases, times(1)).goBack + } + + @Test + fun `GIVEN Forward menu item WHEN the item is pressed THEN goForward use case is called`() { + val menuItem = ToolbarMenu.Item.Forward + browserMenuController.handleMenuInteraction(menuItem) + Mockito.verify(sessionUseCases, times(1)).goForward + } + + @Test + fun `GIVEN Reload menu item WHEN the item is pressed THEN reload use case is called`() { + val menuItem = ToolbarMenu.Item.Reload + browserMenuController.handleMenuInteraction(menuItem) + Mockito.verify(sessionUseCases, times(1)).reload + } + + @Test + fun `GIVEN Stop menu item WHEN the item is pressed THEN stopLoading use case is called`() { + val menuItem = ToolbarMenu.Item.Stop + browserMenuController.handleMenuInteraction(menuItem) + Mockito.verify(sessionUseCases, times(1)).stopLoading + } + + @Test + @Suppress("MaxLineLength") + fun `GIVEN RequestDesktop menu item WHEN the item is switched to false THEN requestDesktopCallback with false is called`() { + val menuItem = ToolbarMenu.Item.RequestDesktop(isChecked = false) + browserMenuController.handleMenuInteraction(menuItem) + Mockito.verify(requestDesktopCallback, times(1)).invoke(false) + } + + @Test + @Suppress("MaxLineLength") + fun `GIVEN RequestDesktop menu item WHEN the item is switched to true THEN requestDesktopCallback with true is called`() { + val menuItem = ToolbarMenu.Item.RequestDesktop(isChecked = true) + browserMenuController.handleMenuInteraction(menuItem) + Mockito.verify(requestDesktopCallback, times(1)).invoke(true) + } + + @Test + fun `GIVEN OpenInBrowser menu item WHEN the item is pressed THEN openInBrowser is called`() { + val menuItem = ToolbarMenu.CustomTabItem.OpenInBrowser + browserMenuController.handleMenuInteraction(menuItem) + Mockito.verify(openInBrowser, times(1)).invoke() + } + + @Test + fun `GIVEN OpenIn menu item WHEN the item is pressed THEN openInCallback is called`() { + val menuItem = ToolbarMenu.Item.OpenInApp + browserMenuController.handleMenuInteraction(menuItem) + Mockito.verify(openInCallback, times(1)).invoke() + } + + @Test + fun `GIVEN FindInPage menu item WHEN the item is pressed THEN findInPageMenuEvent method is called`() { + val menuItem = ToolbarMenu.Item.FindInPage + browserMenuController.handleMenuInteraction(menuItem) + Mockito.verify(showFindInPageCallback, times(1)).invoke() + } + + @Test + fun `Given AddToShortCut menu item WHEN the item is pressed THEN showShortcutAddedSnackBar is called`() { + val menuItem = ToolbarMenu.Item.AddToShortcuts + browserMenuController.handleMenuInteraction(menuItem) + Mockito.verify(showShortcutAddedSnackBar, times(1)).invoke() + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/onboarding/OnboardingControllerTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/onboarding/OnboardingControllerTest.kt new file mode 100644 index 0000000000..5d02674913 --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/onboarding/OnboardingControllerTest.kt @@ -0,0 +1,72 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +package org.mozilla.focus.onboarding + +import android.os.Build +import androidx.preference.PreferenceManager +import androidx.test.core.app.ApplicationProvider +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.spy +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations +import org.mozilla.focus.R +import org.mozilla.focus.fragment.onboarding.DefaultOnboardingController +import org.mozilla.focus.fragment.onboarding.OnboardingController +import org.mozilla.focus.fragment.onboarding.OnboardingStorage +import org.mozilla.focus.state.AppStore +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +class OnboardingControllerTest { + + @Mock + private lateinit var appStore: AppStore + + @Mock + private lateinit var onboardingStorage: OnboardingStorage + private lateinit var onboardingController: OnboardingController + + @Before + fun init() { + MockitoAnnotations.openMocks(this) + onboardingController = spy( + DefaultOnboardingController( + onboardingStorage, + appStore, + ApplicationProvider.getApplicationContext(), + "1", + ), + ) + } + + @Test + fun `GIVEN onBoarding, WHEN start browsing is pressed, THEN onBoarding flag is true`() { + DefaultOnboardingController( + onboardingStorage, + appStore, + ApplicationProvider.getApplicationContext(), + "1", + ).handleFinishOnBoarding() + + val prefManager = + PreferenceManager.getDefaultSharedPreferences(ApplicationProvider.getApplicationContext()) + + assertEquals(false, prefManager.getBoolean(testContext.getString(R.string.firstrun_shown), false)) + } + + @Config(sdk = [Build.VERSION_CODES.M]) + @Test + fun `GIVEN onBoarding and build version is M, WHEN get started button is pressed, THEN onBoarding flow must end`() { + onboardingController.handleGetStartedButtonClicked() + + verify(onboardingController, times(1)).handleFinishOnBoarding() + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/onboarding/OnboardingStorageTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/onboarding/OnboardingStorageTest.kt new file mode 100644 index 0000000000..39d9f8c603 --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/onboarding/OnboardingStorageTest.kt @@ -0,0 +1,79 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.focus.onboarding + +import androidx.preference.PreferenceManager +import androidx.test.core.app.ApplicationProvider +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.spy +import org.mozilla.focus.R +import org.mozilla.focus.fragment.onboarding.OnboardingStep +import org.mozilla.focus.fragment.onboarding.OnboardingStorage +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class OnboardingStorageTest { + private lateinit var onBoardingStorage: OnboardingStorage + + @Before + fun setup() { + onBoardingStorage = spy(OnboardingStorage(testContext)) + } + + @Test + fun `GIVEN at the first onboarding step WHEN querying the current onboarding step from storage THEN get the first step`() { + doReturn(testContext.getString(R.string.pref_key_first_screen)) + .`when`(onBoardingStorage).getCurrentOnboardingStepFromSharedPref() + + assertEquals( + OnboardingStep.ON_BOARDING_FIRST_SCREEN, + onBoardingStorage.getCurrentOnboardingStep(), + ) + } + + @Test + fun `GIVEN at the second onboarding step WHEN querying the current onboarding step from storage THEN get the second step`() { + doReturn(testContext.getString(R.string.pref_key_second_screen)) + .`when`(onBoardingStorage).getCurrentOnboardingStepFromSharedPref() + + assertEquals( + OnboardingStep.ON_BOARDING_SECOND_SCREEN, + onBoardingStorage.getCurrentOnboardingStep(), + ) + } + + @Test + fun `GIVEN onboarding not started WHEN querying the current onboarding step from storage THEN get the first step`() { + assertEquals( + OnboardingStep.ON_BOARDING_FIRST_SCREEN, + onBoardingStorage.getCurrentOnboardingStep(), + ) + } + + @Test + fun `GIVEN saveCurrentOnBoardingStepInSharePref is called WHEN ONBOARDING_FIRST_SCREEN is saved, THEN value for pref_key_onboarding_step is pref_key_first_screen`() { + onBoardingStorage.saveCurrentOnboardingStepInSharePref(OnboardingStep.ON_BOARDING_FIRST_SCREEN) + + val prefManager = + PreferenceManager.getDefaultSharedPreferences(ApplicationProvider.getApplicationContext()) + + assertEquals(testContext.getString(OnboardingStep.ON_BOARDING_FIRST_SCREEN.prefId), prefManager.getString(testContext.getString(R.string.pref_key_onboarding_step), "")) + } + + @Test + fun `GIVEN saveCurrentOnBoardingStepInSharePref is called WHEN ONBOARDING_SECOND_SCREEN is saved, THEN value for pref_key_onboarding_step is pref_key_second_screen`() { + onBoardingStorage.saveCurrentOnboardingStepInSharePref(OnboardingStep.ON_BOARDING_SECOND_SCREEN) + + val prefManager = + PreferenceManager.getDefaultSharedPreferences(ApplicationProvider.getApplicationContext()) + + assertEquals(testContext.getString(OnboardingStep.ON_BOARDING_SECOND_SCREEN.prefId), prefManager.getString(testContext.getString(R.string.pref_key_onboarding_step), "")) + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/searchsuggestions/SearchSuggestionsViewModelTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/searchsuggestions/SearchSuggestionsViewModelTest.kt new file mode 100644 index 0000000000..c2c686c8e0 --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/searchsuggestions/SearchSuggestionsViewModelTest.kt @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.focus.searchsuggestions + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.Observer +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SearchSuggestionsViewModelTest { + @get:Rule + var rule: TestRule = InstantTaskExecutorRule() + + @Mock + private lateinit var observer: Observer<String?> + + private lateinit var lifecycle: LifecycleRegistry + private lateinit var viewModel: SearchSuggestionsViewModel + + @Before + fun setup() { + lifecycle = LifecycleRegistry(mock(LifecycleOwner::class.java)) + MockitoAnnotations.openMocks(this) + + viewModel = SearchSuggestionsViewModel(ApplicationProvider.getApplicationContext()) + } + + @Test + fun setSearchQuery() { + viewModel.searchQuery.observeForever(observer) + + viewModel.setSearchQuery("Mozilla") + verify(observer).onChanged("Mozilla") + } + + @Test + fun alwaysSearchSelected() { + viewModel.selectedSearchSuggestion.observeForever(observer) + + viewModel.selectSearchSuggestion("mozilla.com", "google", true) + verify(observer).onChanged("mozilla.com") + assertEquals(true, viewModel.alwaysSearch) + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/searchwidget/ExternalIntentNavigationTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/searchwidget/ExternalIntentNavigationTest.kt new file mode 100644 index 0000000000..c1156022bd --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/searchwidget/ExternalIntentNavigationTest.kt @@ -0,0 +1,203 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.focus.searchwidget + +import android.app.Activity +import android.content.Context +import android.os.Bundle +import mozilla.components.browser.state.selector.allTabs +import mozilla.components.browser.state.selector.findCustomTabOrSelectedTab +import mozilla.components.browser.state.selector.privateTabs +import mozilla.components.browser.state.state.SessionState +import mozilla.components.feature.search.widget.BaseVoiceSearchActivity +import mozilla.components.support.test.libstate.ext.waitUntilIdle +import mozilla.components.support.test.robolectric.testContext +import mozilla.telemetry.glean.testing.GleanTestRule +import org.junit.Assert.assertNotNull +import org.junit.Rule +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.runner.RunWith +import org.mozilla.focus.GleanMetrics.SearchWidget +import org.mozilla.focus.TestFocusApplication +import org.mozilla.focus.activity.IntentReceiverActivity +import org.mozilla.focus.ext.components +import org.mozilla.focus.ext.settings +import org.mozilla.focus.perf.Performance +import org.mozilla.focus.state.AppAction +import org.mozilla.focus.state.Screen +import org.mozilla.focus.utils.SearchUtils +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements + +@RunWith(RobolectricTestRunner::class) +@Config(application = TestFocusApplication::class) +internal class ExternalIntentNavigationTest { + @get:Rule + val gleanTestRule = GleanTestRule(testContext) + + private val activity: Activity = Robolectric.buildActivity(Activity::class.java).setup().get() + private val appStore = activity.components.appStore + + @Test + fun `GIVEN the onboarding should be shown and the app is not used in performance tests WHEN the app is opened THEN show the onboarding`() { + activity.settings.isFirstRun = true + + ExternalIntentNavigation.handleAppOpened(null, activity) + appStore.waitUntilIdle() + + assertEquals(Screen.FirstRun, appStore.state.screen) + } + + @Test + @Config(shadows = [ShadowPerformance::class]) + fun `GIVEN the onboarding should be shown and the app is used in a performance test WHEN the app is opened THEN show the homescreen`() { + // The AppStore is initialized before the test runs. By default isFirstRun is true. Simulate it being false. + appStore.dispatch(AppAction.ShowHomeScreen) + appStore.waitUntilIdle() + activity.settings.isFirstRun = true + + ExternalIntentNavigation.handleAppOpened(null, activity) + appStore.waitUntilIdle() + + assertEquals(Screen.Home, appStore.state.screen) + } + + @Test + @Config(shadows = [ShadowPerformance::class]) + fun `GIVEN the onboarding should not be shown and in a performance test WHEN the app is opened THEN show the home screen`() { + // The AppStore is initialized before the test runs. By default isFirstRun is true. Simulate it being false. + appStore.dispatch(AppAction.ShowHomeScreen) + appStore.waitUntilIdle() + activity.settings.isFirstRun = false + + ExternalIntentNavigation.handleAppOpened(null, activity) + appStore.waitUntilIdle() + + assertEquals(Screen.Home, appStore.state.screen) + } + + @Test + fun `GIVEN the onboarding should not be shown and not in a performance test WHEN the app is opened THEN show the home screen`() { + // The AppStore is initialized before the test runs. By default isFirstRun is true. Simulate it being false. + appStore.dispatch(AppAction.ShowHomeScreen) + appStore.waitUntilIdle() + activity.settings.isFirstRun = false + + ExternalIntentNavigation.handleAppOpened(null, activity) + appStore.waitUntilIdle() + + assertEquals(Screen.Home, appStore.state.screen) + } + + @Test + fun `GIVEN a tab is already open WHEN trying to navigate to the current tab THEN navigate to it and return true`() { + activity.components.tabsUseCases.addTab(url = "https://mozilla.com") + activity.components.store.waitUntilIdle() + val result = ExternalIntentNavigation.handleBrowserTabAlreadyOpen(activity) + activity.components.appStore.waitUntilIdle() + + assertTrue(result) + val selectedTabId = activity.components.store.state.selectedTabId!! + assertEquals(Screen.Browser(selectedTabId, false), activity.components.appStore.state.screen) + } + + @Test + fun `GIVEN no tabs are currently open WHEN trying to navigate to the current tab THEN navigate home and return false`() { + // The AppStore is initialized before the test runs. By default isFirstRun is true. Simulate it being false. + appStore.dispatch(AppAction.ShowHomeScreen) + appStore.waitUntilIdle() + val result = ExternalIntentNavigation.handleBrowserTabAlreadyOpen(activity) + activity.components.appStore.waitUntilIdle() + + assertFalse(result) + assertEquals(Screen.Home, activity.components.appStore.state.screen) + } + + @Test + fun `GIVEN a text search from the search widget WHEN handling widget interactions THEN record telemetry, show the home screen and return true`() { + val bundle = Bundle().apply { + putBoolean(IntentReceiverActivity.SEARCH_WIDGET_EXTRA, true) + } + + val result = ExternalIntentNavigation.handleWidgetTextSearch(bundle, activity) + appStore.waitUntilIdle() + + assertTrue(result) + assertNotNull(SearchWidget.newTabButton.testGetValue()) + assertEquals(Screen.Home, appStore.state.screen) + } + + @Test + fun `GIVEN no text search from the search widget WHEN handling widget interactions THEN don't record telemetry, show the home screen and false true`() { + // The AppStore is initialized before the test runs. By default isFirstRun is true. Simulate it being false. + appStore.dispatch(AppAction.ShowHomeScreen) + appStore.waitUntilIdle() + val bundle = Bundle() + + val result = ExternalIntentNavigation.handleWidgetTextSearch(bundle, activity) + appStore.waitUntilIdle() + + assertFalse(result) + assertNull(SearchWidget.newTabButton.testGetValue()) + assertEquals(Screen.Home, appStore.state.screen) + } + + @Test + fun `GIVEN a voice search WHEN handling widget interactions THEN create and open a new tab and return true`() { + val browserStore = activity.components.store + val searchArgument = "test" + val bundle = Bundle().apply { + putString(BaseVoiceSearchActivity.SPEECH_PROCESSING, searchArgument) + } + + val result = ExternalIntentNavigation.handleWidgetVoiceSearch(bundle, activity) + + assertTrue(result) + browserStore.waitUntilIdle() + assertEquals(1, browserStore.state.allTabs.size) + assertEquals(1, browserStore.state.privateTabs.size) + val voiceSearchTab = browserStore.state.privateTabs[0] + assertEquals(voiceSearchTab, browserStore.state.findCustomTabOrSelectedTab()) + assertEquals(SearchUtils.createSearchUrl(activity, searchArgument), voiceSearchTab.content.url) + assertEquals(SessionState.Source.External.ActionSend(null), voiceSearchTab.source) + assertEquals(searchArgument, voiceSearchTab.content.searchTerms) + appStore.waitUntilIdle() + assertEquals(Screen.Browser(voiceSearchTab.id, false), appStore.state.screen) + } + + @Test + fun `GIVEN no voice search WHEN handling widget interactions THEN don't open a new tab and return false`() { + // The AppStore is initialized before the test runs. By default isFirstRun is true. Simulate it being false. + appStore.dispatch(AppAction.ShowHomeScreen) + appStore.waitUntilIdle() + val browserStore = activity.components.store + val bundle = Bundle() + + val result = ExternalIntentNavigation.handleWidgetVoiceSearch(bundle, activity) + + assertFalse(result) + browserStore.waitUntilIdle() + assertEquals(0, browserStore.state.allTabs.size) + appStore.waitUntilIdle() + assertEquals(Screen.Home, appStore.state.screen) + } +} + +/** + * Shadow of [Performance] that will have [processIntentIfPerformanceTest] always return `true`. + */ +@Implements(Performance::class) +class ShadowPerformance { + @Implementation + @Suppress("Unused_Parameter") + fun processIntentIfPerformanceTest(bundle: Bundle?, context: Context) = true +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/searchwidget/SearchWidgetProviderTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/searchwidget/SearchWidgetProviderTest.kt new file mode 100644 index 0000000000..6ad9a7b3b5 --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/searchwidget/SearchWidgetProviderTest.kt @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +package org.mozilla.focus.searchwidget + +import android.app.PendingIntent +import android.content.Intent +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.utils.PendingIntentUtils +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.spy +import org.mozilla.focus.activity.IntentReceiverActivity +import org.mozilla.focus.ext.components +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SearchWidgetProviderTest { + + private lateinit var searchWidgetProvider: SearchWidgetProvider + + @Before + fun setup() { + searchWidgetProvider = spy(SearchWidgetProvider()) + } + + @Test + fun `GIVEN search widget provider WHEN onEnabled is called THEN searchWidgetInstalled from Settings should return true`() { + searchWidgetProvider.onEnabled(testContext) + + assertEquals(testContext.components.settings.searchWidgetInstalled, true) + } + + @Test + fun `GIVEN search widget provider WHEN onDeleted is called THEN searchWidgetInstalled from Settings should return false`() { + searchWidgetProvider.onDeleted(testContext, intArrayOf(1)) + + assertEquals(testContext.components.settings.searchWidgetInstalled, false) + } + + @Test + fun `GIVEN search widget provider WHEN createTextSearchIntent is called THEN an PendingIntent should be return`() { + val textSearchIntent = Intent(testContext, IntentReceiverActivity::class.java) + .apply { + this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + this.putExtra(IntentReceiverActivity.SEARCH_WIDGET_EXTRA, true) + } + val dummyPendingIntent = PendingIntent.getActivity( + testContext, + SearchWidgetProvider.REQUEST_CODE_NEW_TAB, + textSearchIntent, + PendingIntentUtils.defaultFlags or + PendingIntent.FLAG_UPDATE_CURRENT, + ) + + assertEquals(searchWidgetProvider.createTextSearchIntent(testContext), dummyPendingIntent) + } + + @Test + fun `GIVEN search widget provider WHEN voiceSearchActivity is called THEN VoiceSearchActivity should be return`() { + assertEquals(searchWidgetProvider.voiceSearchActivity(), VoiceSearchActivity::class.java) + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/settings/SearchEngineValidationTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/settings/SearchEngineValidationTest.kt new file mode 100644 index 0000000000..07c032b5bd --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/settings/SearchEngineValidationTest.kt @@ -0,0 +1,94 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.focus.settings + +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.Response +import mozilla.components.lib.fetch.okhttp.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.focus.settings.ManualAddSearchEngineSettingsFragment.Companion.isValidSearchQueryURL +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +// This unit test is not running on an Android device. Allow me to use spaces in function names. +@Suppress("IllegalIdentifier") +class SearchEngineValidationTest { + + lateinit var client: Client + + @Before + fun setup() { + client = OkHttpWrapper() + } + + @Test + fun `URL returning 200 OK is valid`() = withMockWebServer(responseWithStatus(200)) { + assertTrue(isValidSearchQueryURL(client, it.rootUrl())) + } + + @Test + fun `URL using HTTP redirect is invalid`() = withMockWebServer(responseWithStatus(301)) { + // We now follow redirects(Issue #1976). This test now asserts false. + assertFalse(isValidSearchQueryURL(client, it.rootUrl())) + } + + @Test + fun `URL returning 404 NOT FOUND is not valid`() = withMockWebServer(responseWithStatus(404)) { + assertFalse(isValidSearchQueryURL(client, it.rootUrl())) + } + + @Test + fun `URL returning server error is not valid`() = withMockWebServer(responseWithStatus(500)) { + assertFalse(isValidSearchQueryURL(client, it.rootUrl())) + } + + @Test + fun `URL timing out is not valid`() = withMockWebServer { + // Without queuing a response MockWebServer will not return anything and keep the connection open + assertFalse(isValidSearchQueryURL(client, it.rootUrl())) + } +} + +/** + * Helper for creating a test that uses a mock webserver instance. + */ +private fun withMockWebServer(vararg responses: MockResponse, block: (MockWebServer) -> Unit) { + val server = MockWebServer() + + responses.forEach { server.enqueue(it) } + + server.start() + + try { + block(server) + } finally { + server.shutdown() + } +} + +private fun MockWebServer.rootUrl(): String = url("/").toString() + +private fun responseWithStatus(status: Int) = + MockResponse() + .setResponseCode(status) + .setBody("") + +private class OkHttpWrapper : Client() { + private val actual = OkHttpClient() + + override fun fetch(request: Request): Response { + // OkHttpClient does not support private requests. Therefore we make them non-private for + // testing purposes + val nonPrivate = request.copy(private = false) + return actual.fetch(nonPrivate) + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/shortcut/HomeScreenTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/shortcut/HomeScreenTest.kt new file mode 100644 index 0000000000..7cb9e889f7 --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/shortcut/HomeScreenTest.kt @@ -0,0 +1,20 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +package org.mozilla.focus.shortcut + +import junit.framework.TestCase.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.focus.shortcut.HomeScreen.generateTitleFromUrl +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class HomeScreenTest { + @Test + fun testGenerateTitleFromUrl() { + assertEquals("mozilla.org", generateTitleFromUrl("https://www.mozilla.org")) + assertEquals("facebook.com", generateTitleFromUrl("http://m.facebook.com/home")) + assertEquals("", generateTitleFromUrl("mozilla")) + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/shortcut/IconGeneratorTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/shortcut/IconGeneratorTest.kt new file mode 100644 index 0000000000..f84c3d4d5d --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/shortcut/IconGeneratorTest.kt @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.focus.shortcut + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class IconGeneratorTest { + @Test + fun testRepresentativeCharacter() { + assertEquals('M', IconGenerator.getRepresentativeCharacter("https://mozilla.org")) + assertEquals('W', IconGenerator.getRepresentativeCharacter("http://wikipedia.org")) + assertEquals('P', IconGenerator.getRepresentativeCharacter("http://plus.google.com")) + assertEquals('E', IconGenerator.getRepresentativeCharacter("https://en.m.wikipedia.org/wiki/Main_Page")) + + // Stripping common prefixes + assertEquals('T', IconGenerator.getRepresentativeCharacter("http://www.theverge.com")) + assertEquals('F', IconGenerator.getRepresentativeCharacter("https://m.facebook.com")) + assertEquals('T', IconGenerator.getRepresentativeCharacter("https://mobile.twitter.com")) + + // Special urls + assertEquals('?', IconGenerator.getRepresentativeCharacter("file:///")) + assertEquals('S', IconGenerator.getRepresentativeCharacter("file:///system/")) + assertEquals('P', IconGenerator.getRepresentativeCharacter("ftp://people.mozilla.org/test")) + + // No values + assertEquals('?', IconGenerator.getRepresentativeCharacter("")) + assertEquals('?', IconGenerator.getRepresentativeCharacter(null)) + + // Rubbish + assertEquals('Z', IconGenerator.getRepresentativeCharacter("zZz")) + assertEquals('Ö', IconGenerator.getRepresentativeCharacter("ölkfdpou3rkjaslfdköasdfo8")) + assertEquals('?', IconGenerator.getRepresentativeCharacter("_*+*'##")) + assertEquals('ツ', IconGenerator.getRepresentativeCharacter("¯\\_(ツ)_/¯")) + assertEquals('ಠ', IconGenerator.getRepresentativeCharacter("ಠ_ಠ Look of Disapproval")) + + // Non-ASCII + assertEquals('Ä', IconGenerator.getRepresentativeCharacter("http://www.ätzend.de")) + assertEquals('名', IconGenerator.getRepresentativeCharacter("http://名がドメイン.com")) + assertEquals('C', IconGenerator.getRepresentativeCharacter("http://√.com")) + assertEquals('ß', IconGenerator.getRepresentativeCharacter("http://ß.de")) + assertEquals('Ԛ', IconGenerator.getRepresentativeCharacter("http://ԛәлп.com/")) // cyrillic + + // Punycode + assertEquals('X', IconGenerator.getRepresentativeCharacter("http://xn--tzend-fra.de")) // ätzend.de + assertEquals('X', IconGenerator.getRepresentativeCharacter("http://xn--V8jxj3d1dzdz08w.com")) // 名がドメイン.com + + // Numbers + assertEquals('1', IconGenerator.getRepresentativeCharacter("https://www.1and1.com/")) + + // IP + assertEquals('1', IconGenerator.getRepresentativeCharacter("https://192.168.0.1")) + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/sitepermissions/SitePermissionOptionsStorageTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/sitepermissions/SitePermissionOptionsStorageTest.kt new file mode 100644 index 0000000000..80168da22d --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/sitepermissions/SitePermissionOptionsStorageTest.kt @@ -0,0 +1,342 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +package org.mozilla.focus.sitepermissions + +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.spy +import org.mozilla.focus.R +import org.mozilla.focus.settings.permissions.AutoplayOption +import org.mozilla.focus.settings.permissions.SitePermissionOption +import org.mozilla.focus.settings.permissions.permissionoptions.SitePermission +import org.mozilla.focus.settings.permissions.permissionoptions.SitePermissionOptionsStorage +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SitePermissionOptionsStorageTest { + private lateinit var storage: SitePermissionOptionsStorage + + @Before + fun setup() { + storage = spy(SitePermissionOptionsStorage(testContext)) + } + + @Test + fun `GIVEN get site permission option selected label is called WHEN camera permission isn't granted THEN blocked by android is return`() { + doReturn(false).`when`(storage).isAndroidPermissionGranted(SitePermission.CAMERA) + + assertEquals( + testContext.getString(R.string.phone_feature_blocked_by_android), + storage.getSitePermissionOptionSelectedLabel(SitePermission.CAMERA), + ) + } + + @Test + fun `GIVEN get site permission option selected label is called WHEN camera permission is granted THEN default value ask to allow option is return`() { + doReturn(true).`when`(storage).isAndroidPermissionGranted(SitePermission.CAMERA) + + assertEquals( + testContext.getString(R.string.preference_option_phone_feature_ask_to_allow), + storage.getSitePermissionOptionSelectedLabel(SitePermission.CAMERA), + ) + } + + @Test + fun `GIVEN get site permission option selected label is called WHEN autoplay permission is granted THEN default value block audio only return`() { + doReturn(true).`when`(storage).isAndroidPermissionGranted(SitePermission.AUTOPLAY) + + assertEquals( + testContext.getString(R.string.preference_block_autoplay_audio_only), + storage.getSitePermissionOptionSelectedLabel(SitePermission.AUTOPLAY), + ) + } + + @Test + fun `GIVEN get site permission option selected label is called WHEN location permission is granted THEN default value ask to allow option is return`() { + doReturn(true).`when`(storage).isAndroidPermissionGranted(SitePermission.LOCATION) + + assertEquals( + testContext.getString(R.string.preference_option_phone_feature_ask_to_allow), + storage.getSitePermissionOptionSelectedLabel(SitePermission.LOCATION), + ) + } + + @Test + fun `GIVEN get site permission options is called WHEN location permission is passed as argument THEN available options are return`() { + assertEquals( + listOf(SitePermissionOption.AskToAllow(), SitePermissionOption.Blocked()), + storage.getSitePermissionOptions(SitePermission.LOCATION), + ) + } + + @Test + fun `GIVEN get site permission options is called WHEN camera permission is passed as argument THEN available options are return`() { + assertEquals( + listOf(SitePermissionOption.AskToAllow(), SitePermissionOption.Blocked()), + storage.getSitePermissionOptions(SitePermission.CAMERA), + ) + } + + @Test + fun `GIVEN get site permission options is called WHEN microphone permission is passed as argument THEN available options are return`() { + assertEquals( + listOf(SitePermissionOption.AskToAllow(), SitePermissionOption.Blocked()), + storage.getSitePermissionOptions(SitePermission.MICROPHONE), + ) + } + + @Test + fun `GIVEN get site permission options is called WHEN notification permission is passed as argument THEN available options are return`() { + assertEquals( + listOf(SitePermissionOption.AskToAllow(), SitePermissionOption.Blocked()), + storage.getSitePermissionOptions(SitePermission.NOTIFICATION), + ) + } + + @Test + fun `GIVEN get site permission options is called WHEN media key system access permission is passed as argument THEN available options are return`() { + assertEquals( + listOf(SitePermissionOption.AskToAllow(), SitePermissionOption.Blocked(), SitePermissionOption.Allowed()), + storage.getSitePermissionOptions(SitePermission.MEDIA_KEY_SYSTEM_ACCESS), + ) + } + + @Test + fun `GIVEN get site permission options is called WHEN autoplay permission is passed as argument THEN available options are return`() { + assertEquals( + listOf(AutoplayOption.AllowAudioVideo(), AutoplayOption.BlockAudioOnly(), AutoplayOption.BlockAudioVideo()), + storage.getSitePermissionOptions(SitePermission.AUTOPLAY), + ) + } + + @Test + fun `GIVEN get site permission label is called WHEN camera permission is passed as argument THEN permission label is return`() { + assertEquals( + testContext.getString(R.string.preference_phone_feature_camera), + storage.getSitePermissionLabel(SitePermission.CAMERA), + ) + } + + @Test + fun `GIVEN get site permission label is called WHEN location permission is passed as argument THEN permission label is return`() { + assertEquals( + testContext.getString(R.string.preference_phone_feature_location), + storage.getSitePermissionLabel(SitePermission.LOCATION), + ) + } + + @Test + fun `GIVEN get site permission label is called WHEN microphone permission is passed as argument THEN permission label is return`() { + assertEquals( + testContext.getString(R.string.preference_phone_feature_microphone), + storage.getSitePermissionLabel(SitePermission.MICROPHONE), + ) + } + + @Test + fun `GIVEN get site permission label is called WHEN notification permission is passed as argument THEN permission label is return`() { + assertEquals( + testContext.getString(R.string.preference_phone_feature_notification), + storage.getSitePermissionLabel(SitePermission.NOTIFICATION), + ) + } + + @Test + fun `GIVEN get site permission label is called WHEN media key system access permission is passed as argument THEN permission label is return`() { + assertEquals( + testContext.getString(R.string.preference_phone_feature_media_key_system_access), + storage.getSitePermissionLabel(SitePermission.MEDIA_KEY_SYSTEM_ACCESS), + ) + } + + @Test + fun `GIVEN get site permission label is called WHEN autoplay permission is passed as argument THEN permission label is return`() { + assertEquals( + testContext.getString(R.string.preference_autoplay), + storage.getSitePermissionLabel(SitePermission.AUTOPLAY), + ) + } + + @Test + fun `GIVEN get site permission default option is called WHEN camera permission is passed as argument THEN default option is return`() { + assertEquals( + SitePermissionOption.AskToAllow(), + storage.getSitePermissionDefaultOption(SitePermission.CAMERA), + ) + } + + @Test + fun `GIVEN get site permission default option is called WHEN location permission is passed as argument THEN default option is return`() { + assertEquals( + SitePermissionOption.AskToAllow(), + storage.getSitePermissionDefaultOption(SitePermission.LOCATION), + ) + } + + @Test + fun `GIVEN get site permission default option is called WHEN microphone permission is passed as argument THEN default option is return`() { + assertEquals( + SitePermissionOption.AskToAllow(), + storage.getSitePermissionDefaultOption(SitePermission.MICROPHONE), + ) + } + + @Test + fun `GIVEN get site permission default option is called WHEN notification permission is passed as argument THEN default option is return`() { + assertEquals( + SitePermissionOption.AskToAllow(), + storage.getSitePermissionDefaultOption(SitePermission.NOTIFICATION), + ) + } + + @Test + fun `GIVEN get site permission default option is called WHEN media key system access permission is passed as argument THEN default option is return`() { + assertEquals( + SitePermissionOption.AskToAllow(), + storage.getSitePermissionDefaultOption(SitePermission.MEDIA_KEY_SYSTEM_ACCESS), + ) + } + + @Test + fun `GIVEN get site permission default option is called WHEN autoplay permission is passed as argument THEN default option is return`() { + assertEquals( + AutoplayOption.BlockAudioOnly(), + storage.getSitePermissionDefaultOption(SitePermission.AUTOPLAY), + ) + } + + @Test + fun `GIVEN get permission selected option is called WHEN pref_key_allowed is saved in SharedPreferences THEN site permission Allowed is return`() { + doReturn(testContext.getString(R.string.pref_key_allowed)) + .`when`(storage).permissionSelectedOptionByKey(R.string.pref_key_phone_feature_camera) + + assertEquals( + SitePermissionOption.Allowed(), + storage.permissionSelectedOption(SitePermission.CAMERA), + ) + } + + @Test + fun `GIVEN get permission selected option is called WHEN pref_key_allow_autoplay_audio_video is saved in SharedPreferences THEN site permission Allow Audio and Video is return`() { + doReturn(testContext.getString(R.string.pref_key_allow_autoplay_audio_video)) + .`when`(storage).permissionSelectedOptionByKey(R.string.pref_key_phone_feature_camera) + + assertEquals( + AutoplayOption.AllowAudioVideo(), + storage.permissionSelectedOption(SitePermission.CAMERA), + ) + } + + @Test + fun `GIVEN get permission selected option is called WHEN pref_key_block_autoplay_audio_video is saved in SharedPreferences THEN site permission Block Audio and Video is return`() { + doReturn(testContext.getString(R.string.pref_key_block_autoplay_audio_video)) + .`when`(storage).permissionSelectedOptionByKey(R.string.pref_key_phone_feature_camera) + + assertEquals( + AutoplayOption.BlockAudioVideo(), + storage.permissionSelectedOption(SitePermission.CAMERA), + ) + } + + @Test + fun `GIVEN get permission selected option is called WHEN pref_key_blocked is saved in SharedPreferences THEN site permission Blocked is return`() { + doReturn(testContext.getString(R.string.pref_key_blocked)) + .`when`(storage).permissionSelectedOptionByKey(R.string.pref_key_phone_feature_camera) + + assertEquals( + SitePermissionOption.Blocked(), + storage.permissionSelectedOption(SitePermission.CAMERA), + ) + } + + @Test + fun `GIVEN get permission selected option is called WHEN pref_key_ask_to_allow is saved in SharedPreferences THEN site permission Ask to allow is return`() { + doReturn(testContext.getString(R.string.pref_key_ask_to_allow)) + .`when`(storage).permissionSelectedOptionByKey(R.string.pref_key_phone_feature_camera) + + assertEquals( + SitePermissionOption.AskToAllow(), + storage.permissionSelectedOption(SitePermission.CAMERA), + ) + } + + @Test + fun `GIVEN get permission selected option is called WHEN pref_key_block_autoplay_audio_only is saved in SharedPreferences THEN site permission Block Audio only is return`() { + doReturn(testContext.getString(R.string.pref_key_block_autoplay_audio_only)) + .`when`(storage).permissionSelectedOptionByKey(R.string.pref_key_phone_feature_camera) + + assertEquals( + AutoplayOption.BlockAudioOnly(), + storage.permissionSelectedOption(SitePermission.CAMERA), + ) + } + + @Test + fun `GIVEN get site permission preference id is called WHEN site permission camera is passed as argument THEN pref_key_phone_feature_camera is return`() { + assertEquals( + R.string.pref_key_phone_feature_camera, + storage.getSitePermissionPreferenceId(SitePermission.CAMERA), + ) + } + + @Test + fun `GIVEN get site permission preference id is called WHEN site permission location is passed as argument THEN pref_key_phone_feature_location is return`() { + assertEquals( + R.string.pref_key_phone_feature_location, + storage.getSitePermissionPreferenceId(SitePermission.LOCATION), + ) + } + + @Test + fun `GIVEN get site permission preference id is called WHEN site permission microphone is passed as argument THEN pref_key_phone_feature_microphone is return`() { + assertEquals( + R.string.pref_key_phone_feature_microphone, + storage.getSitePermissionPreferenceId(SitePermission.MICROPHONE), + ) + } + + @Test + fun `GIVEN get site permission preference id is called WHEN site permission notification is passed as argument THEN pref_key_phone_feature_notification is return`() { + assertEquals( + R.string.pref_key_phone_feature_notification, + storage.getSitePermissionPreferenceId(SitePermission.NOTIFICATION), + ) + } + + @Test + fun `GIVEN get site permission preference id is called WHEN site permission autoplay is passed as argument THEN pref_key_autoplay is return`() { + assertEquals( + R.string.pref_key_autoplay, + storage.getSitePermissionPreferenceId(SitePermission.AUTOPLAY), + ) + } + + @Test + fun `GIVEN get site permission preference id is called WHEN site permission autoplay audible is passed as argument THEN pref_key_allow_autoplay_audio_video is return`() { + assertEquals( + R.string.pref_key_allow_autoplay_audio_video, + storage.getSitePermissionPreferenceId(SitePermission.AUTOPLAY_AUDIBLE), + ) + } + + @Test + fun `GIVEN get site permission preference id is called WHEN site permission autoplay inaudible is passed as argument THEN pref_key_block_autoplay_audio_video is return`() { + assertEquals( + R.string.pref_key_block_autoplay_audio_video, + storage.getSitePermissionPreferenceId(SitePermission.AUTOPLAY_INAUDIBLE), + ) + } + + @Test + fun `GIVEN get site permission preference id is called WHEN site permission media key system access is passed as argument THEN pref_key_browser_feature_media_key_system_access is return`() { + assertEquals( + R.string.pref_key_browser_feature_media_key_system_access, + storage.getSitePermissionPreferenceId(SitePermission.MEDIA_KEY_SYSTEM_ACCESS), + ) + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/sitepermissions/SitePermissionOptionsStoreTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/sitepermissions/SitePermissionOptionsStoreTest.kt new file mode 100644 index 0000000000..a76ecb95d6 --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/sitepermissions/SitePermissionOptionsStoreTest.kt @@ -0,0 +1,89 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +package org.mozilla.focus.sitepermissions + +import mozilla.components.support.test.ext.joinBlocking +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.verify +import org.mozilla.focus.settings.permissions.AutoplayOption +import org.mozilla.focus.settings.permissions.SitePermissionOption +import org.mozilla.focus.settings.permissions.permissionoptions.SitePermission +import org.mozilla.focus.settings.permissions.permissionoptions.SitePermissionOptionsScreenAction +import org.mozilla.focus.settings.permissions.permissionoptions.SitePermissionOptionsScreenState +import org.mozilla.focus.settings.permissions.permissionoptions.SitePermissionOptionsScreenStore +import org.mozilla.focus.settings.permissions.permissionoptions.SitePermissionOptionsStorage +import org.mozilla.focus.settings.permissions.permissionoptions.SitePermissionOptionsStorageMiddleware + +class SitePermissionOptionsStoreTest { + private lateinit var sitePermissionOptionsStorageMiddleware: SitePermissionOptionsStorageMiddleware + private lateinit var store: SitePermissionOptionsScreenStore + private lateinit var sitePermissionState: SitePermissionOptionsScreenState + private val storage: SitePermissionOptionsStorage = mock() + + @Before + fun setup() { + sitePermissionOptionsStorageMiddleware = SitePermissionOptionsStorageMiddleware(SitePermission.CAMERA, storage) + sitePermissionState = SitePermissionOptionsScreenState() + + doReturn(listOf(SitePermissionOption.AskToAllow(), SitePermissionOption.Blocked())).`when`(storage).getSitePermissionOptions(SitePermission.CAMERA) + doReturn(SitePermissionOption.AskToAllow()).`when`(storage).permissionSelectedOption(SitePermission.CAMERA) + doReturn("Camera").`when`(storage).getSitePermissionLabel(SitePermission.CAMERA) + doReturn(false).`when`(storage).isAndroidPermissionGranted(SitePermission.CAMERA) + + store = SitePermissionOptionsScreenStore(sitePermissionState, listOf(sitePermissionOptionsStorageMiddleware)) + } + + @Test + fun `GIVEN site permission screen store WHEN android permission action is dispatched THEN site permission options screen state is updated`() { + store.dispatch(SitePermissionOptionsScreenAction.AndroidPermission(true)).joinBlocking() + + verify(storage).getSitePermissionOptions(SitePermission.CAMERA) + verify(storage).permissionSelectedOption(SitePermission.CAMERA) + verify(storage).getSitePermissionLabel(SitePermission.CAMERA) + verify(storage).isAndroidPermissionGranted(SitePermission.CAMERA) + assertTrue(store.state.isAndroidPermissionGranted) + } + + @Test + fun `GIVEN site permission screen store WHEN select permission action is dispatched THEN site permission options screen state is updated`() { + store.dispatch(SitePermissionOptionsScreenAction.Select(SitePermissionOption.Blocked())).joinBlocking() + + verify(storage).saveCurrentSitePermissionOptionInSharePref(SitePermissionOption.Blocked(), SitePermission.CAMERA) + assertEquals(SitePermissionOption.Blocked(), store.state.selectedSitePermissionOption) + } + + @Test + fun `GIVEN site permission screen store WHEN update site permission options is dispatched THEN site permission options screen state is updated`() { + val sitePermissionLabel = "Autoplay" + store.dispatch( + SitePermissionOptionsScreenAction.UpdateSitePermissionOptions( + listOf( + AutoplayOption.BlockAudioOnly(), + AutoplayOption.AllowAudioVideo(), + AutoplayOption.BlockAudioVideo(), + ), + AutoplayOption.AllowAudioVideo(), + sitePermissionLabel, + true, + ), + ).joinBlocking() + + assertEquals(AutoplayOption.AllowAudioVideo(), store.state.selectedSitePermissionOption) + assertTrue(store.state.isAndroidPermissionGranted) + assertEquals( + listOf( + AutoplayOption.BlockAudioOnly(), + AutoplayOption.AllowAudioVideo(), + AutoplayOption.BlockAudioVideo(), + ), + store.state.sitePermissionOptionList, + ) + assertEquals(sitePermissionLabel, store.state.sitePermissionLabel) + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/sitepermissions/SitePermissionsFragmentTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/sitepermissions/SitePermissionsFragmentTest.kt new file mode 100644 index 0000000000..0072c55dee --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/sitepermissions/SitePermissionsFragmentTest.kt @@ -0,0 +1,57 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +package org.mozilla.focus.sitepermissions + +import androidx.preference.Preference +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify +import org.mozilla.focus.R +import org.mozilla.focus.settings.permissions.SitePermissionsFragment +import org.mozilla.focus.settings.permissions.permissionoptions.SitePermission +import org.mozilla.focus.settings.permissions.permissionoptions.SitePermissionOptionsStorage +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SitePermissionsFragmentTest { + + private val storage: SitePermissionOptionsStorage = mock() + private lateinit var fragment: SitePermissionsFragment + + @Before + fun setup() { + fragment = spy(SitePermissionsFragment()) + doReturn(testContext).`when`(fragment).context + fragment.storage = storage + } + + @Test + fun `GIVEN site permission fragment WHEN fragment is created THEN summary for Preference is set from storage`() { + val cameraPreference = Preference(testContext) + doReturn(cameraPreference).`when`(fragment).getPreference(R.string.pref_key_phone_feature_camera) + val expectedSummary = testContext.getString(R.string.phone_feature_blocked_by_android) + doReturn(expectedSummary).`when`(storage).getSitePermissionOptionSelectedLabel(SitePermission.CAMERA) + + fragment.initPhoneFeature(SitePermission.CAMERA) + + assertEquals(expectedSummary, cameraPreference.summary) + } + + @Test + fun `GIVEN site permission fragment WHEN preference is clicked THEN navigate to site permission options screen it called`() { + val cameraPreference = Preference(testContext) + doReturn(cameraPreference).`when`(fragment).getPreference(R.string.pref_key_phone_feature_camera) + + fragment.initPhoneFeature(SitePermission.CAMERA) + cameraPreference.performClick() + + verify(fragment).navigateToSitePermissionOptionsScreen(SitePermission.CAMERA) + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/telemetry/GleanMetricsServiceTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/telemetry/GleanMetricsServiceTest.kt new file mode 100644 index 0000000000..28166ce263 --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/telemetry/GleanMetricsServiceTest.kt @@ -0,0 +1,42 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.focus.telemetry + +import android.content.Context +import kotlinx.coroutines.runBlocking +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.engine.Engine +import mozilla.components.feature.search.telemetry.SearchProviderModel +import mozilla.components.feature.search.telemetry.ads.AdsTelemetry +import mozilla.components.feature.search.telemetry.incontent.InContentTelemetry +import org.junit.Test +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mozilla.focus.Components + +class GleanMetricsServiceTest { + @Test + fun `WHEN installSearchTelemetryExtensions is called THEN install the ads and search telemetry extensions`() { + val components = mock(Components::class.java) + val store = mock(BrowserStore::class.java) + val engine = mock(Engine::class.java) + val adsExtension = mock(AdsTelemetry::class.java) + val searchExtension = mock(InContentTelemetry::class.java) + val providerList: List<SearchProviderModel> = mock() + doReturn(engine).`when`(components).engine + doReturn(store).`when`(components).store + doReturn(adsExtension).`when`(components).adsTelemetry + doReturn(searchExtension).`when`(components).searchTelemetry + val glean = GleanMetricsService(mock(Context::class.java)) + + runBlocking { + glean.installSearchTelemetryExtensions(components, providerList) + + verify(adsExtension).install(engine, store, providerList) + verify(searchExtension).install(engine, store, providerList) + } + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/telemetry/ProfilerMarkerFactProcessorTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/telemetry/ProfilerMarkerFactProcessorTest.kt new file mode 100644 index 0000000000..d95da8aac5 --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/telemetry/ProfilerMarkerFactProcessorTest.kt @@ -0,0 +1,96 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.focus.telemetry + +import android.os.Handler +import android.os.Looper +import mozilla.components.concept.base.profiler.Profiler +import mozilla.components.support.base.Component +import mozilla.components.support.base.facts.Action +import mozilla.components.support.base.facts.Fact +import mozilla.components.support.test.any +import mozilla.components.support.test.argumentCaptor +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoInteractions +import org.mockito.Mockito.`when` + +class ProfilerMarkerFactProcessorTest { + + private val profiler: Profiler = mock(Profiler::class.java) + private val mainHandler: Handler = mock(Handler::class.java) + lateinit var processor: ProfilerMarkerFactProcessor + + var myLooper: Looper? = null + + @Before + fun setUp() { + myLooper = null + processor = ProfilerMarkerFactProcessor({ profiler }, mainHandler, { myLooper }) + } + + @Test + fun `Test on the main thread`() { + // GIVEN we are on the main thread + myLooper = mainHandler.looper // main thread + + // WHEN a fact with an implementation detail action is received + val fact = newFact(Action.IMPLEMENTATION_DETAIL) + processor.process(fact) + + // THEN a profiler marker is added now + verify(profiler).addMarker(fact.item) + } + + @Test + fun `Test not on the main thread`() { + // GIVEN we are not on the main thread + myLooper = mock(Looper::class.java) // off main thread + + // WHEN a fact with an implementation detail action is received + val mainThreadPostedArg = argumentCaptor<Runnable>() + `when`(profiler.getProfilerTime()).thenReturn(100.0) + + val fact = newFact(Action.IMPLEMENTATION_DETAIL) + processor.process(fact) + + // THEN adding the marker is posted to the main thread + verify(mainHandler).post(mainThreadPostedArg.capture()) + verifyProfilerAddMarkerWasNotCalled() + + mainThreadPostedArg.value.run() + verify(profiler).addMarker(fact.item, 100.0, 100.0, null) + } + + @Test + fun `Test non-implementation detail`() { + // WHEN a fact with a non-implementation detail action is received + val fact = newFact(Action.CANCEL) + processor.process(fact) + + // THEN no profiler marker is added + verifyNoInteractions(profiler) + } + + private fun verifyProfilerAddMarkerWasNotCalled() { + verify(profiler, never()).addMarker(any()) + verify(profiler, never()).addMarker(any(), any() as Double?) + verify(profiler, never()).addMarker(any(), any() as String?) + verify(profiler, never()).addMarker(any(), any(), any()) + verify(profiler, never()).addMarker(any(), any(), any(), any()) + } +} + +private fun newFact( + action: Action, + item: String = "itemName", +) = Fact( + Component.BROWSER_SESSION_STORAGE, + action, + item, +) diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/telemetry/StartupActivityLogTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/telemetry/StartupActivityLogTest.kt new file mode 100644 index 0000000000..b08ebb9f6e --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/telemetry/StartupActivityLogTest.kt @@ -0,0 +1,99 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.focus.telemetry + +import android.app.Activity +import android.app.Application +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mozilla.focus.telemetry.startuptelemetry.StartupActivityLog +import org.mozilla.focus.telemetry.startuptelemetry.StartupActivityLog.LogEntry + +class StartupActivityLogTest { + + private lateinit var log: StartupActivityLog + private lateinit var appObserver: StartupActivityLog.StartupLogAppLifecycleObserver + private lateinit var activityCallbacks: StartupActivityLog.StartupLogActivityLifecycleCallbacks + + @Before + fun setUp() { + log = StartupActivityLog() + val (appObserver, activityCallbacks) = log.getObserversForTesting() + this.appObserver = appObserver + this.activityCallbacks = activityCallbacks + } + + @Test + fun `WHEN register is called THEN it is registered`() { + val app: Application = mock() + val lifecycleOwner: LifecycleOwner = mock() + val mLifecycle: Lifecycle = mock() + `when`(lifecycleOwner.lifecycle).thenReturn(mLifecycle) + + log.registerInAppOnCreate(app, lifecycleOwner) + + verify(app).registerActivityLifecycleCallbacks(any()) + } + + @Test // we test start and stop individually due to the clear-on-stop behavior. + fun `WHEN app observer start is called THEN it is added directly to the log`() { + assertTrue(log.log.isEmpty()) + + appObserver.onStart(mock()) + assertEquals(listOf(LogEntry.AppStarted), log.log) + + appObserver.onStart(mock()) + assertEquals(listOf(LogEntry.AppStarted, LogEntry.AppStarted), log.log) + } + + @Test // we test start and stop individually due to the clear-on-stop behavior. + fun `WHEN app observer stop is called THEN it is added directly to the log`() { + assertTrue(log.log.isEmpty()) + + appObserver.onStop(mock()) + assertEquals(listOf(LogEntry.AppStopped), log.log) + } + + @Test + fun `WHEN activity callback methods are called THEN they are added directly to the log`() { + assertTrue(log.log.isEmpty()) + val expected = mutableListOf<LogEntry>() + + val activityClass = mock<Activity>()::class.java // mockk can't mock Class<...> + + activityCallbacks.onActivityCreated(mock(), null) + expected.add(LogEntry.ActivityCreated(activityClass)) + assertEquals(expected, log.log) + + activityCallbacks.onActivityStarted(mock()) + expected.add(LogEntry.ActivityStarted(activityClass)) + assertEquals(expected, log.log) + + activityCallbacks.onActivityStopped(mock()) + expected.add(LogEntry.ActivityStopped(activityClass)) + assertEquals(expected, log.log) + } + + @Test + fun `WHEN app STOPPED is called THEN the log is emptied expect for the stop event`() { + assertTrue(log.log.isEmpty()) + + activityCallbacks.onActivityCreated(mock(), null) + activityCallbacks.onActivityStarted(mock()) + appObserver.onStart(mock()) + assertEquals(3, log.log.size) + + appObserver.onStop(mock()) + assertEquals(listOf(LogEntry.AppStopped), log.log) + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/telemetry/StartupPathProviderTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/telemetry/StartupPathProviderTest.kt new file mode 100644 index 0000000000..cc023ee079 --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/telemetry/StartupPathProviderTest.kt @@ -0,0 +1,213 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.focus.telemetry + +import android.content.Intent +import androidx.lifecycle.Lifecycle +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify +import org.mozilla.focus.telemetry.startuptelemetry.StartupPathProvider +import org.mozilla.focus.telemetry.startuptelemetry.StartupPathProvider.StartupPath + +class StartupPathProviderTest { + + private lateinit var provider: StartupPathProvider + private lateinit var callbacks: StartupPathProvider.StartupPathLifecycleObserver + + private val intent: Intent = mock() + + @Before + fun setUp() { + provider = StartupPathProvider() + callbacks = provider.getTestCallbacks() + } + + @Test + fun `WHEN attach is called THEN the provider is registered to the lifecycle`() { + val lifecycle = mock<Lifecycle>() + provider.attachOnActivityOnCreate(lifecycle, null) + + verify(lifecycle).addObserver(any()) + } + + @Test + fun `WHEN calling attach THEN the intent is passed to on intent received`() { + // With this test, we're basically saying, "attach..." does the same thing as + // "onIntentReceived" so we don't need to duplicate all the tests we run for + // "onIntentReceived". + val spyProvider = spy(provider) + spyProvider.attachOnActivityOnCreate(mock(), intent) + + verify(spyProvider).onIntentReceived(intent) + } + + @Test + fun `GIVEN no intent is received and the activity is not started WHEN getting the start up path THEN it is not set`() { + assertEquals(StartupPath.NOT_SET, provider.startupPathForActivity) + } + + @Test + fun `GIVEN a main intent is received but the activity is not started yet WHEN getting the start up path THEN main is returned`() { + doReturn(Intent.ACTION_MAIN).`when`(intent).action + provider.onIntentReceived(intent) + assertEquals(StartupPath.MAIN, provider.startupPathForActivity) + } + + @Test + fun `GIVEN a main intent is received and the app is started WHEN getting the start up path THEN it is main`() { + doReturn(Intent.ACTION_MAIN).`when`(intent).action + callbacks.onCreate(mock()) + provider.onIntentReceived(intent) + callbacks.onStart(mock()) + + assertEquals(StartupPath.MAIN, provider.startupPathForActivity) + } + + @Test + fun `GIVEN the app is launched from the homeScreen WHEN getting the start up path THEN it is main`() { + // There's technically more to a homeScreen Intent but it's fine for now. + doReturn(Intent.ACTION_MAIN).`when`(intent).action + launchApp(intent) + assertEquals(StartupPath.MAIN, provider.startupPathForActivity) + } + + @Test + fun `GIVEN the app is launched by app link WHEN getting the start up path THEN it is view`() { + // There's technically more to a homeScreen Intent but it's fine for now. + doReturn(Intent.ACTION_VIEW).`when`(intent).action + + launchApp(intent) + + assertEquals(StartupPath.VIEW, provider.startupPathForActivity) + } + + @Test + fun `GIVEN the app is launched by a send action WHEN getting the start up path THEN it is unknown`() { + doReturn(Intent.ACTION_SEND).`when`(intent).action + + launchApp(intent) + + assertEquals(StartupPath.UNKNOWN, provider.startupPathForActivity) + } + + @Test + fun `GIVEN the app is launched by a null intent (is this possible) WHEN getting the start up path THEN it is not set`() { + callbacks.onCreate(mock()) + provider.onIntentReceived(null) + callbacks.onStart(mock()) + callbacks.onResume(mock()) + + assertEquals(StartupPath.NOT_SET, provider.startupPathForActivity) + } + + @Test + fun `GIVEN the app is launched to the homeScreen and stopped WHEN getting the start up path THEN it is not set`() { + doReturn(Intent.ACTION_MAIN).`when`(intent).action + + launchApp(intent) + stopLaunchedApp() + + assertEquals(StartupPath.NOT_SET, provider.startupPathForActivity) + } + + @Test + fun `GIVEN the app is launched to the homeScreen, stopped, and relaunched warm from app link WHEN getting the start up path THEN it is view`() { + doReturn(Intent.ACTION_MAIN).`when`(intent).action + + launchApp(intent) + stopLaunchedApp() + + doReturn(Intent.ACTION_VIEW).`when`(intent).action + + startStoppedApp(intent) + + assertEquals(StartupPath.VIEW, provider.startupPathForActivity) + } + + @Test + fun `GIVEN the app is launched to the homeScreen, stopped, and relaunched warm from the app switcher WHEN getting the start up path THEN it is not set`() { + doReturn(Intent.ACTION_MAIN).`when`(intent).action + + launchApp(intent) + stopLaunchedApp() + startStoppedAppFromAppSwitcher() + + assertEquals(StartupPath.NOT_SET, provider.startupPathForActivity) + } + + @Test + fun `GIVEN the app is launched to the homeScreen, paused, and resumed WHEN getting the start up path THEN it returns the initial intent value`() { + doReturn(Intent.ACTION_MAIN).`when`(intent).action + + launchApp(intent) + callbacks.onPause(mock()) + callbacks.onResume(mock()) + + assertEquals(StartupPath.MAIN, provider.startupPathForActivity) + } + + @Test + fun `GIVEN the app is launched with an intent and receives an intent while the activity is foregrounded WHEN getting the start up path THEN it returns the initial intent value`() { + doReturn(Intent.ACTION_MAIN).`when`(intent).action + + launchApp(intent) + doReturn(Intent.ACTION_VIEW).`when`(intent).action + + receiveIntentInForeground(intent) + + assertEquals(StartupPath.MAIN, provider.startupPathForActivity) + } + + @Test + fun `GIVEN the app is launched, stopped, started from the app switcher and receives an intent in the foreground WHEN getting the start up path THEN it returns not set`() { + doReturn(Intent.ACTION_MAIN).`when`(intent).action + + launchApp(intent) + stopLaunchedApp() + startStoppedAppFromAppSwitcher() + doReturn(Intent.ACTION_VIEW).`when`(intent).action + + receiveIntentInForeground(intent) + + assertEquals(StartupPath.NOT_SET, provider.startupPathForActivity) + } + + private fun launchApp(intent: Intent) { + callbacks.onCreate(mock()) + provider.onIntentReceived(intent) + callbacks.onStart(mock()) + callbacks.onResume(mock()) + } + + private fun stopLaunchedApp() { + callbacks.onPause(mock()) + callbacks.onStop(mock()) + } + + private fun startStoppedApp(intent: Intent) { + callbacks.onStart(mock()) + provider.onIntentReceived(intent) + callbacks.onResume(mock()) + } + + private fun startStoppedAppFromAppSwitcher() { + // What makes the app switcher case special is it starts the app without an intent. + callbacks.onStart(mock()) + callbacks.onResume(mock()) + } + + private fun receiveIntentInForeground(intent: Intent) { + // To my surprise, the app is paused before receiving an intent on Pixel 2. + callbacks.onPause(mock()) + provider.onIntentReceived(intent) + callbacks.onResume(mock()) + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/telemetry/StartupStateProviderTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/telemetry/StartupStateProviderTest.kt new file mode 100644 index 0000000000..db8d452b63 --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/telemetry/StartupStateProviderTest.kt @@ -0,0 +1,417 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.focus.telemetry + +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito +import org.mozilla.focus.activity.IntentReceiverActivity +import org.mozilla.focus.activity.MainActivity +import org.mozilla.focus.telemetry.startuptelemetry.AppStartReasonProvider +import org.mozilla.focus.telemetry.startuptelemetry.AppStartReasonProvider.StartReason +import org.mozilla.focus.telemetry.startuptelemetry.StartupActivityLog +import org.mozilla.focus.telemetry.startuptelemetry.StartupActivityLog.LogEntry +import org.mozilla.focus.telemetry.startuptelemetry.StartupStateProvider +import org.mozilla.focus.telemetry.startuptelemetry.StartupStateProvider.StartupState + +class StartupStateProviderTest { + + private lateinit var provider: StartupStateProvider + private var startupActivityLog: StartupActivityLog = mock() + private var startReasonProvider: AppStartReasonProvider = mock() + + private lateinit var logEntries: MutableList<LogEntry> + + private val mainActivityClass = MainActivity::class.java + private val irActivityClass = IntentReceiverActivity::class.java + + @Before + fun setUp() { + provider = StartupStateProvider(startupActivityLog, startReasonProvider) + + logEntries = mutableListOf() + Mockito.doReturn(logEntries).`when`(startupActivityLog).log + Mockito.doReturn(logEntries).`when`(startupActivityLog).log + Mockito.doReturn(StartReason.ACTIVITY).`when`(startReasonProvider).reason + } + + @Test + fun `GIVEN the app started for an activity WHEN is cold start THEN cold start is true`() { + forEachColdStartEntries { index -> + assertTrue("$index", provider.isColdStartForStartedActivity(mainActivityClass)) + } + } + + @Test + fun `GIVEN the app started for an activity WHEN warm start THEN cold start is false`() { + forEachWarmStartEntries { index -> + assertFalse("$index", provider.isColdStartForStartedActivity(mainActivityClass)) + } + } + + @Test + fun `GIVEN the app started for an activity WHEN hot start THEN cold start is false`() { + forEachHotStartEntries { index -> + assertFalse("$index", provider.isColdStartForStartedActivity(mainActivityClass)) + } + } + + @Test + fun `GIVEN the app started for an activity WHEN is cold start THEN warm start is false`() { + forEachColdStartEntries { index -> + assertFalse("$index", provider.isWarmStartForStartedActivity(mainActivityClass)) + } + } + + @Test + fun `GIVEN the app started for an activity WHEN is warm start THEN warm start is true`() { + forEachWarmStartEntries { index -> + assertTrue("$index", provider.isWarmStartForStartedActivity(mainActivityClass)) + } + } + + @Test + fun `GIVEN the app started for an activity WHEN is hot start THEN warm start is false`() { + forEachHotStartEntries { index -> + assertFalse("$index", provider.isWarmStartForStartedActivity(mainActivityClass)) + } + } + + @Test + fun `GIVEN the app started for an activity WHEN is cold start THEN hot start is false`() { + forEachColdStartEntries { index -> + assertFalse("$index", provider.isHotStartForStartedActivity(mainActivityClass)) + } + } + + @Test + fun `GIVEN the app started for an activity WHEN is warm start THEN hot start is false`() { + forEachWarmStartEntries { index -> + assertFalse("$index", provider.isHotStartForStartedActivity(mainActivityClass)) + } + } + + @Test + fun `GIVEN the app started for an activity WHEN is hot start THEN hot start is true`() { + forEachHotStartEntries { index -> + assertTrue("$index", provider.isHotStartForStartedActivity(mainActivityClass)) + } + } + + @Test + fun `GIVEN the app started for an activity WHEN we launched HA through a drawing IntentRA THEN start up is not cold`() { + // These entries mimic observed behavior for local code changes. + logEntries.addAll( + listOf( + LogEntry.ActivityCreated(irActivityClass), + LogEntry.ActivityStarted(irActivityClass), + LogEntry.AppStarted, + LogEntry.ActivityCreated(mainActivityClass), + LogEntry.ActivityStarted(mainActivityClass), + LogEntry.ActivityStopped(irActivityClass), + ), + ) + assertFalse(provider.isColdStartForStartedActivity(mainActivityClass)) + } + + @Test + fun `GIVEN the app started for an activity WHEN we launched HA through a drawing IntentRA THEN start up is not warm`() { + // These entries mimic observed behavior for local code changes. + logEntries.addAll( + listOf( + LogEntry.AppStopped, + LogEntry.ActivityStopped(mainActivityClass), + LogEntry.ActivityCreated(irActivityClass), + LogEntry.ActivityStarted(irActivityClass), + LogEntry.AppStarted, + LogEntry.ActivityCreated(mainActivityClass), + LogEntry.ActivityStarted(mainActivityClass), + LogEntry.ActivityStopped(irActivityClass), + ), + ) + assertFalse(provider.isWarmStartForStartedActivity(mainActivityClass)) + } + + @Test + fun `GIVEN the app started for an activity WHEN we launched HA through a drawing IntentRA THEN start up is not hot`() { + // These entries mimic observed behavior for local code changes. + logEntries.addAll( + listOf( + LogEntry.AppStopped, + LogEntry.ActivityStopped(mainActivityClass), + LogEntry.ActivityCreated(irActivityClass), + LogEntry.ActivityStarted(irActivityClass), + LogEntry.AppStarted, + LogEntry.ActivityStarted(mainActivityClass), + LogEntry.ActivityStopped(irActivityClass), + ), + ) + assertFalse(provider.isHotStartForStartedActivity(mainActivityClass)) + } + + @Test + fun `GIVEN the app started for an activity WHEN two MainActivities are created THEN start up is not cold`() { + // We're making an assumption about how this would work based on previous observed patterns. + // AIUI, we should never have more than one MainActivity. + logEntries.addAll( + listOf( + LogEntry.ActivityCreated(mainActivityClass), + LogEntry.ActivityStarted(mainActivityClass), + LogEntry.AppStarted, + LogEntry.ActivityCreated(mainActivityClass), + LogEntry.ActivityStarted(mainActivityClass), + LogEntry.ActivityStopped(mainActivityClass), + ), + ) + assertFalse(provider.isColdStartForStartedActivity(mainActivityClass)) + } + + @Test + fun `GIVEN the app started for an activity WHEN an activity hasn't been created yet THEN start up is not cold`() { + assertFalse(provider.isColdStartForStartedActivity(mainActivityClass)) + } + + @Test + fun `GIVEN the app started for an activity WHEN an activity hasn't started yet THEN start up is not cold`() { + logEntries.addAll( + listOf( + LogEntry.ActivityCreated(mainActivityClass), + ), + ) + assertFalse(provider.isColdStartForStartedActivity(mainActivityClass)) + } + + @Test + fun `GIVEN the app did not start for an activity WHEN is cold is checked THEN it returns false`() { + Mockito.doReturn(StartReason.NON_ACTIVITY).`when`(startReasonProvider).reason + assertFalse(provider.isColdStartForStartedActivity(mainActivityClass)) + + forEachColdStartEntries { index -> + assertFalse("$index", provider.isColdStartForStartedActivity(mainActivityClass)) + } + } + + @Test + fun `GIVEN the app has not been stopped WHEN an activity has not been created THEN it's not a warm start`() { + assertFalse(provider.isWarmStartForStartedActivity(mainActivityClass)) + } + + @Test + fun `GIVEN the app has been stopped WHEN an activity has not been created THEN it's not a warm start`() { + logEntries.add(LogEntry.AppStopped) + assertFalse(provider.isWarmStartForStartedActivity(mainActivityClass)) + } + + @Test + fun `GIVEN the app has been stopped WHEN an activity has not been started THEN it's not a warm start`() { + logEntries.addAll( + listOf( + LogEntry.AppStopped, + LogEntry.ActivityCreated(mainActivityClass), + ), + ) + assertFalse(provider.isWarmStartForStartedActivity(mainActivityClass)) + } + + @Test + fun `GIVEN the app has not been stopped WHEN an activity has not been created THEN it's not a hot start`() { + assertFalse(provider.isHotStartForStartedActivity(mainActivityClass)) + } + + @Test + fun `GIVEN the app has been stopped WHEN an activity has not been created THEN it's not a hot start`() { + logEntries.add(LogEntry.AppStopped) + assertFalse(provider.isHotStartForStartedActivity(mainActivityClass)) + } + + @Test + fun `GIVEN the app has been stopped WHEN an activity has not been started THEN it's not a hot start`() { + logEntries.addAll( + listOf( + LogEntry.AppStopped, + LogEntry.ActivityCreated(mainActivityClass), + ), + ) + assertFalse(provider.isHotStartForStartedActivity(mainActivityClass)) + } + + @Test + fun `GIVEN the app started for an activity WHEN it is a cold start THEN get startup state is cold`() { + forEachColdStartEntries { index -> + assertEquals("$index", StartupState.COLD, provider.getStartupStateForStartedActivity(mainActivityClass)) + } + } + + @Test + fun `WHEN it is a warm start THEN get startup state is warm`() { + forEachWarmStartEntries { index -> + assertEquals("$index", StartupState.WARM, provider.getStartupStateForStartedActivity(mainActivityClass)) + } + } + + @Test + fun `WHEN it is a hot start THEN get startup state is hot`() { + forEachHotStartEntries { index -> + assertEquals("$index", StartupState.HOT, provider.getStartupStateForStartedActivity(mainActivityClass)) + } + } + + @Test + fun `WHEN two activities are started THEN get startup state is unknown`() { + logEntries.addAll( + listOf( + LogEntry.ActivityCreated(irActivityClass), + LogEntry.ActivityStarted(irActivityClass), + LogEntry.AppStarted, + LogEntry.ActivityCreated(mainActivityClass), + LogEntry.ActivityStarted(mainActivityClass), + LogEntry.ActivityStopped(irActivityClass), + ), + ) + + assertEquals(StartupState.UNKNOWN, provider.getStartupStateForStartedActivity(mainActivityClass)) + } + + private fun forEachColdStartEntries(block: (index: Int) -> Unit) { + // These entries mimic observed behavior. + // + // MAIN: open HomeActivity directly. + val coldStartEntries = listOf( + listOf( + LogEntry.ActivityCreated(mainActivityClass), + LogEntry.ActivityStarted(mainActivityClass), + LogEntry.AppStarted, + + // VIEW: open non-drawing IntentReceiverActivity, then HomeActivity. + ), + listOf( + LogEntry.ActivityCreated(irActivityClass), + LogEntry.ActivityCreated(mainActivityClass), + LogEntry.ActivityStarted(mainActivityClass), + LogEntry.AppStarted, + ), + ) + + forEachStartEntry(coldStartEntries, block) + } + + private fun forEachWarmStartEntries(block: (index: Int) -> Unit) { + // These entries mimic observed behavior. We test both truncated (i.e. the current behavior + // with the optimization to prevent an infinite log) and untruncated (the behavior without + // such an optimization). + // + // truncated MAIN: open HomeActivity directly. + val warmStartEntries = listOf( + listOf( + LogEntry.AppStopped, + LogEntry.ActivityStopped(mainActivityClass), + LogEntry.ActivityCreated(mainActivityClass), + LogEntry.ActivityStarted(mainActivityClass), + LogEntry.AppStarted, + + // untruncated MAIN: open MainActivity directly. + ), + listOf( + LogEntry.ActivityCreated(mainActivityClass), + LogEntry.ActivityStarted(mainActivityClass), + LogEntry.AppStarted, + LogEntry.AppStopped, + LogEntry.ActivityStopped(mainActivityClass), + LogEntry.ActivityCreated(mainActivityClass), + LogEntry.ActivityStarted(mainActivityClass), + LogEntry.AppStarted, + + // truncated VIEW: open non-drawing IntentReceiverActivity, then MainActivity. + ), + listOf( + LogEntry.AppStopped, + LogEntry.ActivityStopped(mainActivityClass), + LogEntry.ActivityCreated(irActivityClass), + LogEntry.ActivityCreated(mainActivityClass), + LogEntry.ActivityStarted(mainActivityClass), + LogEntry.AppStarted, + + // untruncated VIEW: open non-drawing IntentReceiverActivity, then MainActivity. + ), + listOf( + LogEntry.ActivityCreated(irActivityClass), + LogEntry.ActivityCreated(mainActivityClass), + LogEntry.ActivityStarted(mainActivityClass), + LogEntry.AppStarted, + LogEntry.AppStopped, + LogEntry.ActivityStopped(mainActivityClass), + LogEntry.ActivityCreated(irActivityClass), + LogEntry.ActivityCreated(mainActivityClass), + LogEntry.ActivityStarted(mainActivityClass), + LogEntry.AppStarted, + ), + ) + + forEachStartEntry(warmStartEntries, block) + } + + private fun forEachHotStartEntries(block: (index: Int) -> Unit) { + // These entries mimic observed behavior. We test both truncated (i.e. the current behavior + // with the optimization to prevent an infinite log) and untruncated (the behavior without + // such an optimization). + // + // truncated MAIN: open HomeActivity directly. + val hotStartEntries = listOf( + listOf( + LogEntry.AppStopped, + LogEntry.ActivityStopped(mainActivityClass), + LogEntry.ActivityStarted(mainActivityClass), + LogEntry.AppStarted, + + // untruncated MAIN: open HomeActivity directly. + ), + listOf( + LogEntry.ActivityCreated(mainActivityClass), + LogEntry.ActivityStarted(mainActivityClass), + LogEntry.AppStarted, + LogEntry.AppStopped, + LogEntry.ActivityStopped(mainActivityClass), + LogEntry.ActivityStarted(mainActivityClass), + LogEntry.AppStarted, + + // truncated VIEW: open non-drawing IntentReceiverActivity, then HomeActivity. + ), + listOf( + LogEntry.AppStopped, + LogEntry.ActivityStopped(mainActivityClass), + LogEntry.ActivityCreated(irActivityClass), + LogEntry.ActivityStarted(mainActivityClass), + LogEntry.AppStarted, + + // untruncated VIEW: open non-drawing IntentReceiverActivity, then HomeActivity. + ), + listOf( + LogEntry.ActivityCreated(irActivityClass), + LogEntry.ActivityCreated(mainActivityClass), + LogEntry.ActivityStarted(mainActivityClass), + LogEntry.AppStarted, + LogEntry.AppStopped, + LogEntry.ActivityStopped(mainActivityClass), + LogEntry.ActivityCreated(irActivityClass), + LogEntry.ActivityStarted(mainActivityClass), + LogEntry.AppStarted, + ), + ) + + forEachStartEntry(hotStartEntries, block) + } + + private fun forEachStartEntry(entries: List<List<LogEntry>>, block: (index: Int) -> Unit) { + entries.forEachIndexed { index, startEntry -> + logEntries.clear() + logEntries.addAll(startEntry) + block(index) + } + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/telemetry/StartupTypeTelemetryTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/telemetry/StartupTypeTelemetryTest.kt new file mode 100644 index 0000000000..2d37068eda --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/telemetry/StartupTypeTelemetryTest.kt @@ -0,0 +1,152 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.focus.telemetry + +import androidx.lifecycle.Lifecycle +import kotlinx.coroutines.test.advanceUntilIdle +import mozilla.components.support.ktx.kotlin.crossProduct +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.rule.MainCoroutineRule +import mozilla.components.support.test.rule.runTestOnMain +import mozilla.telemetry.glean.testing.GleanTestRule +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.spy +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mozilla.focus.GleanMetrics.PerfStartup +import org.mozilla.focus.activity.MainActivity +import org.mozilla.focus.telemetry.startuptelemetry.StartupPathProvider +import org.mozilla.focus.telemetry.startuptelemetry.StartupPathProvider.StartupPath +import org.mozilla.focus.telemetry.startuptelemetry.StartupStateProvider +import org.mozilla.focus.telemetry.startuptelemetry.StartupStateProvider.StartupState +import org.mozilla.focus.telemetry.startuptelemetry.StartupTypeTelemetry +import org.mozilla.focus.telemetry.startuptelemetry.StartupTypeTelemetry.StartupTypeLifecycleObserver +import org.robolectric.RobolectricTestRunner + +private val validTelemetryLabels = run { + val allStates = listOf("cold", "warm", "hot", "unknown") + val allPaths = listOf("main", "view", "unknown") + + allStates.crossProduct(allPaths) { state, path -> "${state}_$path" }.toSet() +} + +private val activityClass = MainActivity::class.java + +@RunWith(RobolectricTestRunner::class) +class StartupTypeTelemetryTest { + + private lateinit var telemetry: StartupTypeTelemetry + private lateinit var callbacks: StartupTypeLifecycleObserver + private var stateProvider: StartupStateProvider = mock() + private var pathProvider: StartupPathProvider = mock() + + @get:Rule + val coroutinesTestRule = MainCoroutineRule() + + @get:Rule + val gleanTestRule = GleanTestRule(testContext) + + @Before + fun setUp() { + telemetry = spy(StartupTypeTelemetry(stateProvider, pathProvider)) + callbacks = telemetry.getTestCallbacks() + } + + @Test + fun `WHEN attach is called THEN it is registered to the lifecycle`() { + val lifecycle = mock<Lifecycle>() + + telemetry.attachOnMainActivityOnCreate(lifecycle) + + verify(lifecycle).addObserver(any()) + } + + @Test + fun `GIVEN all possible path and state combinations WHEN record telemetry THEN the labels are incremented the appropriate number of times`() = runTestOnMain { + val allPossibleInputArgs = StartupState.values().toList().crossProduct( + StartupPath.values().toList(), + ) { state, path -> + Pair(state, path) + } + + allPossibleInputArgs.forEach { (state, path) -> + doReturn(state).`when`(stateProvider).getStartupStateForStartedActivity(activityClass) + doReturn(path).`when`(pathProvider).startupPathForActivity + + telemetry.record(coroutinesTestRule.testDispatcher) + advanceUntilIdle() + } + + validTelemetryLabels.forEach { label -> + // Path == NOT_SET gets bucketed with Path == UNKNOWN so we'll increment twice for those. + val expected = if (label.endsWith("unknown")) 2 else 1 + assertEquals("label: $label", expected, PerfStartup.startupType[label].testGetValue()) + } + + // All invalid labels go to a single bucket: let's verify it has no value. + assertNull(PerfStartup.startupType["__other__"].testGetValue()) + } + + @Test + fun `WHEN record is called THEN telemetry is recorded with the appropriate label`() = runTestOnMain { + doReturn(StartupState.COLD).`when`(stateProvider).getStartupStateForStartedActivity(activityClass) + doReturn(StartupPath.MAIN).`when`(pathProvider).startupPathForActivity + + telemetry.record(coroutinesTestRule.testDispatcher) + advanceUntilIdle() + + assertEquals(1, PerfStartup.startupType["cold_main"].testGetValue()) + } + + @Test + fun `GIVEN the activity is launched WHEN onResume is called THEN we record the telemetry`() { + launchApp() + verify(telemetry).record(any()) + } + + @Test + fun `GIVEN the activity is launched WHEN the activity is paused and resumed THEN record is not called`() { + // This part of the test duplicates another test but it's needed to initialize the state of this test. + launchApp() + verify(telemetry).record(any()) + + callbacks.onPause(mock()) + callbacks.onResume(mock()) + + verify(telemetry).record(any()) // i.e. this shouldn't be called again. + } + + @Test + fun `GIVEN the activity is launched WHEN the activity is stopped and resumed THEN record is called again`() { + // This part of the test duplicates another test but it's needed to initialize the state of this test. + launchApp() + verify(telemetry).record(any()) + + callbacks.onPause(mock()) + callbacks.onStop(mock()) + callbacks.onStart(mock()) + callbacks.onResume(mock()) + + verify(telemetry, times(2)).record(any()) + } + + private fun launchApp() { + // What these return isn't important. + doReturn(StartupState.COLD).`when`(stateProvider).getStartupStateForStartedActivity(activityClass) + doReturn(StartupPath.MAIN).`when`(pathProvider).startupPathForActivity + + callbacks.onCreate(mock()) + callbacks.onStart(mock()) + callbacks.onResume(mock()) + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/topsites/DefaultTopSitesStorageTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/topsites/DefaultTopSitesStorageTest.kt new file mode 100644 index 0000000000..27ff6a5378 --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/topsites/DefaultTopSitesStorageTest.kt @@ -0,0 +1,136 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.focus.topsites + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import mozilla.components.feature.top.sites.PinnedSiteStorage +import mozilla.components.feature.top.sites.TopSite +import mozilla.components.support.test.mock +import mozilla.components.support.test.whenever +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.verify + +@ExperimentalCoroutinesApi +class DefaultTopSitesStorageTest { + + private val pinnedSitesStorage: PinnedSiteStorage = mock() + + @Test + fun `WHEN a top site is added THEN the pinned sites storage is called`() = runTest(UnconfinedTestDispatcher()) { + val defaultTopSitesStorage = DefaultTopSitesStorage( + pinnedSitesStorage, + coroutineContext, + ) + + defaultTopSitesStorage.addTopSite("Mozilla", "https://mozilla.com", isDefault = false) + + verify(pinnedSitesStorage).addPinnedSite( + "Mozilla", + "https://mozilla.com", + isDefault = false, + ) + } + + @Test + fun `WHEN a top site is removed THEN the pinned sites storage is called`() = runTest(UnconfinedTestDispatcher()) { + val defaultTopSitesStorage = DefaultTopSitesStorage( + pinnedSitesStorage, + coroutineContext, + ) + + val pinnedSite = TopSite.Pinned( + id = 2, + title = "Firefox", + url = "https://firefox.com", + createdAt = 2, + ) + + defaultTopSitesStorage.removeTopSite(pinnedSite) + + verify(pinnedSitesStorage).removePinnedSite(pinnedSite) + } + + @Test + fun `WHEN a top site is updated THEN the pinned sites storage is called`() = runTest(UnconfinedTestDispatcher()) { + val defaultTopSitesStorage = DefaultTopSitesStorage( + pinnedSitesStorage, + coroutineContext, + ) + + val pinnedSite = TopSite.Pinned( + id = 2, + title = "Wikipedia", + url = "https://wikipedia.com", + createdAt = 2, + ) + defaultTopSitesStorage.updateTopSite( + pinnedSite, + "Wiki", + "https://en.wikipedia.org/wiki/Wiki", + ) + + verify(pinnedSitesStorage).updatePinnedSite( + pinnedSite, + "Wiki", + "https://en.wikipedia.org/wiki/Wiki", + ) + } + + @Test + fun `WHEN getTopSites is called THEN the appropriate top sites are returned`() = runTest { + val defaultTopSitesStorage = DefaultTopSitesStorage( + pinnedSitesStorage, + coroutineContext, + ) + + val pinnedSite1 = TopSite.Pinned( + id = 2, + title = "Wikipedia", + url = "https://wikipedia.com", + createdAt = 2, + ) + val pinnedSite2 = TopSite.Pinned( + id = 3, + title = "Example", + url = "https://example.com", + createdAt = 3, + ) + + whenever(pinnedSitesStorage.getPinnedSites()).thenReturn( + listOf( + pinnedSite1, + pinnedSite2, + ), + ) + + var topSites = defaultTopSitesStorage.getTopSites( + totalSites = 0, + frecencyConfig = null, + ) + + assertTrue(topSites.isEmpty()) + + topSites = defaultTopSitesStorage.getTopSites(totalSites = 1) + + assertEquals(1, topSites.size) + assertEquals(pinnedSite1, topSites[0]) + + topSites = defaultTopSitesStorage.getTopSites(totalSites = 2) + + assertEquals(2, topSites.size) + assertEquals(pinnedSite1, topSites[0]) + assertEquals(pinnedSite2, topSites[1]) + + topSites = defaultTopSitesStorage.getTopSites(totalSites = 5) + + assertEquals(2, topSites.size) + assertEquals(pinnedSite1, topSites[0]) + assertEquals(pinnedSite2, topSites[1]) + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/utils/IntentUtilsTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/utils/IntentUtilsTest.kt new file mode 100644 index 0000000000..dcbfd876a4 --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/utils/IntentUtilsTest.kt @@ -0,0 +1,21 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +package org.mozilla.focus.utils + +import android.app.PendingIntent +import org.junit.Assert.assertEquals +import org.junit.Test + +class IntentUtilsTest { + + @Test + fun `given a build version lower than 23, when defaultIntentPendingFlags is called, then flag 0 should be returned`() { + assertEquals(0, IntentUtils.defaultIntentPendingFlags(22)) + } + + @Test + fun `given a build version bigger than 22, when defaultIntentPendingFlags is called, then flag FLAG_IMMUTABLE should be returned`() { + assertEquals(PendingIntent.FLAG_IMMUTABLE, IntentUtils.defaultIntentPendingFlags(23)) + } +} diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/utils/SupportUtilsTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/utils/SupportUtilsTest.kt new file mode 100644 index 0000000000..fe55d8d4f8 --- /dev/null +++ b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/utils/SupportUtilsTest.kt @@ -0,0 +1,64 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.focus.utils + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.util.Locale + +class SupportUtilsTest { + + @Test + fun cleanup() { + // Other tests might get confused by our locale fiddling, so lets go back to the default: + Locale.setDefault(Locale.ENGLISH) + } + + /* + * Super simple sumo URL test - it exists primarily to verify that we're setting the language + * and page tags correctly. appVersion is null in tests, so we just test that there's a null there, + * which doesn't seem too useful... + */ + @Test + @Throws(Exception::class) + fun getSumoURLForTopic() { + val versionName = "testVersion" + + val testTopic = SupportUtils.SumoTopic.TRACKERS + val testTopicStr = testTopic.topicStr + + Locale.setDefault(Locale.GERMANY) + assertEquals( + "https://support.mozilla.org/1/mobile/$versionName/Android/de-DE/$testTopicStr", + SupportUtils.getSumoURLForTopic(versionName, testTopic), + ) + + Locale.setDefault(Locale.CANADA_FRENCH) + assertEquals( + "https://support.mozilla.org/1/mobile/$versionName/Android/fr-CA/$testTopicStr", + SupportUtils.getSumoURLForTopic(versionName, testTopic), + ) + } + + /** + * This is a pretty boring tests - it exists primarily to verify that we're actually setting + * a langtag in the manfiesto URL. + */ + @Test + @Throws(Exception::class) + fun getManifestoURL() { + Locale.setDefault(Locale.UK) + assertEquals( + "https://www.mozilla.org/en-GB/about/manifesto/", + SupportUtils.manifestoURL, + ) + + Locale.setDefault(Locale.KOREA) + assertEquals( + "https://www.mozilla.org/ko-KR/about/manifesto/", + SupportUtils.manifestoURL, + ) + } +} diff --git a/mobile/android/focus-android/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/focus-android/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000000..cf1c399ea8 --- /dev/null +++ b/mobile/android/focus-android/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1,2 @@ +mock-maker-inline +// This allows mocking final classes (classes are final by default in Kotlin) diff --git a/mobile/android/focus-android/app/src/test/resources/robolectric.properties b/mobile/android/focus-android/app/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..4359826c57 --- /dev/null +++ b/mobile/android/focus-android/app/src/test/resources/robolectric.properties @@ -0,0 +1,3 @@ +# Needed until Robolectric supports SDK 29+ +sdk=28 +application=org.mozilla.focus.EmptyFocusApplication |