diff --git a/src/components/TestToolMenu.tsx b/src/components/TestToolMenu.tsx index e81405d026b4..89f3fbc528ef 100644 --- a/src/components/TestToolMenu.tsx +++ b/src/components/TestToolMenu.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -9,18 +8,13 @@ import * as Session from '@userActions/Session'; import * as User from '@userActions/User'; import CONFIG from '@src/CONFIG'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Network as NetworkOnyx, User as UserOnyx} from '@src/types/onyx'; +import type {User as UserOnyx} from '@src/types/onyx'; import Button from './Button'; -import {withNetwork} from './OnyxProvider'; import Switch from './Switch'; import TestCrash from './TestCrash'; import TestToolRow from './TestToolRow'; import Text from './Text'; -type TestToolMenuProps = { - /** Network object in Onyx */ - network: OnyxEntry; -}; const USER_DEFAULT: UserOnyx = { shouldUseStagingServer: undefined, isSubscribedToNewsletter: false, @@ -30,7 +24,8 @@ const USER_DEFAULT: UserOnyx = { isDebugModeEnabled: false, }; -function TestToolMenu({network}: TestToolMenuProps) { +function TestToolMenu() { + const [network] = useOnyx(ONYXKEYS.NETWORK); const [user = USER_DEFAULT] = useOnyx(ONYXKEYS.USER); const [isUsingImportedState] = useOnyx(ONYXKEYS.IS_USING_IMPORTED_STATE); const shouldUseStagingServer = user?.shouldUseStagingServer ?? ApiUtils.isUsingStagingApi(); @@ -74,7 +69,17 @@ function TestToolMenu({network}: TestToolMenuProps) { accessibilityLabel="Force offline" isOn={!!network?.shouldForceOffline} onToggle={() => Network.setShouldForceOffline(!network?.shouldForceOffline)} - disabled={isUsingImportedState} + disabled={!!isUsingImportedState || !!network?.shouldSimulatePoorConnection || network?.shouldFailAllRequests} + /> + + + {/* When toggled the app will randomly change internet connection every 2-5 seconds */} + + Network.setShouldSimulatePoorConnection(!network?.shouldSimulatePoorConnection, network?.poorConnectionTimeoutID)} + disabled={!!isUsingImportedState || !!network?.shouldFailAllRequests || network?.shouldForceOffline} /> @@ -84,6 +89,7 @@ function TestToolMenu({network}: TestToolMenuProps) { accessibilityLabel="Simulate failing network requests" isOn={!!network?.shouldFailAllRequests} onToggle={() => Network.setShouldFailAllRequests(!network?.shouldFailAllRequests)} + disabled={!!network?.shouldForceOffline || network?.shouldSimulatePoorConnection} /> @@ -112,4 +118,4 @@ function TestToolMenu({network}: TestToolMenuProps) { TestToolMenu.displayName = 'TestToolMenu'; -export default withNetwork()(TestToolMenu); +export default TestToolMenu; diff --git a/src/languages/en.ts b/src/languages/en.ts index 375d478a8d6d..c61ef58f8eff 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1229,6 +1229,7 @@ const translations = { testingPreferences: 'Testing preferences', useStagingServer: 'Use Staging Server', forceOffline: 'Force offline', + simulatePoorConnection: 'Simulate poor internet connection', simulatFailingNetworkRequests: 'Simulate failing network requests', authenticationStatus: 'Authentication status', deviceCredentials: 'Device credentials', diff --git a/src/languages/es.ts b/src/languages/es.ts index bd2dd6fae4d4..5220acf3b9d9 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1228,6 +1228,7 @@ const translations = { testingPreferences: 'Preferencias para Tests', useStagingServer: 'Usar servidor “staging”', forceOffline: 'Forzar desconexión', + simulatePoorConnection: 'Simular una conexión a internet deficiente', simulatFailingNetworkRequests: 'Simular fallos en solicitudes de red', authenticationStatus: 'Estado de autenticación', deviceCredentials: 'Credenciales del dispositivo', diff --git a/src/libs/NetworkConnection.ts b/src/libs/NetworkConnection.ts index cb9faae31ddd..b91190e4ee81 100644 --- a/src/libs/NetworkConnection.ts +++ b/src/libs/NetworkConnection.ts @@ -1,4 +1,5 @@ import NetInfo from '@react-native-community/netinfo'; +import {differenceInHours} from 'date-fns/differenceInHours'; import isBoolean from 'lodash/isBoolean'; import throttle from 'lodash/throttle'; import Onyx from 'react-native-onyx'; @@ -6,6 +7,8 @@ import type {ValueOf} from 'type-fest'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type Network from '@src/types/onyx/Network'; +import type {ConnectionChanges} from '@src/types/onyx/Network'; import * as NetworkActions from './actions/Network'; import AppStateMonitor from './AppStateMonitor'; import Log from './Log'; @@ -51,6 +54,7 @@ const triggerReconnectionCallbacks = throttle( * then all of the reconnection callbacks are triggered */ function setOfflineStatus(isCurrentlyOffline: boolean, reason = ''): void { + trackConnectionChanges(); NetworkActions.setIsOffline(isCurrentlyOffline, reason); // When reconnecting, ie, going from offline to online, all the reconnection callbacks @@ -64,12 +68,20 @@ function setOfflineStatus(isCurrentlyOffline: boolean, reason = ''): void { // Update the offline status in response to changes in shouldForceOffline let shouldForceOffline = false; +let isPoorConnectionSimulated: boolean | undefined; +let connectionChanges: ConnectionChanges | undefined; Onyx.connect({ key: ONYXKEYS.NETWORK, callback: (network) => { if (!network) { return; } + + simulatePoorConnection(network); + + isPoorConnectionSimulated = !!network.shouldSimulatePoorConnection; + connectionChanges = network.connectionChanges; + const currentShouldForceOffline = !!network.shouldForceOffline; if (currentShouldForceOffline === shouldForceOffline) { return; @@ -104,6 +116,69 @@ Onyx.connect({ }, }); +function simulatePoorConnection(network: Network) { + // Starts random network status change when shouldSimulatePoorConnection is turned into true + // or after app restart if shouldSimulatePoorConnection is true already + if (!isPoorConnectionSimulated && !!network.shouldSimulatePoorConnection) { + clearTimeout(network.poorConnectionTimeoutID); + setRandomNetworkStatus(true); + } + + // Fetch the NetInfo state to set the correct offline status when shouldSimulatePoorConnection is turned into false + if (isPoorConnectionSimulated && !network.shouldSimulatePoorConnection) { + NetInfo.fetch().then((state) => { + const isInternetUnreachable = !state.isInternetReachable; + const stringifiedState = JSON.stringify(state); + setOfflineStatus(isInternetUnreachable || !isServerUp, 'NetInfo checked if the internet is reachable'); + Log.info( + `[NetworkStatus] The poor connection simulation mode was turned off. Getting the device network status from NetInfo. Network state: ${stringifiedState}. Setting the offline status to: ${isInternetUnreachable}.`, + ); + }); + } +} + +/** Sets online/offline connection randomly every 2-5 seconds */ +function setRandomNetworkStatus(initialCall = false) { + // The check to ensure no new timeouts are scheduled after poor connection simulation is stopped + if (!isPoorConnectionSimulated && !initialCall) { + return; + } + + const statuses = [CONST.NETWORK.NETWORK_STATUS.OFFLINE, CONST.NETWORK.NETWORK_STATUS.ONLINE]; + const randomStatus = statuses[Math.floor(Math.random() * statuses.length)]; + const randomInterval = Math.random() * (5000 - 2000) + 2000; // random interval between 2-5 seconds + Log.info(`[NetworkConnection] Set connection status "${randomStatus}" for ${randomInterval} sec`); + + setOfflineStatus(randomStatus === CONST.NETWORK.NETWORK_STATUS.OFFLINE); + + const timeoutID = setTimeout(setRandomNetworkStatus, randomInterval); + NetworkActions.setPoorConnectionTimeoutID(timeoutID); +} + +/** Tracks how many times the connection has changed within the time period */ +function trackConnectionChanges() { + if (!connectionChanges?.startTime) { + NetworkActions.setConnectionChanges({startTime: new Date().getTime(), amount: 1}); + return; + } + + const diffInHours = differenceInHours(new Date(), connectionChanges.startTime); + const newAmount = (connectionChanges.amount ?? 0) + 1; + + if (diffInHours < 1) { + NetworkActions.setConnectionChanges({amount: newAmount}); + return; + } + + Log.info( + `[NetworkConnection] Connection has changed ${newAmount} time(s) for the last ${diffInHours} hour(s). Poor connection simulation is turned ${ + isPoorConnectionSimulated ? 'on' : 'off' + }`, + ); + + NetworkActions.setConnectionChanges({startTime: new Date().getTime(), amount: 0}); +} + /** * Set up the event listener for NetInfo to tell whether the user has * internet connectivity or not. This is more reliable than the Pusher diff --git a/src/libs/actions/Network.ts b/src/libs/actions/Network.ts index d8a87aff551d..f2228a008dad 100644 --- a/src/libs/actions/Network.ts +++ b/src/libs/actions/Network.ts @@ -2,6 +2,7 @@ import Onyx from 'react-native-onyx'; import Log from '@libs/Log'; import type {NetworkStatus} from '@libs/NetworkConnection'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {ConnectionChanges} from '@src/types/onyx/Network'; function setIsOffline(isOffline: boolean, reason = '') { if (reason) { @@ -32,4 +33,21 @@ function setShouldFailAllRequests(shouldFailAllRequests: boolean) { Onyx.merge(ONYXKEYS.NETWORK, {shouldFailAllRequests}); } -export {setIsOffline, setShouldForceOffline, setShouldFailAllRequests, setTimeSkew, setNetWorkStatus}; +function setPoorConnectionTimeoutID(poorConnectionTimeoutID: NodeJS.Timeout | undefined) { + Onyx.merge(ONYXKEYS.NETWORK, {poorConnectionTimeoutID}); +} + +function setShouldSimulatePoorConnection(shouldSimulatePoorConnection: boolean, poorConnectionTimeoutID: NodeJS.Timeout | undefined) { + if (!shouldSimulatePoorConnection) { + clearTimeout(poorConnectionTimeoutID); + Onyx.merge(ONYXKEYS.NETWORK, {shouldSimulatePoorConnection, poorConnectionTimeoutID: undefined}); + return; + } + Onyx.merge(ONYXKEYS.NETWORK, {shouldSimulatePoorConnection}); +} + +function setConnectionChanges(connectionChanges: ConnectionChanges) { + Onyx.merge(ONYXKEYS.NETWORK, {connectionChanges}); +} + +export {setIsOffline, setShouldForceOffline, setConnectionChanges, setShouldSimulatePoorConnection, setPoorConnectionTimeoutID, setShouldFailAllRequests, setTimeSkew, setNetWorkStatus}; diff --git a/src/types/onyx/Network.ts b/src/types/onyx/Network.ts index 680c6c468c00..74fb1202a8a2 100644 --- a/src/types/onyx/Network.ts +++ b/src/types/onyx/Network.ts @@ -1,5 +1,14 @@ import type {NetworkStatus} from '@libs/NetworkConnection'; +/** The value where connection changes are tracked */ +type ConnectionChanges = { + /** Amount of connection changes */ + amount?: number; + + /** Start time in milliseconds */ + startTime?: number; +}; + /** Model of network state */ type Network = { /** Is the network currently offline or not */ @@ -8,6 +17,15 @@ type Network = { /** Should the network be forced offline */ shouldForceOffline?: boolean; + /** Whether we should simulate poor connection */ + shouldSimulatePoorConnection?: boolean; + + /** Poor connection timeout id */ + poorConnectionTimeoutID?: NodeJS.Timeout; + + /** The value where connection changes are tracked */ + connectionChanges?: ConnectionChanges; + /** Whether we should fail all network requests */ shouldFailAllRequests?: boolean; @@ -19,3 +37,4 @@ type Network = { }; export default Network; +export type {ConnectionChanges}; diff --git a/tests/unit/NetworkTest.ts b/tests/unit/NetworkTest.tsx similarity index 89% rename from tests/unit/NetworkTest.ts rename to tests/unit/NetworkTest.tsx index 2998aa0e8a25..abd5011769a4 100644 --- a/tests/unit/NetworkTest.ts +++ b/tests/unit/NetworkTest.tsx @@ -1,9 +1,13 @@ +import {render, screen} from '@testing-library/react-native'; +import {sub as dateSubtract} from 'date-fns/sub'; import type {Mock} from 'jest-mock'; import type {OnyxEntry} from 'react-native-onyx'; import MockedOnyx from 'react-native-onyx'; +import TestToolMenu from '@components/TestToolMenu'; import * as App from '@libs/actions/App'; import {resetReauthentication} from '@libs/Middleware/Reauthentication'; import CONST from '@src/CONST'; +import * as NetworkActions from '@src/libs/actions/Network'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; import * as PersistedRequests from '@src/libs/actions/PersistedRequests'; import * as PersonalDetails from '@src/libs/actions/PersonalDetails'; @@ -391,4 +395,36 @@ describe('NetworkTests', () => { expect(xhr.mock.calls.length).toBe(3); }); }); + + test('poor connection simulation', async () => { + const logSpy = jest.spyOn(Log, 'info'); + + // Given an opened test tool menu + render(); + expect(screen.getByAccessibilityHint('Force offline')).not.toBeDisabled(); + expect(screen.getByAccessibilityHint('Simulate failing network requests')).not.toBeDisabled(); + + // When the connection simulation is turned on + NetworkActions.setShouldSimulatePoorConnection(true, undefined); + await waitForBatchedUpdates(); + + // Then the connection status change log should be displayed as well as Force offline/Simulate failing network requests toggles should be disabled + expect(logSpy).toHaveBeenCalledWith(expect.stringMatching(/\[NetworkConnection\] Set connection status "(online|offline)" for (\d+(?:\.\d+)?) sec/)); + expect(screen.getByAccessibilityHint('Force offline')).toBeDisabled(); + expect(screen.getByAccessibilityHint('Simulate failing network requests')).toBeDisabled(); + }); + + test('connection changes tracking', async () => { + const logSpy = jest.spyOn(Log, 'info'); + + // Given tracked connection changes started at least an hour ago + Onyx.merge(ONYXKEYS.NETWORK, {connectionChanges: {amount: 5, startTime: dateSubtract(new Date(), {hours: 1}).getTime()}}); + await waitForBatchedUpdates(); + + // When the connection is changed one more time + NetworkConnection.setOfflineStatus(true); + + // Then the log with information about connection changes since the start time should be shown + expect(logSpy).toHaveBeenCalledWith('[NetworkConnection] Connection has changed 6 time(s) for the last 1 hour(s). Poor connection simulation is turned off'); + }); });