feat(MM-65625): low connectivity feature toggle in advance settings (#9191)
This commit is contained in:
parent
0b7820c2b9
commit
c872e2e2b1
23 changed files with 1294 additions and 158 deletions
|
|
@ -2,6 +2,7 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Tutorial} from '@constants';
|
||||
import {GLOBAL_IDENTIFIERS} from '@constants/database';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {
|
||||
getDeviceToken,
|
||||
|
|
@ -33,6 +34,7 @@ import {
|
|||
removePushDisabledInServerAcknowledged,
|
||||
storeScheduledPostTutorial,
|
||||
storeScheduledPostsListTutorial,
|
||||
storeLowConnectivityMonitor,
|
||||
} from './global';
|
||||
|
||||
const serverUrl = 'server.test.com';
|
||||
|
|
@ -203,4 +205,17 @@ describe('/app/actions/app/global', () => {
|
|||
records = await queryGlobalValue(Tutorial.SCHEDULED_POSTS_LIST)?.fetch();
|
||||
expect(records?.[0]?.value).toBe(true);
|
||||
});
|
||||
|
||||
test('storeLowConnectivityMonitor', async () => {
|
||||
let records = await queryGlobalValue(GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR)?.fetch();
|
||||
expect(records?.[0]?.value).toBeUndefined();
|
||||
|
||||
await storeLowConnectivityMonitor(true);
|
||||
records = await queryGlobalValue(GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR)?.fetch();
|
||||
expect(records?.[0]?.value).toBe(true);
|
||||
|
||||
await storeLowConnectivityMonitor(false);
|
||||
records = await queryGlobalValue(GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR)?.fetch();
|
||||
expect(records?.[0]?.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -97,3 +97,7 @@ export const storePushDisabledInServerAcknowledged = async (serverUrl: string) =
|
|||
export const removePushDisabledInServerAcknowledged = async (serverUrl: string) => {
|
||||
return storeGlobal(`${GLOBAL_IDENTIFIERS.PUSH_DISABLED_ACK}${serverUrl}`, null, false);
|
||||
};
|
||||
|
||||
export const storeLowConnectivityMonitor = async (enabled: boolean) => {
|
||||
return storeGlobal(GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR, enabled, false);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -31,7 +31,13 @@ jest.mock('@actions/remote/user');
|
|||
jest.mock('@actions/remote/post');
|
||||
jest.mock('@actions/remote/groups');
|
||||
jest.mock('@actions/remote/thread');
|
||||
jest.mock('@queries/app/global');
|
||||
jest.mock('@queries/app/global', () => {
|
||||
const {of: mockOf} = require('rxjs');
|
||||
return {
|
||||
getDeviceToken: jest.fn(),
|
||||
observeLowConnectivityMonitor: jest.fn(() => mockOf(true)),
|
||||
};
|
||||
});
|
||||
jest.mock('@queries/servers/system');
|
||||
jest.mock('@queries/servers/entry');
|
||||
jest.mock('@queries/servers/system', () => {
|
||||
|
|
|
|||
|
|
@ -920,16 +920,11 @@ describe('ClientTracking', () => {
|
|||
groupLabel: 'Cold Start' as RequestGroupLabel,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
LocalConfig.MonitorNetworkPerformance = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
LocalConfig.MonitorNetworkPerformance = false;
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should call full tracking lifecycle when MonitorNetworkPerformance is enabled', async () => {
|
||||
it('should call full tracking lifecycle', async () => {
|
||||
const mockMetrics = createMockMetrics();
|
||||
apiClientMock.get.mockResolvedValue(mockSuccessResponse(mockMetrics));
|
||||
|
||||
|
|
@ -939,16 +934,6 @@ describe('ClientTracking', () => {
|
|||
expect(mockedNPM.completeRequestTracking).toHaveBeenCalledWith('https://example.com', 'mock-request-id-123', mockMetrics);
|
||||
});
|
||||
|
||||
it('should not call tracking methods when MonitorNetworkPerformance is disabled', async () => {
|
||||
LocalConfig.MonitorNetworkPerformance = false;
|
||||
apiClientMock.get.mockResolvedValue(mockSuccessResponse());
|
||||
|
||||
await client.doFetchWithTracking('https://example.com/api', requestOptions);
|
||||
|
||||
expect(mockedNPM.startRequestTracking).not.toHaveBeenCalled();
|
||||
expect(mockedNPM.completeRequestTracking).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call completeRequestTracking when response.metrics is undefined', async () => {
|
||||
apiClientMock.get.mockResolvedValue({
|
||||
ok: true,
|
||||
|
|
@ -1008,17 +993,6 @@ describe('ClientTracking', () => {
|
|||
expect(mockedNPM.cancelRequestTracking).toHaveBeenCalledWith('https://example.com', 'mock-request-id-123');
|
||||
expect(mockedNPM.completeRequestTracking).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call cancelRequestTracking when MonitorNetworkPerformance is disabled and request fails', async () => {
|
||||
LocalConfig.MonitorNetworkPerformance = false;
|
||||
apiClientMock.get.mockRejectedValue(new Error('Request failed'));
|
||||
|
||||
await expect(client.doFetchWithTracking('https://example.com/api', requestOptions)).rejects.toThrow('Received invalid response from the server.');
|
||||
|
||||
expect(mockedNPM.startRequestTracking).not.toHaveBeenCalled();
|
||||
expect(mockedNPM.cancelRequestTracking).not.toHaveBeenCalled();
|
||||
expect(mockedNPM.completeRequestTracking).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
/* eslint-enable max-lines */
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
import {defineMessage} from 'react-intl';
|
||||
import {DeviceEventEmitter, Platform} from 'react-native';
|
||||
|
||||
import {CollectNetworkMetrics, MonitorNetworkPerformance} from '@assets/config.json';
|
||||
import {CollectNetworkMetrics} from '@assets/config.json';
|
||||
import {Events} from '@constants';
|
||||
import {setServerCredentials} from '@init/credentials';
|
||||
import NetworkPerformanceManager from '@managers/network_performance_manager';
|
||||
|
|
@ -133,21 +133,18 @@ export default class ClientTracking {
|
|||
}
|
||||
|
||||
startNetworkPerformanceTracking(url: string): string | undefined {
|
||||
if (!MonitorNetworkPerformance) {
|
||||
return undefined;
|
||||
}
|
||||
return NetworkPerformanceManager.startRequestTracking(this.apiClient.baseUrl, url);
|
||||
}
|
||||
|
||||
completeNetworkPerformanceTracking(requestId: string | undefined, url: string, metrics: ClientResponseMetrics | undefined) {
|
||||
if (!MonitorNetworkPerformance || !requestId || !metrics) {
|
||||
if (!requestId || !metrics) {
|
||||
return;
|
||||
}
|
||||
NetworkPerformanceManager.completeRequestTracking(this.apiClient.baseUrl, requestId, metrics);
|
||||
}
|
||||
|
||||
cancelNetworkPerformanceTracking(requestId: string | undefined) {
|
||||
if (!MonitorNetworkPerformance || !requestId) {
|
||||
if (!requestId) {
|
||||
return;
|
||||
}
|
||||
NetworkPerformanceManager.cancelRequestTracking(this.apiClient.baseUrl, requestId);
|
||||
|
|
|
|||
|
|
@ -48,15 +48,17 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
|||
return {
|
||||
actionContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-end',
|
||||
marginTop: 2,
|
||||
},
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
alignItems: 'flex-start',
|
||||
minHeight: ITEM_HEIGHT,
|
||||
gap: 12,
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 12,
|
||||
},
|
||||
destructive: {
|
||||
color: theme.dndIndicator,
|
||||
|
|
|
|||
|
|
@ -84,9 +84,10 @@ export const GLOBAL_IDENTIFIERS = {
|
|||
DONT_ASK_FOR_REVIEW: 'dontAskForReview',
|
||||
FIRST_LAUNCH: 'firstLaunch',
|
||||
LAST_ASK_FOR_REVIEW: 'lastAskForReview',
|
||||
ONBOARDING: 'onboarding',
|
||||
LAST_VIEWED_CHANNEL: 'lastViewedChannel',
|
||||
LAST_VIEWED_THREAD: 'lastViewedThread',
|
||||
LOW_CONNECTIVITY_MONITOR: 'lowConnectivityMonitor',
|
||||
ONBOARDING: 'onboarding',
|
||||
PUSH_DISABLED_ACK: 'pushDisabledAck',
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,15 @@ jest.mock('@database/manager', () => ({
|
|||
serverDatabases: {},
|
||||
}));
|
||||
jest.mock('@init/credentials');
|
||||
jest.mock('@queries/app/global');
|
||||
jest.mock('@queries/app/global', () => {
|
||||
const {of: mockOf} = require('rxjs');
|
||||
return {
|
||||
getLastViewedChannelIdAndServer: jest.fn(),
|
||||
getOnboardingViewed: jest.fn(),
|
||||
getLastViewedThreadIdAndServer: jest.fn(),
|
||||
observeLowConnectivityMonitor: jest.fn(() => mockOf(true)),
|
||||
};
|
||||
});
|
||||
jest.mock('@queries/app/servers');
|
||||
jest.mock('@queries/servers/post');
|
||||
jest.mock('@queries/servers/preference');
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ describe('NetworkConnectivityManager', () => {
|
|||
expect((manager as any).netInfo).toBeNull();
|
||||
expect((manager as any).appState).toBeNull();
|
||||
expect((manager as any).currentPerformanceState).toBeNull();
|
||||
expect((manager as any).performanceSuppressedUntilNormal).toBe(false);
|
||||
expect((manager as any).suppressSlowPerformanceBanner).toBe(false);
|
||||
expect((manager as any).isOnAppStart).toBe(true);
|
||||
});
|
||||
|
||||
|
|
@ -560,16 +560,16 @@ describe('NetworkConnectivityManager', () => {
|
|||
expect(mockBannerManager.showBanner).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reset performance suppression when performance returns to normal after manual dismiss', () => {
|
||||
(manager as any).performanceSuppressedUntilNormal = true;
|
||||
expect((manager as any).performanceSuppressedUntilNormal).toBe(true);
|
||||
it('should reset suppression when reset is called', () => {
|
||||
(manager as any).suppressSlowPerformanceBanner = true;
|
||||
expect((manager as any).suppressSlowPerformanceBanner).toBe(true);
|
||||
|
||||
manager.updatePerformanceState('normal');
|
||||
manager.reset();
|
||||
|
||||
expect((manager as any).performanceSuppressedUntilNormal).toBe(false);
|
||||
expect((manager as any).suppressSlowPerformanceBanner).toBe(false);
|
||||
});
|
||||
|
||||
it('should show reconnection banner when performance returns to normal', () => {
|
||||
it('should hide banner when performance returns to normal', () => {
|
||||
setupConnectedState(manager);
|
||||
|
||||
manager.updatePerformanceState('slow');
|
||||
|
|
@ -580,8 +580,8 @@ describe('NetworkConnectivityManager', () => {
|
|||
mockBannerManager.showBannerWithAutoHide.mockClear();
|
||||
|
||||
manager.updatePerformanceState('normal');
|
||||
const reconnectionBannerCall = mockBannerManager.showBannerWithAutoHide.mock.calls[0][0];
|
||||
expect((reconnectionBannerCall.customComponent as any).props.message).toBe('Connection restored');
|
||||
expect(mockBannerManager.hideBanner).toHaveBeenCalled();
|
||||
expect(mockBannerManager.showBannerWithAutoHide).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not hide banner if it is not a performance banner', () => {
|
||||
|
|
|
|||
|
|
@ -61,9 +61,9 @@ function shouldShowConnectingBanner(
|
|||
|
||||
function shouldShowPerformanceBanner(
|
||||
performanceState: NetworkPerformanceState | null,
|
||||
performanceSuppressed: boolean,
|
||||
suppressPerformanceBanner: boolean,
|
||||
): boolean {
|
||||
return performanceState === 'slow' && !performanceSuppressed;
|
||||
return performanceState === 'slow' && !suppressPerformanceBanner;
|
||||
}
|
||||
|
||||
function shouldShowReconnectionBanner(
|
||||
|
|
@ -84,8 +84,7 @@ class NetworkConnectivityManagerSingleton {
|
|||
private appState: string | null = null;
|
||||
private readonly intl = getIntlShape();
|
||||
private currentPerformanceState: NetworkPerformanceState | null = null;
|
||||
|
||||
private performanceSuppressedUntilNormal = false;
|
||||
private suppressSlowPerformanceBanner = false;
|
||||
|
||||
private getConnectionMessage(): string {
|
||||
if (!this.websocketState || !this.netInfo) {
|
||||
|
|
@ -137,7 +136,7 @@ class NetworkConnectivityManagerSingleton {
|
|||
id: NETWORK_STATUS_BANNER_ID,
|
||||
dismissible: true,
|
||||
onDismiss: () => {
|
||||
this.performanceSuppressedUntilNormal = true;
|
||||
this.suppressSlowPerformanceBanner = true;
|
||||
},
|
||||
customComponent: React.createElement(ConnectionBanner, {
|
||||
isConnected: false,
|
||||
|
|
@ -186,19 +185,32 @@ class NetworkConnectivityManagerSingleton {
|
|||
updatePerformanceState(
|
||||
performanceState: NetworkPerformanceState,
|
||||
) {
|
||||
const wasSlowPerformance = this.currentPerformanceState === 'slow';
|
||||
this.currentPerformanceState = performanceState;
|
||||
|
||||
if (this.performanceSuppressedUntilNormal && performanceState === 'normal') {
|
||||
this.performanceSuppressedUntilNormal = false;
|
||||
}
|
||||
// Performance state changes require special handling to avoid showing reconnection banners.
|
||||
// We can't always call updateBanner() like updateState() does, because it would re-evaluate
|
||||
// all banner conditions (including reconnection) when only performance has changed.
|
||||
|
||||
this.updateBanner();
|
||||
// Show slow performance banner (suppression check here prevents unnecessary updateBanner calls.
|
||||
// suppressSlowPerformanceBanner also checks suppression for calls coming from updateState).
|
||||
|
||||
if (performanceState === 'slow' && !this.suppressSlowPerformanceBanner) {
|
||||
this.updateBanner();
|
||||
} else if (wasSlowPerformance && performanceState === 'normal') {
|
||||
BannerManager.hideBanner(NETWORK_STATUS_BANNER_ID);
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.suppressSlowPerformanceBanner = false;
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
BannerManager.cleanup();
|
||||
this.previousWebsocketState = null;
|
||||
this.isOnAppStart = true;
|
||||
this.reset();
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
|
|
@ -208,7 +220,6 @@ class NetworkConnectivityManagerSingleton {
|
|||
this.netInfo = null;
|
||||
this.appState = null;
|
||||
this.currentPerformanceState = null;
|
||||
this.performanceSuppressedUntilNormal = false;
|
||||
}
|
||||
|
||||
private updateBanner() {
|
||||
|
|
@ -258,7 +269,7 @@ class NetworkConnectivityManagerSingleton {
|
|||
}
|
||||
|
||||
private handlePerformanceState(): boolean {
|
||||
if (shouldShowPerformanceBanner(this.currentPerformanceState, this.performanceSuppressedUntilNormal)) {
|
||||
if (shouldShowPerformanceBanner(this.currentPerformanceState, this.suppressSlowPerformanceBanner)) {
|
||||
this.showPerformance(PERFORMANCE_BANNER_DURATION);
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ jest.mock('./network_connectivity_manager', () => ({
|
|||
updateState: jest.fn(),
|
||||
updatePerformanceState: jest.fn(),
|
||||
shutdown: jest.fn(),
|
||||
reset: jest.fn(),
|
||||
}));
|
||||
jest.mock('./websocket_manager', () => ({
|
||||
observeWebsocketState: jest.fn(),
|
||||
|
|
@ -282,6 +283,32 @@ describe('NetworkConnectivitySubscriptionManager', () => {
|
|||
expect(mockWebsocketManager.observeWebsocketState).toHaveBeenCalledWith('https://test.com');
|
||||
});
|
||||
|
||||
it('should call updateState when websocket state changes', async () => {
|
||||
let websocketCallback: ((state: string) => void) | undefined;
|
||||
mockWebsocketManager.observeWebsocketState.mockReturnValue({
|
||||
subscribe: jest.fn((callback: (state: string) => void) => {
|
||||
websocketCallback = callback;
|
||||
return mockWebsocketSubscription;
|
||||
}),
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
const mockServers: MockServer[] = [{url: 'https://test.com', lastActiveAt: 1000}];
|
||||
|
||||
startNetworkConnectivitySubscriptions();
|
||||
|
||||
const serversCallback = mockSubscribeActiveServers.mock.calls[0][0];
|
||||
await serversCallback(mockServers as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
expect(websocketCallback).toBeDefined();
|
||||
websocketCallback!('connected');
|
||||
|
||||
expect(mockNetworkConnectivityManager.updateState).toHaveBeenCalledWith(
|
||||
'connected',
|
||||
{isInternetReachable: null},
|
||||
'active',
|
||||
);
|
||||
});
|
||||
|
||||
it('should unsubscribe from previous websocket subscription when servers change', async () => {
|
||||
const mockServers1: MockServer[] = [{url: 'https://server1.com', lastActiveAt: 1000}];
|
||||
const mockServers2: MockServer[] = [{url: 'https://server2.com', lastActiveAt: 2000}];
|
||||
|
|
@ -299,6 +326,115 @@ describe('NetworkConnectivitySubscriptionManager', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('performance subscription handling', () => {
|
||||
it('should subscribe to performance state changes', async () => {
|
||||
const mockServers: MockServer[] = [{url: 'https://test.com', lastActiveAt: 1000}];
|
||||
|
||||
startNetworkConnectivitySubscriptions();
|
||||
|
||||
const serversCallback = mockSubscribeActiveServers.mock.calls[0][0];
|
||||
await serversCallback(mockServers as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
expect(mockNetworkPerformanceManager.observePerformanceState).toHaveBeenCalledWith('https://test.com');
|
||||
});
|
||||
|
||||
it('should call updatePerformanceState when performance state changes', async () => {
|
||||
let performanceCallback: ((state: string) => void) | undefined;
|
||||
mockNetworkPerformanceManager.observePerformanceState.mockReturnValue({
|
||||
subscribe: jest.fn((callback: (state: string) => void) => {
|
||||
performanceCallback = callback;
|
||||
return mockPerformanceSubscription;
|
||||
}),
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
const mockServers: MockServer[] = [{url: 'https://test.com', lastActiveAt: 1000}];
|
||||
|
||||
startNetworkConnectivitySubscriptions();
|
||||
|
||||
const serversCallback = mockSubscribeActiveServers.mock.calls[0][0];
|
||||
await serversCallback(mockServers as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
expect(performanceCallback).toBeDefined();
|
||||
performanceCallback!('slow');
|
||||
|
||||
expect(mockNetworkConnectivityManager.updatePerformanceState).toHaveBeenCalledWith('slow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should find most recent server when first server is more recent', async () => {
|
||||
const mockServers: MockServer[] = [
|
||||
{url: 'https://server1.com', lastActiveAt: 2000},
|
||||
{url: 'https://server2.com', lastActiveAt: 1000},
|
||||
];
|
||||
|
||||
startNetworkConnectivitySubscriptions();
|
||||
|
||||
const serversCallback = mockSubscribeActiveServers.mock.calls[0][0];
|
||||
await serversCallback(mockServers as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
expect(mockNetworkConnectivityManager.setServerConnectionStatus).toHaveBeenCalledWith(true, 'https://server1.com');
|
||||
expect(mockWebsocketManager.observeWebsocketState).toHaveBeenCalledWith('https://server1.com');
|
||||
});
|
||||
|
||||
it('should reinitialize when app becomes active after subscriptions were cleared', () => {
|
||||
const mockAppStateListener = {remove: jest.fn()};
|
||||
const mockNetInfoUnsubscriber = jest.fn();
|
||||
const mockActiveServersUnsubscriber = {unsubscribe: jest.fn()};
|
||||
|
||||
mockAppState.addEventListener.mockReturnValue(mockAppStateListener as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
mockNetInfo.addEventListener.mockReturnValue(mockNetInfoUnsubscriber);
|
||||
mockSubscribeActiveServers.mockReturnValue(mockActiveServersUnsubscriber as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
startNetworkConnectivitySubscriptions();
|
||||
|
||||
const appStateCallback = mockAppState.addEventListener.mock.calls[0][1];
|
||||
|
||||
mockNetInfo.addEventListener.mockClear();
|
||||
mockSubscribeActiveServers.mockClear();
|
||||
|
||||
appStateCallback('background');
|
||||
|
||||
expect(mockNetInfoUnsubscriber).toHaveBeenCalled();
|
||||
expect(mockActiveServersUnsubscriber.unsubscribe).toHaveBeenCalled();
|
||||
|
||||
mockNetInfo.addEventListener.mockClear();
|
||||
mockSubscribeActiveServers.mockClear();
|
||||
|
||||
appStateCallback('active');
|
||||
|
||||
expect(mockNetInfo.addEventListener).toHaveBeenCalled();
|
||||
expect(mockSubscribeActiveServers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not reinitialize when transitioning to active if subscriptions exist', async () => {
|
||||
startNetworkConnectivitySubscriptions();
|
||||
|
||||
const appStateCallback = mockAppState.addEventListener.mock.calls[0][1];
|
||||
const serversCallback = mockSubscribeActiveServers.mock.calls[0][0];
|
||||
|
||||
await serversCallback([{url: 'https://test.com', lastActiveAt: 1000}] as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
mockNetInfo.addEventListener.mockClear();
|
||||
mockSubscribeActiveServers.mockClear();
|
||||
|
||||
appStateCallback('active');
|
||||
|
||||
expect(mockNetInfo.addEventListener).not.toHaveBeenCalled();
|
||||
expect(mockSubscribeActiveServers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle netInfo with undefined isInternetReachable', () => {
|
||||
startNetworkConnectivitySubscriptions();
|
||||
|
||||
const netInfoCallback = mockNetInfo.addEventListener.mock.calls[0][0];
|
||||
const mockNetInfoState = {} as MockNetInfoState;
|
||||
netInfoCallback(mockNetInfoState as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
expect(mockNetInfo.addEventListener).toHaveBeenCalledWith(expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('shutdownNetworkConnectivitySubscriptions', () => {
|
||||
it('should clean up all subscriptions including AppState listener', async () => {
|
||||
const mockAppStateListener = {remove: jest.fn()};
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ class NetworkConnectivitySubscriptionManagerSingleton {
|
|||
this.init();
|
||||
}
|
||||
} else if (previousAppState === 'active') {
|
||||
NetworkConnectivityManager.reset();
|
||||
this.stop();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ class NetworkManagerSingleton {
|
|||
timeoutIntervalForRequest: managedConfig?.timeout ? parseInt(managedConfig.timeout, 10) : this.DEFAULT_CONFIG.sessionConfiguration?.timeoutIntervalForRequest,
|
||||
timeoutIntervalForResource: managedConfig?.timeoutVPN ? parseInt(managedConfig.timeoutVPN, 10) : this.DEFAULT_CONFIG.sessionConfiguration?.timeoutIntervalForResource,
|
||||
waitsForConnectivity: managedConfig?.useVPN === 'true',
|
||||
collectMetrics: LocalConfig.CollectNetworkMetrics || LocalConfig.MonitorNetworkPerformance,
|
||||
collectMetrics: true,
|
||||
},
|
||||
headers,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/* eslint-disable max-lines */
|
||||
|
||||
import {of as of$} from 'rxjs';
|
||||
|
||||
import {testExports} from './network_performance_manager';
|
||||
|
||||
import type {ClientResponseMetrics} from '@mattermost/react-native-network-client';
|
||||
|
|
@ -18,10 +22,18 @@ jest.mock('react-native', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
jest.mock('@queries/app/global', () => {
|
||||
const {of: mockOf} = require('rxjs');
|
||||
return {
|
||||
observeLowConnectivityMonitor: jest.fn(() => mockOf(true)),
|
||||
};
|
||||
});
|
||||
|
||||
const {
|
||||
NetworkPerformanceManagerSingleton,
|
||||
REQUEST_OUTCOME_WINDOW_SIZE,
|
||||
MINIMUM_REQUESTS_FOR_INITIAL_DETECTION,
|
||||
MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION,
|
||||
calculatePerformanceStateFromOutcomes,
|
||||
} = testExports;
|
||||
|
||||
|
|
@ -43,13 +55,13 @@ const createMockMetrics = (latency: number, size: number, compressedSize: number
|
|||
describe('Pure Functions', () => {
|
||||
describe('calculatePerformanceStateFromOutcomes', () => {
|
||||
describe('initial detection', () => {
|
||||
it('should return normal when not enough requests for initial detection', () => {
|
||||
it('should return current state when not enough requests for initial detection', () => {
|
||||
const outcomes = [
|
||||
{timestamp: Date.now(), isSlow: true, wasEarlyDetection: false},
|
||||
{timestamp: Date.now(), isSlow: true, wasEarlyDetection: false},
|
||||
];
|
||||
|
||||
const state = calculatePerformanceStateFromOutcomes(outcomes, true);
|
||||
const state = calculatePerformanceStateFromOutcomes(outcomes, true, 'normal');
|
||||
expect(state).toBe('normal');
|
||||
});
|
||||
|
||||
|
|
@ -60,7 +72,7 @@ describe('Pure Functions', () => {
|
|||
wasEarlyDetection: false,
|
||||
}));
|
||||
|
||||
const state = calculatePerformanceStateFromOutcomes(outcomes, true);
|
||||
const state = calculatePerformanceStateFromOutcomes(outcomes, true, 'normal');
|
||||
expect(state).toBe('slow');
|
||||
});
|
||||
|
||||
|
|
@ -71,21 +83,24 @@ describe('Pure Functions', () => {
|
|||
wasEarlyDetection: false,
|
||||
}));
|
||||
|
||||
const state = calculatePerformanceStateFromOutcomes(outcomes, true);
|
||||
const state = calculatePerformanceStateFromOutcomes(outcomes, true, 'normal');
|
||||
expect(state).toBe('normal');
|
||||
});
|
||||
});
|
||||
|
||||
describe('subsequent detection', () => {
|
||||
it('should return slow even with fewer requests than subsequent threshold', () => {
|
||||
it('should return current state with fewer requests than subsequent threshold', () => {
|
||||
const outcomes = Array.from({length: 6}, (_, i) => ({
|
||||
timestamp: Date.now(),
|
||||
isSlow: i < 5, // 5 out of 6 = 83%
|
||||
wasEarlyDetection: false,
|
||||
}));
|
||||
|
||||
const state = calculatePerformanceStateFromOutcomes(outcomes, false);
|
||||
expect(state).toBe('slow');
|
||||
const slowState = calculatePerformanceStateFromOutcomes(outcomes, false, 'slow');
|
||||
expect(slowState).toBe('slow');
|
||||
|
||||
const normalState = calculatePerformanceStateFromOutcomes(outcomes, false, 'normal');
|
||||
expect(normalState).toBe('normal');
|
||||
});
|
||||
|
||||
it('should return normal when slow percentage is below threshold', () => {
|
||||
|
|
@ -95,7 +110,7 @@ describe('Pure Functions', () => {
|
|||
wasEarlyDetection: false,
|
||||
}));
|
||||
|
||||
const state = calculatePerformanceStateFromOutcomes(outcomes, false);
|
||||
const state = calculatePerformanceStateFromOutcomes(outcomes, false, 'slow');
|
||||
expect(state).toBe('normal');
|
||||
});
|
||||
|
||||
|
|
@ -106,7 +121,7 @@ describe('Pure Functions', () => {
|
|||
wasEarlyDetection: false,
|
||||
}));
|
||||
|
||||
const state = calculatePerformanceStateFromOutcomes(outcomes, false);
|
||||
const state = calculatePerformanceStateFromOutcomes(outcomes, false, 'normal');
|
||||
expect(state).toBe('slow');
|
||||
});
|
||||
|
||||
|
|
@ -117,7 +132,7 @@ describe('Pure Functions', () => {
|
|||
wasEarlyDetection: false,
|
||||
}));
|
||||
|
||||
const state = calculatePerformanceStateFromOutcomes(outcomes, false);
|
||||
const state = calculatePerformanceStateFromOutcomes(outcomes, false, 'normal');
|
||||
expect(state).toBe('slow');
|
||||
});
|
||||
});
|
||||
|
|
@ -127,11 +142,17 @@ describe('Pure Functions', () => {
|
|||
describe('NetworkPerformanceManager', () => {
|
||||
let performanceManager: InstanceType<typeof NetworkPerformanceManagerSingleton>;
|
||||
const serverUrl = 'https://test-server.com';
|
||||
const {observeLowConnectivityMonitor} = require('@queries/app/global');
|
||||
|
||||
beforeEach(() => {
|
||||
observeLowConnectivityMonitor.mockReturnValue(of$(true));
|
||||
performanceManager = new NetworkPerformanceManagerSingleton();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('request tracking lifecycle', () => {
|
||||
it('should start and complete request tracking', () => {
|
||||
performanceManager.observePerformanceState(serverUrl);
|
||||
|
|
@ -154,7 +175,7 @@ describe('NetworkPerformanceManager', () => {
|
|||
performanceManager.observePerformanceState(serverUrl);
|
||||
|
||||
const url = '/api/v4/posts';
|
||||
const slowMetrics = createMockMetrics(3000, 1000, 500);
|
||||
const slowMetrics = createMockMetrics(1000, 1000, 500);
|
||||
|
||||
const requestId = performanceManager.startRequestTracking(serverUrl, url);
|
||||
performanceManager.completeRequestTracking(serverUrl, requestId, slowMetrics);
|
||||
|
|
@ -179,13 +200,13 @@ describe('NetworkPerformanceManager', () => {
|
|||
describe('performance state calculation', () => {
|
||||
it('should return normal when slow percentage is below threshold', () => {
|
||||
const states: string[] = [];
|
||||
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => {
|
||||
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state: string) => {
|
||||
states.push(state);
|
||||
});
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
||||
const metrics = createMockMetrics(i < 3 ? 3000 : 500, 1000, 500);
|
||||
const metrics = createMockMetrics(i < 3 ? 1000 : 500, 1000, 500);
|
||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
||||
}
|
||||
|
||||
|
|
@ -195,13 +216,13 @@ describe('NetworkPerformanceManager', () => {
|
|||
|
||||
it('should return slow when slow percentage meets threshold', () => {
|
||||
const states: string[] = [];
|
||||
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => {
|
||||
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state: string) => {
|
||||
states.push(state);
|
||||
});
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
||||
const metrics = createMockMetrics(i < 7 ? 3000 : 500, 1000, 500);
|
||||
const metrics = createMockMetrics(i < 7 ? 1000 : 500, 1000, 500);
|
||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
||||
}
|
||||
|
||||
|
|
@ -211,13 +232,13 @@ describe('NetworkPerformanceManager', () => {
|
|||
|
||||
it('should use initial detection threshold for first slow detection', () => {
|
||||
const states: string[] = [];
|
||||
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => {
|
||||
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state: string) => {
|
||||
states.push(state);
|
||||
});
|
||||
|
||||
for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) {
|
||||
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
||||
const metrics = createMockMetrics(i < 3 ? 3000 : 500, 1000, 500); // 3 out of 4 = 75%
|
||||
const metrics = createMockMetrics(i < 3 ? 1000 : 500, 1000, 500); // 3 out of 4 = 75%
|
||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
||||
}
|
||||
|
||||
|
|
@ -225,28 +246,28 @@ describe('NetworkPerformanceManager', () => {
|
|||
expect(states).toEqual(['normal', 'slow']);
|
||||
});
|
||||
|
||||
it('should not return to normal when switching from initial to subsequent detection', () => {
|
||||
it('should require minimum requests for subsequent detection after initial phase', () => {
|
||||
const states: string[] = [];
|
||||
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => {
|
||||
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state: string) => {
|
||||
states.push(state);
|
||||
});
|
||||
|
||||
for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) {
|
||||
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
||||
const metrics = createMockMetrics(i < 3 ? 3000 : 500, 1000, 500);
|
||||
const metrics = createMockMetrics(1000, 1000, 500);
|
||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
||||
}
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
for (let i = 0; i < MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION; i++) {
|
||||
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-extra-${i}`);
|
||||
const metrics = createMockMetrics(i < 3 ? 3000 : 500, 1000, 500); // 3 more slow out of 4
|
||||
const metrics = createMockMetrics(i < 7 ? 1000 : 500, 1000, 500);
|
||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
||||
}
|
||||
|
||||
subscription.unsubscribe();
|
||||
|
||||
expect(states.filter((s) => s === 'normal')).toHaveLength(1);
|
||||
expect(states).toEqual(['normal', 'slow']);
|
||||
expect(states[0]).toBe('normal');
|
||||
expect(states).toContain('slow');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -254,16 +275,16 @@ describe('NetworkPerformanceManager', () => {
|
|||
it('should provide accurate request outcome statistics', () => {
|
||||
performanceManager.observePerformanceState(serverUrl);
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
for (let i = 0; i < REQUEST_OUTCOME_WINDOW_SIZE; i++) {
|
||||
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
||||
const metrics = createMockMetrics(i < 15 ? 3000 : 500, 1000, 500);
|
||||
const metrics = createMockMetrics(i < 7 ? 1000 : 500, 1000, 500);
|
||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
||||
}
|
||||
|
||||
const stats = performanceManager.getRequestOutcomeStats(serverUrl);
|
||||
expect(stats.totalRequests).toBe(20);
|
||||
expect(stats.slowRequests).toBe(15);
|
||||
expect(stats.slowPercentage).toBe(0.75);
|
||||
expect(stats.totalRequests).toBe(REQUEST_OUTCOME_WINDOW_SIZE);
|
||||
expect(stats.slowRequests).toBe(7);
|
||||
expect(stats.slowPercentage).toBeCloseTo(7 / REQUEST_OUTCOME_WINDOW_SIZE);
|
||||
expect(stats.earlyDetectionCount).toBe(0);
|
||||
});
|
||||
|
||||
|
|
@ -296,7 +317,7 @@ describe('NetworkPerformanceManager', () => {
|
|||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const requestId = performanceManager.startRequestTracking(serverUrl2, `/api/request-${i}`);
|
||||
const metrics = createMockMetrics(i < 8 ? 3000 : 500, 1000, 500);
|
||||
const metrics = createMockMetrics(i < 8 ? 1000 : 500, 1000, 500);
|
||||
performanceManager.completeRequestTracking(serverUrl2, requestId, metrics);
|
||||
}
|
||||
|
||||
|
|
@ -327,7 +348,7 @@ describe('NetworkPerformanceManager', () => {
|
|||
|
||||
performanceManager.removeServer(serverUrl);
|
||||
|
||||
const metrics = createMockMetrics(3000, 1000, 500);
|
||||
const metrics = createMockMetrics(1000, 1000, 500);
|
||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
||||
|
||||
const stats = performanceManager.getRequestOutcomeStats(serverUrl);
|
||||
|
|
@ -361,13 +382,13 @@ describe('NetworkPerformanceManager', () => {
|
|||
describe('observable behavior', () => {
|
||||
it('should emit state changes when performance changes', () => {
|
||||
const states: string[] = [];
|
||||
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => {
|
||||
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state: string) => {
|
||||
states.push(state);
|
||||
});
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) {
|
||||
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
||||
const metrics = createMockMetrics(i < 8 ? 3000 : 500, 1000, 500);
|
||||
const metrics = createMockMetrics(1000, 1000, 500);
|
||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
||||
}
|
||||
|
||||
|
|
@ -388,7 +409,7 @@ describe('NetworkPerformanceManager', () => {
|
|||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
||||
const metrics = createMockMetrics(i < 8 ? 3000 : 500, 1000, 500);
|
||||
const metrics = createMockMetrics(i < 8 ? 1000 : 500, 1000, 500);
|
||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
||||
}
|
||||
|
||||
|
|
@ -422,7 +443,7 @@ describe('NetworkPerformanceManager', () => {
|
|||
|
||||
for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) {
|
||||
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
||||
const metrics = createMockMetrics(3000, 1000, 500);
|
||||
const metrics = createMockMetrics(1000, 1000, 500);
|
||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
||||
}
|
||||
|
||||
|
|
@ -444,10 +465,12 @@ describe('NetworkPerformanceManager', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should not log when performance recovers from slow to normal', () => {
|
||||
it('should log when performance recovers from slow to normal', () => {
|
||||
performanceManager.observePerformanceState(serverUrl);
|
||||
|
||||
for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) {
|
||||
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
||||
const metrics = createMockMetrics(3000, 1000, 500);
|
||||
const metrics = createMockMetrics(1000, 1000, 500);
|
||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
||||
}
|
||||
|
||||
|
|
@ -458,8 +481,21 @@ describe('NetworkPerformanceManager', () => {
|
|||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
||||
}
|
||||
|
||||
expect(logDebug).not.toHaveBeenCalled();
|
||||
expect(logDebug).toHaveBeenCalledWith(
|
||||
`Network performance improved for ${serverUrl}: slow -> normal`,
|
||||
expect.objectContaining({
|
||||
totalRequests: expect.any(Number),
|
||||
slowRequests: expect.any(Number),
|
||||
slowPercentage: expect.any(String),
|
||||
earlyDetectionCount: expect.any(Number),
|
||||
lastOutcome: expect.objectContaining({
|
||||
isSlow: expect.any(Boolean),
|
||||
wasEarlyDetection: expect.any(Boolean),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('AppState monitoring', () => {
|
||||
|
|
@ -493,13 +529,13 @@ describe('NetworkPerformanceManager', () => {
|
|||
|
||||
it('should reset performance state to normal when app becomes inactive', () => {
|
||||
const states: string[] = [];
|
||||
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => {
|
||||
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state: string) => {
|
||||
states.push(state);
|
||||
});
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
||||
const metrics = createMockMetrics(i < 8 ? 3000 : 500, 1000, 500);
|
||||
const metrics = createMockMetrics(i < 8 ? 1000 : 500, 1000, 500);
|
||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
||||
}
|
||||
|
||||
|
|
@ -533,4 +569,158 @@ describe('NetworkPerformanceManager', () => {
|
|||
expect(mockRemove).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Low Connectivity Monitor Integration', () => {
|
||||
it('should subscribe to observeLowConnectivityMonitor on construction', () => {
|
||||
expect(observeLowConnectivityMonitor).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit performance state changes when monitoring is enabled', () => {
|
||||
const states: string[] = [];
|
||||
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state: string) => {
|
||||
states.push(state);
|
||||
});
|
||||
|
||||
for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) {
|
||||
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
||||
const metrics = createMockMetrics(1000, 1000, 500);
|
||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
||||
}
|
||||
|
||||
subscription.unsubscribe();
|
||||
expect(states).toEqual(['normal', 'slow']);
|
||||
});
|
||||
|
||||
it('should not emit performance state changes when monitoring is disabled', () => {
|
||||
observeLowConnectivityMonitor.mockReturnValue(of$(false));
|
||||
const disabledManager = new NetworkPerformanceManagerSingleton();
|
||||
|
||||
const states: string[] = [];
|
||||
const subscription = disabledManager.observePerformanceState(serverUrl).subscribe((state: string) => {
|
||||
states.push(state);
|
||||
});
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const requestId = disabledManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
||||
const metrics = createMockMetrics(i < 8 ? 1000 : 500, 1000, 500);
|
||||
disabledManager.completeRequestTracking(serverUrl, requestId, metrics);
|
||||
}
|
||||
|
||||
subscription.unsubscribe();
|
||||
expect(states).toEqual([]);
|
||||
|
||||
disabledManager.destroy();
|
||||
});
|
||||
|
||||
it('should still track requests and log when monitoring is disabled', () => {
|
||||
const {logDebug} = require('@utils/log');
|
||||
logDebug.mockClear();
|
||||
|
||||
observeLowConnectivityMonitor.mockReturnValue(of$(false));
|
||||
const disabledManager = new NetworkPerformanceManagerSingleton();
|
||||
|
||||
disabledManager.observePerformanceState(serverUrl);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const requestId = disabledManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
||||
const metrics = createMockMetrics(i < 8 ? 1000 : 500, 1000, 500);
|
||||
disabledManager.completeRequestTracking(serverUrl, requestId, metrics);
|
||||
}
|
||||
|
||||
const stats = disabledManager.getRequestOutcomeStats(serverUrl);
|
||||
expect(stats.totalRequests).toBe(10);
|
||||
expect(stats.slowRequests).toBe(8);
|
||||
|
||||
expect(logDebug).toHaveBeenCalledWith(
|
||||
`Network performance degraded for ${serverUrl}: normal -> slow`,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
disabledManager.destroy();
|
||||
});
|
||||
|
||||
it('should clean up monitor subscription on destroy', () => {
|
||||
const mockUnsubscribe = jest.fn();
|
||||
observeLowConnectivityMonitor.mockReturnValue({
|
||||
subscribe: jest.fn(() => ({
|
||||
unsubscribe: mockUnsubscribe,
|
||||
})),
|
||||
});
|
||||
|
||||
const manager = new NetworkPerformanceManagerSingleton();
|
||||
manager.destroy();
|
||||
|
||||
expect(mockUnsubscribe).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle monitor state changing from enabled to disabled', () => {
|
||||
const {BehaviorSubject} = require('rxjs');
|
||||
const monitorSubject = new BehaviorSubject(true);
|
||||
observeLowConnectivityMonitor.mockReturnValue(monitorSubject.asObservable());
|
||||
|
||||
const dynamicManager = new NetworkPerformanceManagerSingleton();
|
||||
|
||||
const states: string[] = [];
|
||||
const subscription = dynamicManager.observePerformanceState(serverUrl).subscribe((state: string) => {
|
||||
states.push(state);
|
||||
});
|
||||
|
||||
for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) {
|
||||
const requestId = dynamicManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
||||
const metrics = createMockMetrics(1000, 1000, 500);
|
||||
dynamicManager.completeRequestTracking(serverUrl, requestId, metrics);
|
||||
}
|
||||
|
||||
expect(states).toEqual(['normal', 'slow']);
|
||||
|
||||
monitorSubject.next(false);
|
||||
|
||||
states.length = 0;
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const requestId = dynamicManager.startRequestTracking(serverUrl, `/api/request-extra-${i}`);
|
||||
const metrics = createMockMetrics(500, 1000, 500);
|
||||
dynamicManager.completeRequestTracking(serverUrl, requestId, metrics);
|
||||
}
|
||||
|
||||
expect(states).toEqual([]);
|
||||
|
||||
subscription.unsubscribe();
|
||||
dynamicManager.destroy();
|
||||
});
|
||||
|
||||
it('should handle monitor state changing from disabled to enabled', () => {
|
||||
const {BehaviorSubject} = require('rxjs');
|
||||
const monitorSubject = new BehaviorSubject(false);
|
||||
observeLowConnectivityMonitor.mockReturnValue(monitorSubject.asObservable());
|
||||
|
||||
const dynamicManager = new NetworkPerformanceManagerSingleton();
|
||||
|
||||
const states: string[] = [];
|
||||
const subscription = dynamicManager.observePerformanceState(serverUrl).subscribe((state: string) => {
|
||||
states.push(state);
|
||||
});
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const requestId = dynamicManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
||||
const metrics = createMockMetrics(i < 4 ? 1000 : 500, 1000, 500);
|
||||
dynamicManager.completeRequestTracking(serverUrl, requestId, metrics);
|
||||
}
|
||||
|
||||
expect(states).toEqual([]);
|
||||
|
||||
monitorSubject.next(true);
|
||||
|
||||
for (let i = 5; i < 10; i++) {
|
||||
const requestId = dynamicManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
||||
const metrics = createMockMetrics(500, 1000, 500);
|
||||
dynamicManager.completeRequestTracking(serverUrl, requestId, metrics);
|
||||
}
|
||||
|
||||
expect(states.length).toBeGreaterThan(0);
|
||||
|
||||
subscription.unsubscribe();
|
||||
dynamicManager.destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {AppState, type AppStateStatus} from 'react-native';
|
||||
import {BehaviorSubject} from 'rxjs';
|
||||
import {distinctUntilChanged} from 'rxjs/operators';
|
||||
import {AppState, type AppStateStatus, type NativeEventSubscription} from 'react-native';
|
||||
import {BehaviorSubject, type Subscription} from 'rxjs';
|
||||
import {distinctUntilChanged, filter} from 'rxjs/operators';
|
||||
|
||||
import {observeLowConnectivityMonitor} from '@queries/app/global';
|
||||
import {logDebug} from '@utils/log';
|
||||
|
||||
import type {ClientResponseMetrics} from '@mattermost/react-native-network-client';
|
||||
|
|
@ -24,21 +25,23 @@ interface RequestOutcome {
|
|||
wasEarlyDetection: boolean;
|
||||
}
|
||||
|
||||
const SLOW_REQUEST_THRESHOLD = 2000;
|
||||
const SLOW_REQUEST_THRESHOLD = 800;
|
||||
const EARLY_DETECTION_SLOW_THRESHOLD = 2000;
|
||||
const SLOW_REQUEST_PERCENTAGE_THRESHOLD = 0.7;
|
||||
const REQUEST_OUTCOME_WINDOW_SIZE = 20;
|
||||
const MINIMUM_REQUESTS_FOR_INITIAL_DETECTION = 4;
|
||||
const MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION = 10;
|
||||
|
||||
const calculatePerformanceStateFromOutcomes = (outcomes: RequestOutcome[], isInitialDetection: boolean): NetworkPerformanceState => {
|
||||
const calculatePerformanceStateFromOutcomes = (outcomes: RequestOutcome[], isInitialDetection: boolean, currentState: NetworkPerformanceState): NetworkPerformanceState => {
|
||||
const minimumRequests = isInitialDetection ? MINIMUM_REQUESTS_FOR_INITIAL_DETECTION : MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION;
|
||||
|
||||
if (isInitialDetection && outcomes.length < minimumRequests) {
|
||||
return 'normal';
|
||||
if (outcomes.length < minimumRequests) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
const slowRequestCount = outcomes.filter((outcome) => outcome.isSlow).length;
|
||||
const slowPercentage = slowRequestCount / outcomes.length;
|
||||
const recentOutcomes = outcomes.slice(-minimumRequests);
|
||||
const slowRequestCount = recentOutcomes.filter((outcome) => outcome.isSlow).length;
|
||||
const slowPercentage = slowRequestCount / recentOutcomes.length;
|
||||
|
||||
return slowPercentage >= SLOW_REQUEST_PERCENTAGE_THRESHOLD ? 'slow' : 'normal';
|
||||
};
|
||||
|
|
@ -47,12 +50,16 @@ class NetworkPerformanceManagerSingleton {
|
|||
private performanceSubjects: Record<string, BehaviorSubject<NetworkPerformanceState>> = {};
|
||||
private activeRequests: Record<string, Record<string, ActiveRequest>> = {};
|
||||
private requestOutcomes: Record<string, RequestOutcome[]> = {};
|
||||
private totalRequestCount: Record<string, number> = {};
|
||||
private initialRequestTimestamp: Record<string, number> = {};
|
||||
private isInitialDetection: Record<string, boolean> = {};
|
||||
private appStateSubscription: any = null;
|
||||
private appStateSubscription: NativeEventSubscription | null = null;
|
||||
private lowConnectivityMonitorEnabled = true;
|
||||
private monitorSubscription: Subscription | null = null;
|
||||
|
||||
constructor() {
|
||||
this.setupAppStateMonitoring();
|
||||
this.setupMonitorObserver();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -73,7 +80,7 @@ class NetworkPerformanceManagerSingleton {
|
|||
|
||||
request.checkTimer = setTimeout(() => {
|
||||
this.checkRequestLatency(serverUrl, requestId);
|
||||
}, SLOW_REQUEST_THRESHOLD);
|
||||
}, EARLY_DETECTION_SLOW_THRESHOLD);
|
||||
|
||||
this.activeRequests[serverUrl][requestId] = request;
|
||||
return requestId;
|
||||
|
|
@ -110,9 +117,11 @@ class NetworkPerformanceManagerSingleton {
|
|||
/**
|
||||
* Returns an observable that emits network performance state changes.
|
||||
* Emits 'normal' or 'slow' based on current performance metrics.
|
||||
* Only emits when low connectivity monitoring is enabled.
|
||||
*/
|
||||
public observePerformanceState = (serverUrl: string) => {
|
||||
return this.getPerformanceSubject(serverUrl).asObservable().pipe(
|
||||
filter(() => this.lowConnectivityMonitorEnabled),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
};
|
||||
|
|
@ -127,8 +136,15 @@ class NetworkPerformanceManagerSingleton {
|
|||
/**
|
||||
* Gets the current request outcome statistics for a server.
|
||||
*/
|
||||
public getRequestOutcomeStats = (serverUrl: string) => {
|
||||
const outcomes = this.requestOutcomes[serverUrl] || [];
|
||||
public getRequestOutcomeStats = (serverUrl: string, useRecentOnly = false) => {
|
||||
let outcomes = this.requestOutcomes[serverUrl] || [];
|
||||
|
||||
if (useRecentOnly && outcomes.length > 0) {
|
||||
const isInitialDetection = !this.isInitialDetection[serverUrl];
|
||||
const minimumRequests = isInitialDetection ? MINIMUM_REQUESTS_FOR_INITIAL_DETECTION : MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION;
|
||||
outcomes = outcomes.slice(-minimumRequests);
|
||||
}
|
||||
|
||||
if (!outcomes.length) {
|
||||
return {
|
||||
totalRequests: 0,
|
||||
|
|
@ -166,6 +182,7 @@ class NetworkPerformanceManagerSingleton {
|
|||
delete this.performanceSubjects[serverUrl];
|
||||
}
|
||||
delete this.requestOutcomes[serverUrl];
|
||||
delete this.totalRequestCount[serverUrl];
|
||||
delete this.initialRequestTimestamp[serverUrl];
|
||||
delete this.isInitialDetection[serverUrl];
|
||||
};
|
||||
|
|
@ -179,6 +196,11 @@ class NetworkPerformanceManagerSingleton {
|
|||
this.appStateSubscription = null;
|
||||
}
|
||||
|
||||
if (this.monitorSubscription) {
|
||||
this.monitorSubscription.unsubscribe();
|
||||
this.monitorSubscription = null;
|
||||
}
|
||||
|
||||
Object.keys(this.activeRequests).forEach((serverUrl) => {
|
||||
this.clearAllActiveRequests(serverUrl);
|
||||
});
|
||||
|
|
@ -189,6 +211,7 @@ class NetworkPerformanceManagerSingleton {
|
|||
|
||||
this.performanceSubjects = {};
|
||||
this.requestOutcomes = {};
|
||||
this.totalRequestCount = {};
|
||||
this.initialRequestTimestamp = {};
|
||||
this.isInitialDetection = {};
|
||||
};
|
||||
|
|
@ -202,7 +225,7 @@ class NetworkPerformanceManagerSingleton {
|
|||
const currentTime = Date.now();
|
||||
const elapsedTime = currentTime - activeRequest.startTime;
|
||||
|
||||
if (elapsedTime >= SLOW_REQUEST_THRESHOLD) {
|
||||
if (elapsedTime >= EARLY_DETECTION_SLOW_THRESHOLD) {
|
||||
this.recordRequestOutcome(serverUrl, {
|
||||
timestamp: currentTime,
|
||||
isSlow: true,
|
||||
|
|
@ -244,11 +267,16 @@ class NetworkPerformanceManagerSingleton {
|
|||
this.requestOutcomes[serverUrl] = [];
|
||||
}
|
||||
|
||||
if (!this.totalRequestCount[serverUrl]) {
|
||||
this.totalRequestCount[serverUrl] = 0;
|
||||
}
|
||||
|
||||
if (!this.initialRequestTimestamp[serverUrl] && outcome.isSlow) {
|
||||
this.initialRequestTimestamp[serverUrl] = outcome.timestamp;
|
||||
}
|
||||
|
||||
this.requestOutcomes[serverUrl].push(outcome);
|
||||
this.totalRequestCount[serverUrl]++;
|
||||
|
||||
if (this.requestOutcomes[serverUrl].length > REQUEST_OUTCOME_WINDOW_SIZE) {
|
||||
this.requestOutcomes[serverUrl] = this.requestOutcomes[serverUrl].slice(-REQUEST_OUTCOME_WINDOW_SIZE);
|
||||
|
|
@ -257,28 +285,34 @@ class NetworkPerformanceManagerSingleton {
|
|||
const outcomes = this.requestOutcomes[serverUrl];
|
||||
const currentPerformanceState = this.getCurrentPerformanceState(serverUrl);
|
||||
const isInitialDetection = !this.isInitialDetection[serverUrl];
|
||||
const newPerformanceState = calculatePerformanceStateFromOutcomes(outcomes, isInitialDetection);
|
||||
const newPerformanceState = calculatePerformanceStateFromOutcomes(outcomes, isInitialDetection, currentPerformanceState);
|
||||
|
||||
if (currentPerformanceState !== newPerformanceState && newPerformanceState === 'slow') {
|
||||
const stats = this.getRequestOutcomeStats(serverUrl);
|
||||
const detectionDelayMs = outcome.timestamp - this.initialRequestTimestamp[serverUrl];
|
||||
if (currentPerformanceState !== newPerformanceState) {
|
||||
const stats = this.getRequestOutcomeStats(serverUrl, true);
|
||||
const statusChange = newPerformanceState === 'slow' ? 'degraded' : 'improved';
|
||||
|
||||
logDebug(`Network performance degraded for ${serverUrl}: ${currentPerformanceState} -> ${newPerformanceState}`, {
|
||||
const logData: Record<string, unknown> = {
|
||||
totalRequests: stats.totalRequests,
|
||||
slowRequests: stats.slowRequests,
|
||||
slowPercentage: `${(stats.slowPercentage * 100).toFixed(1)}%`,
|
||||
earlyDetectionCount: stats.earlyDetectionCount,
|
||||
currentTimestamp: Date.now(),
|
||||
detectionDelayMs,
|
||||
detectionDelaySeconds: `${(detectionDelayMs / 1000).toFixed(1)}s`,
|
||||
lastOutcome: {
|
||||
isSlow: outcome.isSlow,
|
||||
wasEarlyDetection: outcome.wasEarlyDetection,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
this.isInitialDetection[serverUrl] = true;
|
||||
delete this.initialRequestTimestamp[serverUrl];
|
||||
if (newPerformanceState === 'slow') {
|
||||
const detectionDelayMs = outcome.timestamp - this.initialRequestTimestamp[serverUrl];
|
||||
logData.currentTimestamp = Date.now();
|
||||
logData.detectionDelayMs = detectionDelayMs;
|
||||
logData.detectionDelaySeconds = `${(detectionDelayMs / 1000).toFixed(1)}s`;
|
||||
|
||||
this.isInitialDetection[serverUrl] = true;
|
||||
delete this.initialRequestTimestamp[serverUrl];
|
||||
}
|
||||
|
||||
logDebug(`Network performance ${statusChange} for ${serverUrl}: ${currentPerformanceState} -> ${newPerformanceState}`, logData);
|
||||
}
|
||||
|
||||
this.getPerformanceSubject(serverUrl).next(newPerformanceState);
|
||||
|
|
@ -295,6 +329,12 @@ class NetworkPerformanceManagerSingleton {
|
|||
this.appStateSubscription = AppState.addEventListener('change', this.handleAppStateChange);
|
||||
};
|
||||
|
||||
private setupMonitorObserver = () => {
|
||||
this.monitorSubscription = observeLowConnectivityMonitor().subscribe((enabled) => {
|
||||
this.lowConnectivityMonitorEnabled = enabled;
|
||||
});
|
||||
};
|
||||
|
||||
private handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||
if (nextAppState !== 'active') {
|
||||
this.cleanupOnAppStateChange();
|
||||
|
|
@ -315,6 +355,7 @@ class NetworkPerformanceManagerSingleton {
|
|||
export const testExports = {
|
||||
NetworkPerformanceManagerSingleton,
|
||||
SLOW_REQUEST_THRESHOLD,
|
||||
EARLY_DETECTION_SLOW_THRESHOLD,
|
||||
REQUEST_OUTCOME_WINDOW_SIZE,
|
||||
MINIMUM_REQUESTS_FOR_INITIAL_DETECTION,
|
||||
MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION,
|
||||
|
|
|
|||
169
app/queries/app/global.test.ts
Normal file
169
app/queries/app/global.test.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {of as of$} from 'rxjs';
|
||||
|
||||
import DatabaseManager from '@database/manager';
|
||||
|
||||
import {observeLowConnectivityMonitor} from './global';
|
||||
|
||||
import type {AppDatabase} from '@typings/database/database';
|
||||
|
||||
jest.mock('@database/manager', () => ({
|
||||
getAppDatabaseAndOperator: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('observeLowConnectivityMonitor', () => {
|
||||
const mockDatabase = {
|
||||
get: jest.fn(),
|
||||
};
|
||||
|
||||
const mockQuery = {
|
||||
query: jest.fn(),
|
||||
observe: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(DatabaseManager.getAppDatabaseAndOperator).mockReturnValue({
|
||||
database: mockDatabase,
|
||||
operator: {},
|
||||
} as unknown as AppDatabase);
|
||||
});
|
||||
|
||||
it('should return observable that defaults to true when no database record exists', (done) => {
|
||||
mockDatabase.get.mockReturnValue({
|
||||
query: jest.fn().mockReturnValue(mockQuery),
|
||||
});
|
||||
mockQuery.observe.mockReturnValue(of$([]));
|
||||
|
||||
const observable = observeLowConnectivityMonitor();
|
||||
|
||||
observable.subscribe((value) => {
|
||||
expect(value).toBe(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return observable with database value when record exists', (done) => {
|
||||
const mockRecord = {
|
||||
observe: jest.fn().mockReturnValue(of$({value: false})),
|
||||
};
|
||||
|
||||
mockDatabase.get.mockReturnValue({
|
||||
query: jest.fn().mockReturnValue(mockQuery),
|
||||
});
|
||||
mockQuery.observe.mockReturnValue(of$([mockRecord]));
|
||||
|
||||
const observable = observeLowConnectivityMonitor();
|
||||
|
||||
observable.subscribe((value) => {
|
||||
expect(value).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle boolean value directly', (done) => {
|
||||
const mockRecord = {
|
||||
observe: jest.fn().mockReturnValue(of$(true)),
|
||||
};
|
||||
|
||||
mockDatabase.get.mockReturnValue({
|
||||
query: jest.fn().mockReturnValue(mockQuery),
|
||||
});
|
||||
mockQuery.observe.mockReturnValue(of$([mockRecord]));
|
||||
|
||||
const observable = observeLowConnectivityMonitor();
|
||||
|
||||
observable.subscribe((value) => {
|
||||
expect(value).toBe(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle false boolean value directly', (done) => {
|
||||
const mockRecord = {
|
||||
observe: jest.fn().mockReturnValue(of$(false)),
|
||||
};
|
||||
|
||||
mockDatabase.get.mockReturnValue({
|
||||
query: jest.fn().mockReturnValue(mockQuery),
|
||||
});
|
||||
mockQuery.observe.mockReturnValue(of$([mockRecord]));
|
||||
|
||||
const observable = observeLowConnectivityMonitor();
|
||||
|
||||
observable.subscribe((value) => {
|
||||
expect(value).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should default to true when record value is undefined', (done) => {
|
||||
const mockRecord = {
|
||||
observe: jest.fn().mockReturnValue(of$({value: undefined})),
|
||||
};
|
||||
|
||||
mockDatabase.get.mockReturnValue({
|
||||
query: jest.fn().mockReturnValue(mockQuery),
|
||||
});
|
||||
mockQuery.observe.mockReturnValue(of$([mockRecord]));
|
||||
|
||||
const observable = observeLowConnectivityMonitor();
|
||||
|
||||
observable.subscribe((value) => {
|
||||
expect(value).toBe(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should default to true when query returns null', (done) => {
|
||||
jest.mocked(DatabaseManager.getAppDatabaseAndOperator).mockImplementation(() => {
|
||||
throw new Error('Database not found');
|
||||
});
|
||||
|
||||
const observable = observeLowConnectivityMonitor();
|
||||
|
||||
observable.subscribe((value) => {
|
||||
expect(value).toBe(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle enabled state (true)', (done) => {
|
||||
const mockRecord = {
|
||||
observe: jest.fn().mockReturnValue(of$({value: true})),
|
||||
};
|
||||
|
||||
mockDatabase.get.mockReturnValue({
|
||||
query: jest.fn().mockReturnValue(mockQuery),
|
||||
});
|
||||
mockQuery.observe.mockReturnValue(of$([mockRecord]));
|
||||
|
||||
const observable = observeLowConnectivityMonitor();
|
||||
|
||||
observable.subscribe((value) => {
|
||||
expect(value).toBe(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle disabled state (false)', (done) => {
|
||||
const mockRecord = {
|
||||
observe: jest.fn().mockReturnValue(of$({value: false})),
|
||||
};
|
||||
|
||||
mockDatabase.get.mockReturnValue({
|
||||
query: jest.fn().mockReturnValue(mockQuery),
|
||||
});
|
||||
mockQuery.observe.mockReturnValue(of$([mockRecord]));
|
||||
|
||||
const observable = observeLowConnectivityMonitor();
|
||||
|
||||
observable.subscribe((value) => {
|
||||
expect(value).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -100,3 +100,19 @@ export const observeTutorialWatched = (tutorial: string) => {
|
|||
switchMap((v) => of$(Boolean(v))),
|
||||
);
|
||||
};
|
||||
|
||||
export const observeLowConnectivityMonitor = () => {
|
||||
const query = queryGlobalValue(GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR);
|
||||
if (!query) {
|
||||
return of$(true);
|
||||
}
|
||||
return query.observe().pipe(
|
||||
switchMap((result) => (result.length ? result[0].observe() : of$(true))),
|
||||
switchMap((v) => {
|
||||
if (typeof v === 'boolean') {
|
||||
return of$(v);
|
||||
}
|
||||
return of$(v?.value ?? true);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
|
|||
464
app/screens/settings/advanced/advanced.test.tsx
Normal file
464
app/screens/settings/advanced/advanced.test.tsx
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {fireEvent, screen, waitFor} from '@testing-library/react-native';
|
||||
import React from 'react';
|
||||
import {Alert} from 'react-native';
|
||||
|
||||
import {storeLowConnectivityMonitor} from '@actions/app/global';
|
||||
import {Screens} from '@constants';
|
||||
import {goToScreen} from '@screens/navigation';
|
||||
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
|
||||
import {deleteFileCache, getAllFilesInCachesDirectory} from '@utils/file';
|
||||
|
||||
import AdvancedSettings from './advanced';
|
||||
|
||||
import type {FileInfo} from 'expo-file-system';
|
||||
|
||||
jest.mock('@actions/app/global');
|
||||
jest.mock('@utils/file');
|
||||
jest.mock('@screens/navigation');
|
||||
jest.mock('@hooks/android_back_handler', () => jest.fn());
|
||||
|
||||
jest.mock('@context/server', () => ({
|
||||
useServerUrl: jest.fn(() => 'https://test.mattermost.com'),
|
||||
}));
|
||||
|
||||
jest.mock('@hooks/utils', () => ({
|
||||
usePreventDoubleTap: jest.fn((callback) => callback),
|
||||
}));
|
||||
|
||||
const mockStoreLowConnectivityMonitor = storeLowConnectivityMonitor as jest.Mock;
|
||||
const mockGetAllFilesInCachesDirectory = getAllFilesInCachesDirectory as jest.Mock;
|
||||
const mockDeleteFileCache = deleteFileCache as jest.Mock;
|
||||
const mockGoToScreen = goToScreen as jest.Mock;
|
||||
|
||||
describe('AdvancedSettings', () => {
|
||||
const defaultProps = {
|
||||
componentId: 'advanced-settings-screen' as const,
|
||||
isDevMode: false,
|
||||
lowConnectivityMonitorEnabled: false,
|
||||
};
|
||||
|
||||
const mockFiles: FileInfo[] = [
|
||||
{
|
||||
uri: 'file:///cache/file1.jpg',
|
||||
size: 1024 * 1024,
|
||||
modificationTime: Date.now() / 1000,
|
||||
isDirectory: false,
|
||||
exists: true,
|
||||
md5: 'abc123',
|
||||
},
|
||||
{
|
||||
uri: 'file:///cache/file2.png',
|
||||
size: 2048 * 1024,
|
||||
modificationTime: Date.now() / 1000,
|
||||
isDirectory: false,
|
||||
exists: true,
|
||||
md5: 'def456',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetAllFilesInCachesDirectory.mockResolvedValue({
|
||||
totalSize: 3072 * 1024,
|
||||
files: mockFiles,
|
||||
});
|
||||
Alert.alert = jest.fn();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render advanced settings container', async () => {
|
||||
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('advanced_settings.screen')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render delete local files option', async () => {
|
||||
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('advanced_settings.delete_data.option')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render low connectivity monitor toggle', async () => {
|
||||
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('advanced_settings.low_connectivity_monitor.option')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render component library option in dev mode', async () => {
|
||||
renderWithIntlAndTheme(
|
||||
<AdvancedSettings
|
||||
{...defaultProps}
|
||||
isDevMode={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('advanced_settings.component_library.option')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not render component library option when not in dev mode', async () => {
|
||||
renderWithIntlAndTheme(
|
||||
<AdvancedSettings
|
||||
{...defaultProps}
|
||||
isDevMode={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('advanced_settings.component_library.option')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display file size for delete option', async () => {
|
||||
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
|
||||
|
||||
await waitFor(() => {
|
||||
const deleteOption = screen.getByTestId('advanced_settings.delete_data.option');
|
||||
expect(deleteOption).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty cache gracefully', async () => {
|
||||
mockGetAllFilesInCachesDirectory.mockResolvedValue({
|
||||
totalSize: 0,
|
||||
files: [],
|
||||
});
|
||||
|
||||
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('advanced_settings.screen')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete local files', () => {
|
||||
it('should show confirmation alert when delete button is pressed with files', async () => {
|
||||
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('advanced_settings.delete_data.option')).toBeTruthy();
|
||||
});
|
||||
|
||||
const deleteOption = screen.getByTestId('advanced_settings.delete_data.option');
|
||||
|
||||
fireEvent.press(deleteOption);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Alert.alert).toHaveBeenCalledWith(
|
||||
'Delete local files',
|
||||
'\nThis will delete all files downloaded through the app for this server. Please confirm to proceed.\n',
|
||||
[
|
||||
{text: 'Cancel', style: 'cancel'},
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: expect.any(Function),
|
||||
},
|
||||
],
|
||||
{cancelable: false},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete files when user confirms', async () => {
|
||||
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('advanced_settings.delete_data.option')).toBeTruthy();
|
||||
});
|
||||
|
||||
const deleteOption = screen.getByTestId('advanced_settings.delete_data.option');
|
||||
fireEvent.press(deleteOption);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Alert.alert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const alertCalls = (Alert.alert as jest.Mock).mock.calls;
|
||||
const deleteCallback = alertCalls[0][2][1].onPress;
|
||||
|
||||
await deleteCallback();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteFileCache).toHaveBeenCalledWith('https://test.mattermost.com');
|
||||
expect(mockGetAllFilesInCachesDirectory).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not show alert when no files exist', async () => {
|
||||
mockGetAllFilesInCachesDirectory.mockResolvedValue({
|
||||
totalSize: 0,
|
||||
files: [],
|
||||
});
|
||||
|
||||
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('advanced_settings.delete_data.option')).toBeTruthy();
|
||||
});
|
||||
|
||||
const deleteOption = screen.getByTestId('advanced_settings.delete_data.option');
|
||||
fireEvent.press(deleteOption);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Alert.alert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle delete error gracefully', async () => {
|
||||
mockDeleteFileCache.mockResolvedValue(undefined);
|
||||
|
||||
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('advanced_settings.delete_data.option')).toBeTruthy();
|
||||
});
|
||||
|
||||
const deleteOption = screen.getByTestId('advanced_settings.delete_data.option');
|
||||
fireEvent.press(deleteOption);
|
||||
|
||||
expect(Alert.alert).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('component library navigation', () => {
|
||||
it('should navigate to component library when option is pressed', async () => {
|
||||
renderWithIntlAndTheme(
|
||||
<AdvancedSettings
|
||||
{...defaultProps}
|
||||
isDevMode={true}
|
||||
/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('advanced_settings.component_library.option')).toBeTruthy();
|
||||
});
|
||||
|
||||
const componentLibraryOption = screen.getByTestId('advanced_settings.component_library.option');
|
||||
fireEvent.press(componentLibraryOption);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGoToScreen).toHaveBeenCalledWith(
|
||||
Screens.COMPONENT_LIBRARY,
|
||||
'Component library',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not navigate when component library is not rendered', async () => {
|
||||
renderWithIntlAndTheme(
|
||||
<AdvancedSettings
|
||||
{...defaultProps}
|
||||
isDevMode={false}
|
||||
/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('advanced_settings.component_library.option')).toBeNull();
|
||||
});
|
||||
|
||||
expect(mockGoToScreen).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('low connectivity monitor toggle', () => {
|
||||
it('should render toggle in off state when disabled', async () => {
|
||||
renderWithIntlAndTheme(
|
||||
<AdvancedSettings
|
||||
{...defaultProps}
|
||||
lowConnectivityMonitorEnabled={false}
|
||||
/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('advanced_settings.low_connectivity_monitor.option.toggled.false.button')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render toggle in on state when enabled', async () => {
|
||||
renderWithIntlAndTheme(
|
||||
<AdvancedSettings
|
||||
{...defaultProps}
|
||||
lowConnectivityMonitorEnabled={true}
|
||||
/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('advanced_settings.low_connectivity_monitor.option.toggled.true.button')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call storeLowConnectivityMonitor when toggled on', async () => {
|
||||
mockStoreLowConnectivityMonitor.mockResolvedValue({});
|
||||
|
||||
renderWithIntlAndTheme(
|
||||
<AdvancedSettings
|
||||
{...defaultProps}
|
||||
lowConnectivityMonitorEnabled={false}
|
||||
/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('advanced_settings.low_connectivity_monitor.option.toggled.false.button')).toBeTruthy();
|
||||
});
|
||||
|
||||
const toggle = screen.getByTestId('advanced_settings.low_connectivity_monitor.option.toggled.false.button');
|
||||
|
||||
fireEvent(toggle, 'valueChange', true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStoreLowConnectivityMonitor).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should call storeLowConnectivityMonitor when toggled off', async () => {
|
||||
mockStoreLowConnectivityMonitor.mockResolvedValue({});
|
||||
|
||||
renderWithIntlAndTheme(
|
||||
<AdvancedSettings
|
||||
{...defaultProps}
|
||||
lowConnectivityMonitorEnabled={true}
|
||||
/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('advanced_settings.low_connectivity_monitor.option.toggled.true.button')).toBeTruthy();
|
||||
});
|
||||
|
||||
const toggle = screen.getByTestId('advanced_settings.low_connectivity_monitor.option.toggled.true.button');
|
||||
|
||||
fireEvent(toggle, 'valueChange', false);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStoreLowConnectivityMonitor).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should update local state when toggle is changed', async () => {
|
||||
mockStoreLowConnectivityMonitor.mockResolvedValue({});
|
||||
|
||||
renderWithIntlAndTheme(
|
||||
<AdvancedSettings
|
||||
{...defaultProps}
|
||||
lowConnectivityMonitorEnabled={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('advanced_settings.low_connectivity_monitor.option.toggled.false.button')).toBeTruthy();
|
||||
});
|
||||
|
||||
const toggle = screen.getByTestId('advanced_settings.low_connectivity_monitor.option.toggled.false.button');
|
||||
|
||||
fireEvent(toggle, 'valueChange', true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('advanced_settings.low_connectivity_monitor.option.toggled.true.button')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('data fetching', () => {
|
||||
it('should fetch cached files on mount', async () => {
|
||||
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetAllFilesInCachesDirectory).toHaveBeenCalledWith('https://test.mattermost.com');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle fetch error gracefully', async () => {
|
||||
mockGetAllFilesInCachesDirectory.mockRejectedValue(new Error('Fetch failed'));
|
||||
|
||||
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetAllFilesInCachesDirectory).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should refetch files after deletion', async () => {
|
||||
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('advanced_settings.delete_data.option')).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(mockGetAllFilesInCachesDirectory).toHaveBeenCalledTimes(1);
|
||||
|
||||
const deleteOption = screen.getByTestId('advanced_settings.delete_data.option');
|
||||
fireEvent.press(deleteOption);
|
||||
|
||||
const alertCalls = (Alert.alert as jest.Mock).mock.calls;
|
||||
const deleteCallback = alertCalls[0][2][1].onPress;
|
||||
|
||||
await deleteCallback();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetAllFilesInCachesDirectory).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle undefined totalSize gracefully', async () => {
|
||||
mockGetAllFilesInCachesDirectory.mockResolvedValue({
|
||||
totalSize: undefined,
|
||||
files: [],
|
||||
});
|
||||
|
||||
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('advanced_settings.screen')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle null files gracefully', async () => {
|
||||
mockGetAllFilesInCachesDirectory.mockResolvedValue({
|
||||
totalSize: 0,
|
||||
files: null,
|
||||
});
|
||||
|
||||
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('advanced_settings.screen')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle very large file sizes', async () => {
|
||||
mockGetAllFilesInCachesDirectory.mockResolvedValue({
|
||||
totalSize: 5 * 1024 * 1024 * 1024,
|
||||
files: [],
|
||||
});
|
||||
|
||||
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('advanced_settings.delete_data.option')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render experimental features section header', async () => {
|
||||
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Experimental Features')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display low connectivity monitor description', async () => {
|
||||
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Display banners when network connectivity or performance issues are detected')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -2,9 +2,11 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {defineMessage, useIntl} from 'react-intl';
|
||||
import {Alert, TouchableOpacity} from 'react-native';
|
||||
|
||||
import {storeLowConnectivityMonitor} from '@actions/app/global';
|
||||
import SettingBlock from '@components/settings/block';
|
||||
import SettingContainer from '@components/settings/container';
|
||||
import SettingOption from '@components/settings/option';
|
||||
import SettingSeparator from '@components/settings/separator';
|
||||
|
|
@ -23,15 +25,18 @@ const EMPTY_FILES: FileInfo[] = [];
|
|||
type AdvancedSettingsProps = {
|
||||
componentId: AvailableScreens;
|
||||
isDevMode: boolean;
|
||||
lowConnectivityMonitorEnabled: boolean;
|
||||
};
|
||||
const AdvancedSettings = ({
|
||||
componentId,
|
||||
isDevMode,
|
||||
lowConnectivityMonitorEnabled,
|
||||
}: AdvancedSettingsProps) => {
|
||||
const intl = useIntl();
|
||||
const serverUrl = useServerUrl();
|
||||
const [dataSize, setDataSize] = useState<number | undefined>(0);
|
||||
const [files, setFiles] = useState<FileInfo[]>(EMPTY_FILES);
|
||||
const [isLowConnectivityMonitorEnabled, setIsLowConnectivityMonitorEnabled] = useState(lowConnectivityMonitorEnabled);
|
||||
|
||||
const getAllCachedFiles = useCallback(async () => {
|
||||
const {totalSize = 0, files: cachedFiles} = await getAllFilesInCachesDirectory(serverUrl);
|
||||
|
|
@ -76,9 +81,14 @@ const AdvancedSettings = ({
|
|||
goToScreen(screen, title);
|
||||
}, [intl]);
|
||||
|
||||
const onToggleLowConnectivityMonitor = useCallback(async (value: boolean) => {
|
||||
setIsLowConnectivityMonitorEnabled(value);
|
||||
await storeLowConnectivityMonitor(value);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getAllCachedFiles();
|
||||
}, []);
|
||||
}, [getAllCachedFiles]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
popTopScreen(componentId);
|
||||
|
|
@ -114,9 +124,30 @@ const AdvancedSettings = ({
|
|||
testID='advanced_settings.component_library.option'
|
||||
type='none'
|
||||
/>
|
||||
<SettingSeparator/>
|
||||
{/* <SettingSeparator/> */}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<SettingBlock
|
||||
headerText={defineMessage({
|
||||
id: 'settings.advanced.experimental_features',
|
||||
defaultMessage: 'Experimental Features',
|
||||
})}
|
||||
>
|
||||
<SettingSeparator/>
|
||||
|
||||
<SettingOption
|
||||
action={onToggleLowConnectivityMonitor}
|
||||
label={intl.formatMessage({id: 'settings.advanced.low_connectivity_monitor', defaultMessage: 'Low Connectivity Monitor'})}
|
||||
description={intl.formatMessage({
|
||||
id: 'settings.advanced.low_connectivity_monitor.description',
|
||||
defaultMessage: 'Display banners when network connectivity or performance issues are detected',
|
||||
})}
|
||||
selected={isLowConnectivityMonitorEnabled}
|
||||
testID='advanced_settings.low_connectivity_monitor.option'
|
||||
type='toggle'
|
||||
/>
|
||||
<SettingSeparator/>
|
||||
</SettingBlock>
|
||||
</SettingContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
|
||||
|
||||
import {observeLowConnectivityMonitor} from '@queries/app/global';
|
||||
import {observeConfigBooleanValue} from '@queries/servers/system';
|
||||
|
||||
import AdvancedSettings from './advanced';
|
||||
|
|
@ -12,6 +13,7 @@ import type {WithDatabaseArgs} from '@typings/database/database';
|
|||
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
|
||||
return {
|
||||
isDevMode: observeConfigBooleanValue(database, 'EnableDeveloper', false),
|
||||
lowConnectivityMonitorEnabled: observeLowConnectivityMonitor(),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -32,3 +32,5 @@
|
|||
"CollectNetworkMetrics": false,
|
||||
"MonitorNetworkPerformance": true
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1263,6 +1263,9 @@
|
|||
"settings.advanced.delete": "Delete",
|
||||
"settings.advanced.delete_data": "Delete local files",
|
||||
"settings.advanced.delete_message.confirmation": "\nThis will delete all files downloaded through the app for this server. Please confirm to proceed.\n",
|
||||
"settings.advanced.experimental_features": "Experimental Features",
|
||||
"settings.advanced.low_connectivity_monitor": "Low Connectivity Monitor",
|
||||
"settings.advanced.low_connectivity_monitor.description": "Display banners when network connectivity or performance issues are detected",
|
||||
"settings.display": "Display",
|
||||
"settings.notice_mobile_link": "mobile apps",
|
||||
"settings.notice_platform_link": "server",
|
||||
|
|
|
|||
|
|
@ -522,50 +522,113 @@ updateState(websocketState, netInfo, appState) {
|
|||
|
||||
## Configuration
|
||||
|
||||
### Config Flags
|
||||
### User Preference
|
||||
|
||||
#### `assets/base/config.json`
|
||||
```json
|
||||
{
|
||||
"CollectNetworkMetrics": false,
|
||||
"MonitorNetworkPerformance": true
|
||||
}
|
||||
**Low Connectivity Monitor** (User Setting):
|
||||
- Accessible in: Settings → Advanced Settings → Experimental Features
|
||||
- Controls real-time performance monitoring and banner display
|
||||
- Enables early detection and network performance tracking
|
||||
- **Independent** from `CollectNetworkMetrics`
|
||||
- Stored in app-level database via `GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR`
|
||||
- Default: **Enabled** (true)
|
||||
- Observable via `observeLowConnectivityMonitor()` from `@queries/app/global`
|
||||
|
||||
#### Implementation Details
|
||||
|
||||
**Actions** (`app/actions/app/global.ts`):
|
||||
```typescript
|
||||
export const storeLowConnectivityMonitor = async (enabled: boolean) => {
|
||||
return storeGlobal(GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR, enabled, false);
|
||||
};
|
||||
```
|
||||
|
||||
**`CollectNetworkMetrics`** (Existing):
|
||||
- Controls telemetry/analytics collection
|
||||
- Used by `PerformanceMetricsManager`
|
||||
- Tracks request groups, parallel requests, etc.
|
||||
|
||||
**`MonitorNetworkPerformance`** (New):
|
||||
- Controls real-time performance monitoring
|
||||
- Used by `NetworkPerformanceManager`
|
||||
- Enables early detection and banner display
|
||||
- **Independent** from `CollectNetworkMetrics`
|
||||
**Queries** (`app/queries/app/global.ts`):
|
||||
```typescript
|
||||
export const observeLowConnectivityMonitor = () => {
|
||||
const query = queryGlobalValue(GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR);
|
||||
if (!query) {
|
||||
return of$(true); // Default to enabled
|
||||
}
|
||||
return query.observe().pipe(
|
||||
switchMap((result) => (result.length ? result[0].observe() : of$(true))),
|
||||
switchMap((v) => {
|
||||
if (typeof v === 'boolean') {
|
||||
return of$(v);
|
||||
}
|
||||
return of$(v?.value ?? true);
|
||||
}),
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Integration in ClientTracking
|
||||
|
||||
```typescript
|
||||
// Network metrics collection (existing)
|
||||
class ClientTracking {
|
||||
private lowConnectivityMonitorEnabled = true;
|
||||
|
||||
constructor(apiClient: APIClientInterface) {
|
||||
this.apiClient = apiClient;
|
||||
|
||||
// Subscribe to user preference changes
|
||||
observeLowConnectivityMonitor().subscribe((enabled) => {
|
||||
this.lowConnectivityMonitorEnabled = enabled;
|
||||
});
|
||||
}
|
||||
|
||||
startNetworkPerformanceTracking(url: string): string | undefined {
|
||||
if (!this.lowConnectivityMonitorEnabled) {
|
||||
return undefined;
|
||||
}
|
||||
return NetworkPerformanceManager.startRequestTracking(this.apiClient.baseUrl, url);
|
||||
}
|
||||
|
||||
completeNetworkPerformanceTracking(
|
||||
requestId: string | undefined,
|
||||
url: string,
|
||||
metrics: ClientResponseMetrics | undefined
|
||||
) {
|
||||
if (!this.lowConnectivityMonitorEnabled || !requestId || !metrics) {
|
||||
return;
|
||||
}
|
||||
NetworkPerformanceManager.completeRequestTracking(this.apiClient.baseUrl, requestId, metrics);
|
||||
}
|
||||
|
||||
cancelNetworkPerformanceTracking(requestId: string | undefined) {
|
||||
if (!this.lowConnectivityMonitorEnabled || !requestId) {
|
||||
return;
|
||||
}
|
||||
NetworkPerformanceManager.cancelRequestTracking(this.apiClient.baseUrl, requestId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Network metrics collection** (existing - for telemetry):
|
||||
```typescript
|
||||
if (groupLabel && CollectNetworkMetrics) {
|
||||
this.incrementRequestCount(groupLabel);
|
||||
this.trackRequest(groupLabel, url, response.metrics);
|
||||
this.decrementRequestCount(groupLabel);
|
||||
}
|
||||
|
||||
// Performance monitoring (new)
|
||||
if (MonitorNetworkPerformance) {
|
||||
const requestId = NetworkPerformanceManager.startRequestTracking(baseUrl, url);
|
||||
|
||||
try {
|
||||
response = await request(url, options);
|
||||
NetworkPerformanceManager.completeRequestTracking(baseUrl, requestId, response.metrics);
|
||||
} catch (error) {
|
||||
NetworkPerformanceManager.cancelRequestTracking(baseUrl, requestId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Network Manager Configuration
|
||||
|
||||
**Metrics Collection** (`app/managers/network_manager.ts`):
|
||||
```typescript
|
||||
const config = {
|
||||
sessionConfiguration: {
|
||||
timeoutIntervalForRequest: managedConfig?.timeout ? parseInt(managedConfig.timeout, 10) : this.DEFAULT_CONFIG.sessionConfiguration?.timeoutIntervalForRequest,
|
||||
timeoutIntervalForResource: managedConfig?.timeoutVPN ? parseInt(managedConfig.timeoutVPN, 10) : this.DEFAULT_CONFIG.sessionConfiguration?.timeoutIntervalForResource,
|
||||
waitsForConnectivity: managedConfig?.useVPN === 'true',
|
||||
collectMetrics: true, // Always enabled to support Low Connectivity Monitor
|
||||
},
|
||||
headers,
|
||||
};
|
||||
```
|
||||
|
||||
**Note**: `collectMetrics` is now always set to `true` to ensure network metrics are available for the Low Connectivity Monitor. The actual usage of these metrics is controlled dynamically by the user's Low Connectivity Monitor preference in `ClientTracking`.
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle Management
|
||||
|
|
|
|||
Loading…
Reference in a new issue