Add performance metrics to the app (#7953) (#8008)

* Add performance metrics to the app

* add batcher and improve handling

* Add tests

* Fix test

* Address feedback

* Address feedback

* Address feedback

* update podfile

(cherry picked from commit 5f01f9e9af)

Co-authored-by: Daniel Espino García <larkox@gmail.com>
This commit is contained in:
Mattermost Build 2024-06-12 10:51:38 +03:00 committed by GitHub
parent 1594d5e77f
commit 78c01a74f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1383 additions and 28 deletions

View file

@ -0,0 +1,201 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ActionType} from '@constants';
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
import {prepareThreadsFromReceivedPosts} from '@queries/servers/thread';
import NavigationStore from '@store/navigation_store';
import {mockApiClient} from '@test/mock_api_client';
import TestHelper from '@test/test_helper';
import {pushNotificationEntry} from './notification';
import type ServerDataOperator from '@database/operator/server_data_operator';
jest.mock('@managers/performance_metrics_manager');
jest.mock('@store/navigation_store');
const mockedNavigationStore = jest.mocked(NavigationStore);
describe('Performance metrics are set correctly', () => {
const serverUrl = 'http://www.someserverurl.com';
let operator: ServerDataOperator;
let post: Post;
beforeAll(() => {
mockApiClient.get.mockImplementation((url: string) => {
if (url.match(/\/api\/v4\/channels\/[a-z1-90-]*\/posts/)) {
return {status: 200, ok: true, data: {order: [], posts: {}}};
}
if (url.match(/\/api\/v4\/channels\/[a-z1-90-]*\/stats/)) {
return {status: 200, ok: true, data: {}};
}
if (url.match(/\/api\/v4\/posts\/[a-z1-90-]*\/thread/)) {
return {status: 200, ok: true, data: {order: [], posts: {}}};
}
console.log(`GET ${url} not registered in the mock`);
return {status: 404, ok: false};
});
mockApiClient.post.mockImplementation((url: string) => {
if (url.match(/\/api\/v4\/channels\/members\/me\/view/)) {
return {status: 200, ok: true, data: {}};
}
console.log(`POST ${url} not registered in the mock`);
return {status: 404, ok: false};
});
mockedNavigationStore.waitUntilScreenIsTop.mockImplementation(() => Promise.resolve());
// There are no problems when running the tests for this file alone without this line
// but for some reason, when running several tests together, it fails if we don't add this.
mockedNavigationStore.getScreensInStack.mockImplementation(() => []);
});
afterAll(() => {
mockApiClient.get.mockReset();
mockApiClient.post.mockReset();
});
beforeEach(async () => {
const client = await NetworkManager.createClient(serverUrl);
expect(client).toBeTruthy();
operator = (await TestHelper.setupServerDatabase(serverUrl)).operator;
await DatabaseManager.setActiveServerDatabase(serverUrl);
post = TestHelper.fakePost(TestHelper.basicChannel!.id);
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_NEW,
order: [post.id],
posts: [post],
prepareRecordsOnly: false,
});
const threadModels = await prepareThreadsFromReceivedPosts(operator, [post], true);
await operator.batchRecords(threadModels, 'test');
});
afterEach(async () => {
await TestHelper.tearDown();
NetworkManager.invalidateClient(serverUrl);
});
it('channel notification', async () => {
await operator.handleConfigs({
configs: [
{id: 'CollapsedThreads', value: 'default_on'},
{id: 'FeatureFlagCollapsedThreads', value: 'true'},
],
configsToDelete: [],
prepareRecordsOnly: false,
});
await pushNotificationEntry(serverUrl, {
channel_id: TestHelper.basicChannel!.id,
team_id: TestHelper.basicTeam!.id,
isCRTEnabled: false, // isCRTEnabled is not checked at this level
post_id: '', // Post ID is not checked at this level
type: '', // Type is not checked at this level
version: '', // Version is not checked at this level
});
expect(PerformanceMetricsManager.setLoadTarget).toHaveBeenCalledWith('CHANNEL');
});
it('thread notification', async () => {
const commentPost = TestHelper.fakePost(TestHelper.basicChannel!.id);
commentPost.root_id = post.id;
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_NEW,
order: [commentPost.id],
posts: [commentPost],
prepareRecordsOnly: false,
});
const threadModels = await prepareThreadsFromReceivedPosts(operator, [commentPost], true);
await operator.batchRecords(threadModels, 'test');
await operator.handleConfigs({
configs: [
{id: 'CollapsedThreads', value: 'default_on'},
{id: 'FeatureFlagCollapsedThreads', value: 'true'},
],
configsToDelete: [],
prepareRecordsOnly: false,
});
await pushNotificationEntry(serverUrl, {
root_id: post.id,
channel_id: TestHelper.basicChannel!.id,
team_id: TestHelper.basicTeam!.id,
isCRTEnabled: false, // isCRTEnabled is not checked at this level
post_id: '', // Post ID is not checked at this level
type: '', // Type is not checked at this level
version: '', // Version is not checked at this level
});
expect(PerformanceMetricsManager.setLoadTarget).toHaveBeenCalledWith('THREAD');
});
it('thread notification with wrong root id', async () => {
const commentPost = TestHelper.fakePost(TestHelper.basicChannel!.id);
commentPost.root_id = post.id;
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_NEW,
order: [commentPost.id],
posts: [commentPost],
prepareRecordsOnly: false,
});
const threadModels = await prepareThreadsFromReceivedPosts(operator, [commentPost], true);
await operator.batchRecords(threadModels, 'test');
await operator.handleConfigs({
configs: [
{id: 'CollapsedThreads', value: 'default_on'},
{id: 'FeatureFlagCollapsedThreads', value: 'true'},
],
configsToDelete: [],
prepareRecordsOnly: false,
});
await pushNotificationEntry(serverUrl, {
root_id: commentPost.id,
channel_id: TestHelper.basicChannel!.id,
team_id: TestHelper.basicTeam!.id,
isCRTEnabled: false, // isCRTEnabled is not checked at this level
post_id: '', // Post ID is not checked at this level
type: '', // Type is not checked at this level
version: '', // Version is not checked at this level
});
expect(PerformanceMetricsManager.setLoadTarget).toHaveBeenCalledWith('THREAD');
});
it('thread notification with non crt', async () => {
const commentPost = TestHelper.fakePost(TestHelper.basicChannel!.id);
commentPost.root_id = post.id;
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_NEW,
order: [commentPost.id],
posts: [commentPost],
prepareRecordsOnly: false,
});
const threadModels = await prepareThreadsFromReceivedPosts(operator, [commentPost], true);
await operator.batchRecords(threadModels, 'test');
await operator.handleConfigs({
configs: [
{id: 'CollapsedThreads', value: 'disabled'},
{id: 'FeatureFlagCollapsedThreads', value: 'false'},
],
configsToDelete: [],
prepareRecordsOnly: false,
});
await pushNotificationEntry(serverUrl, {
root_id: post.id,
channel_id: TestHelper.basicChannel!.id,
team_id: TestHelper.basicTeam!.id,
isCRTEnabled: false, // isCRTEnabled is not checked at this level
post_id: '', // Post ID is not checked at this level
type: '', // Type is not checked at this level
version: '', // Version is not checked at this level
});
expect(PerformanceMetricsManager.setLoadTarget).toHaveBeenCalledWith('CHANNEL');
});
});

View file

@ -7,6 +7,7 @@ import {fetchMyTeam} from '@actions/remote/team';
import {fetchAndSwitchToThread} from '@actions/remote/thread';
import {getDefaultThemeByAppearance} from '@context/theme';
import DatabaseManager from '@database/manager';
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
import WebsocketManager from '@managers/websocket_manager';
import {getMyChannel} from '@queries/servers/channel';
import {getPostById} from '@queries/servers/post';
@ -103,13 +104,16 @@ export async function pushNotificationEntry(serverUrl: string, notification: Not
const actualRootId = post && ('root_id' in post ? post.root_id : post.rootId);
if (actualRootId) {
PerformanceMetricsManager.setLoadTarget('THREAD');
await fetchAndSwitchToThread(serverUrl, actualRootId, true);
} else if (post) {
PerformanceMetricsManager.setLoadTarget('THREAD');
await fetchAndSwitchToThread(serverUrl, rootId, true);
} else {
emitNotificationError('Post');
}
} else {
PerformanceMetricsManager.setLoadTarget('CHANNEL');
await switchToChannelById(serverUrl, channelId, teamId);
}
}

View file

@ -0,0 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import NetworkManager from '@managers/network_manager';
import {mockApiClient} from '@test/mock_api_client';
import {sendPerformanceReport} from './performance';
describe('sendPerformanceReport', () => {
const serverUrl = 'http://www.someserverurl.com';
const report: PerformanceReport = {
counters: [],
start: 1234,
end: 1235,
histograms: [
{
metric: 'metric1',
timestamp: 1234,
value: 123,
},
{
metric: 'metric1',
timestamp: 1234,
value: 124,
},
{
metric: 'metric2',
timestamp: 1234,
value: 125,
},
],
labels: {
agent: 'rnapp',
platform: 'ios',
},
version: '0.1.0',
};
beforeAll(() => {
mockApiClient.post.mockImplementation(() => ({status: 200, ok: true}));
});
afterAll(() => {
mockApiClient.post.mockReset();
});
beforeEach(async () => {
const client = await NetworkManager.createClient(serverUrl);
expect(client).toBeTruthy();
});
afterEach(async () => {
NetworkManager.invalidateClient(serverUrl);
});
it('happy path', async () => {
const {error} = await sendPerformanceReport(serverUrl, report);
expect(error).toBeFalsy();
expect(mockApiClient.post).toHaveBeenCalledWith(`${serverUrl}/api/v4/client_perf`, {body: report, headers: {}});
});
it('properly returns error', async () => {
mockApiClient.post.mockImplementationOnce(() => ({status: 404, ok: false}));
const {error} = await sendPerformanceReport(serverUrl, report);
expect(error).toBeTruthy();
});
});

View file

@ -0,0 +1,20 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import NetworkManager from '@managers/network_manager';
import {getFullErrorMessage} from '@utils/errors';
import {logDebug} from '@utils/log';
import {forceLogoutIfNecessary} from './session';
export const sendPerformanceReport = async (serverUrl: string, report: PerformanceReport) => {
try {
const client = NetworkManager.getClient(serverUrl);
await client.sendPerformanceReport(report);
return {};
} catch (error) {
logDebug('error on sendPerformanceReport', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};

View file

@ -52,7 +52,7 @@ type AuthorsRequest = {
error?: unknown;
}
export async function createPost(serverUrl: string, post: Partial<Post>, files: FileInfo[] = []): Promise<{data?: boolean; error?: any}> {
export async function createPost(serverUrl: string, post: Partial<Post>, files: FileInfo[] = []): Promise<{data?: boolean; error?: unknown}> {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
@ -571,7 +571,7 @@ export async function fetchPostThread(serverUrl: string, postId: string, options
});
const result = processPostsFetched(data);
let posts: Model[] = [];
if (!fetchOnly) {
if (result.posts.length && !fetchOnly) {
const models: Model[] = [];
posts = await operator.handlePosts({
...result,

View file

@ -226,6 +226,10 @@ export default class ClientBase {
return this.getPluginRoute(Calls.PluginId);
}
getPerformanceRoute() {
return `${this.urlVersion}/client_perf`;
}
doFetch = async (url: string, options: ClientOptions, returnDataOnly = true) => {
let request;
const method = options.method?.toLowerCase();

View file

@ -24,6 +24,7 @@ export interface ClientGeneralMix {
getChannelDataRetentionPolicies: (userId: string, page?: number, perPage?: number) => Promise<PoliciesResponse<ChannelDataRetentionPolicy>>;
getRolesByNames: (rolesNames: string[]) => Promise<Role[]>;
getRedirectLocation: (urlParam: string) => Promise<Record<string, string>>;
sendPerformanceReport: (batch: PerformanceReport) => Promise<{}>;
}
const ClientGeneral = <TBase extends Constructor<ClientBase>>(superclass: TBase) => class extends superclass {
@ -111,6 +112,13 @@ const ClientGeneral = <TBase extends Constructor<ClientBase>>(superclass: TBase)
const url = `${this.getRedirectLocationRoute()}${buildQueryString({url: urlParam})}`;
return this.doFetch(url, {method: 'get'});
};
sendPerformanceReport = async (report: PerformanceReport) => {
return this.doFetch(
this.getPerformanceRoute(),
{method: 'post', body: report},
);
};
};
export default ClientGeneral;

View file

@ -13,6 +13,7 @@ type LoadingProps = {
themeColor?: keyof Theme;
footerText?: string;
footerTextStyles?: TextStyle;
testID?: string;
}
const Loading = ({
@ -22,12 +23,16 @@ const Loading = ({
themeColor,
footerText,
footerTextStyles,
testID,
}: LoadingProps) => {
const theme = useTheme();
const indicatorColor = themeColor ? theme[themeColor] : color;
return (
<View style={containerStyle}>
<View
style={containerStyle}
testID={testID}
>
<ActivityIndicator
color={indicatorColor}
size={size}

View file

@ -154,6 +154,7 @@ const withPost = withObservables(
post: post.observe(),
thread: isCRTEnabled ? observeThreadById(database, post.id) : of$(undefined),
hasReactions,
isLastPost: of$(!nextPost),
};
});

View file

@ -0,0 +1,75 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {type ComponentProps} from 'react';
import NetworkManager from '@managers/network_manager';
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
import {getPostById} from '@queries/servers/post';
import {renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import Post from './post';
import type {Database} from '@nozbe/watermelondb';
import type PostModel from '@typings/database/models/servers/post';
jest.mock('@managers/performance_metrics_manager');
describe('performance metrics', () => {
let database: Database;
let post: PostModel;
function getBaseProps(): ComponentProps<typeof Post> {
return {
appsEnabled: false,
canDelete: false,
customEmojiNames: [],
differentThreadSequence: false,
hasFiles: false,
hasReactions: false,
hasReplies: false,
highlightReplyBar: false,
isEphemeral: false,
isPostAddChannelMember: false,
isPostPriorityEnabled: false,
location: 'Channel',
post,
isLastPost: true,
};
}
const serverUrl = 'http://www.someserverurl.com';
beforeEach(async () => {
const client = await NetworkManager.createClient(serverUrl);
expect(client).toBeTruthy();
database = (await TestHelper.setupServerDatabase(serverUrl)).database;
post = (await getPostById(database, TestHelper.basicPost!.id))!;
});
afterEach(async () => {
await TestHelper.tearDown();
NetworkManager.invalidateClient(serverUrl);
});
it('do not call the performance metrics if it is not the last post', () => {
const props = getBaseProps();
props.isLastPost = false;
renderWithEverything(<Post {...props}/>, {database, serverUrl});
expect(PerformanceMetricsManager.finishLoad).not.toHaveBeenCalled();
expect(PerformanceMetricsManager.endMetric).not.toHaveBeenCalled();
});
it('on channel', () => {
const props = getBaseProps();
renderWithEverything(<Post {...props}/>, {database, serverUrl});
expect(PerformanceMetricsManager.finishLoad).toHaveBeenCalledWith('CHANNEL', serverUrl);
expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledWith('mobile_channel_switch', serverUrl);
});
it('on thread', () => {
const props = getBaseProps();
props.location = 'Thread';
renderWithEverything(<Post {...props}/>, {database, serverUrl});
expect(PerformanceMetricsManager.finishLoad).toHaveBeenCalledWith('THREAD', serverUrl);
expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledWith('mobile_channel_switch', serverUrl);
});
});

View file

@ -17,6 +17,7 @@ import * as Screens from '@constants/screens';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
import {openAsBottomSheet} from '@screens/navigation';
import {hasJumboEmojiOnly} from '@utils/emoji/helpers';
import {fromAutoResponder, isFromWebhook, isPostFailed, isPostPendingOrFailed, isSystemMessage} from '@utils/post';
@ -60,6 +61,7 @@ type PostProps = {
post: PostModel;
rootId?: string;
previousPost?: PostModel;
isLastPost: boolean;
hasReactions: boolean;
searchPatterns?: SearchPattern[];
shouldRenderReplyButton?: boolean;
@ -109,10 +111,39 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
});
const Post = ({
appsEnabled, canDelete, currentUser, customEmojiNames, differentThreadSequence, hasFiles, hasReplies, highlight, highlightPinnedOrSaved = true, highlightReplyBar,
isCRTEnabled, isConsecutivePost, isEphemeral, isFirstReply, isSaved, isLastReply, isPostAcknowledgementEnabled, isPostAddChannelMember, isPostPriorityEnabled,
location, post, rootId, hasReactions, searchPatterns, shouldRenderReplyButton, skipSavedHeader, skipPinnedHeader, showAddReaction = true, style,
testID, thread, previousPost,
appsEnabled,
canDelete,
currentUser,
customEmojiNames,
differentThreadSequence,
hasFiles,
hasReplies,
highlight,
highlightPinnedOrSaved = true,
highlightReplyBar,
isCRTEnabled,
isConsecutivePost,
isEphemeral,
isFirstReply,
isSaved,
isLastReply,
isPostAcknowledgementEnabled,
isPostAddChannelMember,
isPostPriorityEnabled,
location,
post,
rootId,
hasReactions,
searchPatterns,
shouldRenderReplyButton,
skipSavedHeader,
skipPinnedHeader,
showAddReaction = true,
style,
testID,
thread,
previousPost,
isLastPost,
}: PostProps) => {
const pressDetected = useRef(false);
const intl = useIntl();
@ -216,6 +247,19 @@ const Post = ({
};
}, [post.id]);
useEffect(() => {
if (!isLastPost) {
return;
}
if (location !== 'Channel' && location !== 'Thread') {
return;
}
PerformanceMetricsManager.finishLoad(location === 'Thread' ? 'THREAD' : 'CHANNEL', serverUrl);
PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl);
}, []);
const highlightSaved = isSaved && !skipSavedHeader;
const hightlightPinned = post.isPinned && !skipPinnedHeader;
const itemTestID = `${testID}.${post.id}`;

View file

@ -0,0 +1,64 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {render, screen, userEvent} from '@testing-library/react-native';
import React, {type ComponentProps} from 'react';
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
import {getTeamById} from '@queries/servers/team';
import TestHelper from '@test/test_helper';
import TeamItem from './team_item';
import type TeamModel from '@typings/database/models/servers/team';
jest.mock('@managers/performance_metrics_manager');
function getBaseProps(): ComponentProps<typeof TeamItem> {
return {
hasUnreads: false,
mentionCount: 0,
selected: false,
};
}
describe('performance metrics', () => {
const serverUrl = 'http://www.someserverurl.com';
let team: TeamModel | undefined;
beforeAll(() => {
jest.useFakeTimers({legacyFakeTimers: true});
});
afterAll(() => {
jest.useRealTimers();
});
beforeEach(async () => {
userEvent.setup({advanceTimers: jest.advanceTimersByTime});
const {database} = await TestHelper.setupServerDatabase(serverUrl);
team = await getTeamById(database, TestHelper.basicTeam!.id);
});
afterEach(async () => {
await TestHelper.tearDown();
});
it('happy path', async () => {
const baseProps = getBaseProps();
baseProps.team = team;
render(<TeamItem {...baseProps}/>);
const button = await screen.findByTestId(`team_sidebar.team_list.team_item.${team!.id}.not_selected`);
await userEvent.press(button);
expect(PerformanceMetricsManager.startMetric).toHaveBeenCalledWith('mobile_team_switch');
});
it('do not start when the team is already selected', async () => {
const baseProps = getBaseProps();
baseProps.team = team;
baseProps.selected = true;
render(<TeamItem {...baseProps}/>);
const button = await screen.findByTestId(`team_sidebar.team_list.team_item.${team!.id}.selected`);
await userEvent.press(button);
expect(PerformanceMetricsManager.startMetric).not.toHaveBeenCalled();
});
});

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import React, {useCallback} from 'react';
import {View} from 'react-native';
import {handleTeamChange} from '@actions/remote/team';
@ -9,6 +9,7 @@ import Badge from '@components/badge';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
import {makeStyleSheetFromTheme} from '@utils/theme';
import TeamIcon from './team_icon';
@ -62,6 +63,15 @@ export default function TeamItem({team, hasUnreads, mentionCount, selected}: Pro
const styles = getStyleSheet(theme);
const serverUrl = useServerUrl();
const onPress = useCallback(() => {
if (!team || selected) {
return;
}
PerformanceMetricsManager.startMetric('mobile_team_switch');
handleTeamChange(serverUrl, team.id);
}, [selected, team?.id, serverUrl]);
if (!team) {
return null;
}
@ -92,7 +102,7 @@ export default function TeamItem({team, hasUnreads, mentionCount, selected}: Pro
<>
<View style={[styles.container, selected ? styles.containerSelected : undefined]}>
<TouchableWithFeedback
onPress={() => handleTeamChange(serverUrl, team.id)}
onPress={onPress}
type='opacity'
testID={teamItemTestId}
>

View file

@ -12,6 +12,7 @@ import LocalConfig from '@assets/config.json';
import {DeepLink, Events, Launch, PushNotification} from '@constants';
import DatabaseManager from '@database/manager';
import {getActiveServerUrl, getServerCredentials, removeServerCredentials} from '@init/credentials';
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
import {getLastViewedChannelIdAndServer, getOnboardingViewed, getLastViewedThreadIdAndServer} from '@queries/app/global';
import {getThemeForCurrentTeam} from '@queries/servers/preference';
import {getCurrentUserId} from '@queries/servers/system';
@ -178,9 +179,13 @@ const launchToHome = async (props: LaunchProps) => {
const lastViewedThread = await getLastViewedThreadIdAndServer();
if (lastViewedThread && lastViewedThread.server_url === props.serverUrl && lastViewedThread.thread_id) {
PerformanceMetricsManager.setLoadTarget('THREAD');
fetchAndSwitchToThread(props.serverUrl!, lastViewedThread.thread_id);
} else if (lastViewedChannel && lastViewedChannel.server_url === props.serverUrl && lastViewedChannel.channel_id) {
PerformanceMetricsManager.setLoadTarget('CHANNEL');
switchToChannelById(props.serverUrl!, lastViewedChannel.channel_id);
} else {
PerformanceMetricsManager.setLoadTarget('HOME');
}
appEntry(props.serverUrl!);

View file

@ -0,0 +1,209 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import performance from 'react-native-performance';
import {mockApiClient} from '@test/mock_api_client';
import TestHelper from '@test/test_helper';
import NetworkManager from '../network_manager';
import {getBaseReportRequest} from './test_utils';
import PerformanceMetricsManager from '.';
const TEST_EPOCH = 1577836800000;
jest.mock('@utils/log', () => ({
logDebug: () => '',
}));
performance.timeOrigin = TEST_EPOCH;
describe('load metrics', () => {
const serverUrl = 'http://www.someserverurl.com/';
const expectedUrl = `${serverUrl}/api/v4/client_perf`;
const measure: PerformanceReportMeasure = {
metric: 'mobile_load',
timestamp: TEST_EPOCH + 61000,
value: 61000,
};
beforeEach(async () => {
NetworkManager.createClient(serverUrl);
const {operator} = await TestHelper.setupServerDatabase(serverUrl);
await operator.handleConfigs({configs: [{id: 'EnableClientMetrics', value: 'true'}], configsToDelete: [], prepareRecordsOnly: false});
jest.useFakeTimers({doNotFake: [
'cancelAnimationFrame',
'cancelIdleCallback',
'clearImmediate',
'clearInterval',
'clearTimeout',
'hrtime',
'nextTick',
'queueMicrotask',
'requestAnimationFrame',
'requestIdleCallback',
'setImmediate',
'setInterval',
]}).setSystemTime(new Date(TEST_EPOCH));
});
afterEach(async () => {
jest.useRealTimers();
NetworkManager.invalidateClient(serverUrl);
await TestHelper.tearDown();
});
it('only load on target', async () => {
performance.mark('nativeLaunchStart');
const expectedRequest = getBaseReportRequest(measure.timestamp, measure.timestamp + 1);
expectedRequest.body.histograms = [measure];
PerformanceMetricsManager.setLoadTarget('CHANNEL');
PerformanceMetricsManager.finishLoad('HOME', serverUrl);
await TestHelper.tick();
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).not.toHaveBeenCalled();
PerformanceMetricsManager.finishLoad('CHANNEL', serverUrl);
await TestHelper.tick();
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl, expectedRequest);
});
});
describe('other metrics', () => {
const serverUrl1 = 'http://www.someserverurl.com/';
const expectedUrl1 = `${serverUrl1}/api/v4/client_perf`;
const serverUrl2 = 'http://www.otherserverurl.com/';
const expectedUrl2 = `${serverUrl2}/api/v4/client_perf`;
const measure1: PerformanceReportMeasure = {
metric: 'mobile_channel_switch',
timestamp: TEST_EPOCH + 100,
value: 100,
};
const measure2: PerformanceReportMeasure = {
metric: 'mobile_team_switch',
timestamp: TEST_EPOCH + 150 + 50,
value: 150,
};
beforeEach(async () => {
NetworkManager.createClient(serverUrl1);
NetworkManager.createClient(serverUrl2);
const {operator: operator1} = await TestHelper.setupServerDatabase(serverUrl1);
const {operator: operator2} = await TestHelper.setupServerDatabase(serverUrl2);
await operator1.handleConfigs({configs: [{id: 'EnableClientMetrics', value: 'true'}], configsToDelete: [], prepareRecordsOnly: false});
await operator2.handleConfigs({configs: [{id: 'EnableClientMetrics', value: 'true'}], configsToDelete: [], prepareRecordsOnly: false});
jest.useFakeTimers({doNotFake: [
'cancelAnimationFrame',
'cancelIdleCallback',
'clearImmediate',
'clearInterval',
'clearTimeout',
'hrtime',
'nextTick',
'queueMicrotask',
'requestAnimationFrame',
'requestIdleCallback',
'setImmediate',
'setInterval',
]}).setSystemTime(new Date(TEST_EPOCH));
});
afterEach(async () => {
jest.useRealTimers();
NetworkManager.invalidateClient(serverUrl1);
NetworkManager.invalidateClient(serverUrl2);
await TestHelper.tearDown();
});
it('do not send metrics when we do not start them', async () => {
PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1);
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).not.toHaveBeenCalled();
});
it('send metric after it has been started', async () => {
const expectedRequest = getBaseReportRequest(measure1.timestamp, measure1.timestamp + 1);
expectedRequest.body.histograms = [measure1];
PerformanceMetricsManager.startMetric('mobile_channel_switch');
jest.advanceTimersByTime(100);
PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1);
await TestHelper.tick();
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl1, expectedRequest);
});
it('a second end metric does not generate a second measure', async () => {
const expectedRequest = getBaseReportRequest(measure1.timestamp, measure1.timestamp + 1);
expectedRequest.body.histograms = [measure1];
PerformanceMetricsManager.startMetric('mobile_channel_switch');
jest.advanceTimersByTime(100);
PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1);
await TestHelper.tick();
jest.advanceTimersByTime(100);
PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1);
await TestHelper.tick();
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl1, expectedRequest);
});
it('different metrics do not interfere', async () => {
const expectedRequest = getBaseReportRequest(measure1.timestamp, measure2.timestamp);
expectedRequest.body.histograms = [measure1, measure2];
PerformanceMetricsManager.startMetric('mobile_channel_switch');
jest.advanceTimersByTime(50);
PerformanceMetricsManager.startMetric('mobile_team_switch');
jest.advanceTimersByTime(50);
PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1);
await TestHelper.tick();
jest.advanceTimersByTime(100);
PerformanceMetricsManager.endMetric('mobile_team_switch', serverUrl1);
await TestHelper.tick();
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl1, expectedRequest);
});
it('metrics to different servers do not interfere', async () => {
const expectedRequest1 = getBaseReportRequest(measure1.timestamp, measure1.timestamp + 1);
expectedRequest1.body.histograms = [measure1];
const expectedRequest2 = getBaseReportRequest(measure2.timestamp, measure2.timestamp + 1);
expectedRequest2.body.histograms = [measure2];
PerformanceMetricsManager.startMetric('mobile_channel_switch');
jest.advanceTimersByTime(50);
PerformanceMetricsManager.startMetric('mobile_team_switch');
jest.advanceTimersByTime(50);
PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1);
await TestHelper.tick();
jest.advanceTimersByTime(100);
PerformanceMetricsManager.endMetric('mobile_team_switch', serverUrl2);
await TestHelper.tick();
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl1, expectedRequest1);
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl2, expectedRequest2);
});
});

View file

@ -0,0 +1,85 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {AppState, type AppStateStatus} from 'react-native';
import performance from 'react-native-performance';
import Batcher from './performance_metrics_batcher';
type Target = 'HOME' | 'CHANNEL' | 'THREAD' | undefined;
type MetricName = 'mobile_channel_switch' |
'mobile_team_switch';
class PerformanceMetricsManager {
private target: Target;
private batchers: {[serverUrl: string]: Batcher} = {};
private hasRegisteredLoad = false;
private lastAppStateIsActive = AppState.currentState === 'active';
constructor() {
AppState.addEventListener('change', (appState) => this.onAppStateChange(appState));
}
private onAppStateChange(appState: AppStateStatus) {
const isAppStateActive = appState === 'active';
if (this.lastAppStateIsActive !== isAppStateActive && !isAppStateActive) {
for (const batcher of Object.values(this.batchers)) {
batcher.forceSend();
}
}
this.lastAppStateIsActive = isAppStateActive;
}
private ensureBatcher(serverUrl: string) {
if (this.batchers[serverUrl]) {
return this.batchers[serverUrl];
}
this.batchers[serverUrl] = new Batcher(serverUrl);
return this.batchers[serverUrl];
}
public setLoadTarget(target: Target) {
this.target = target;
}
public finishLoad(location: Target, serverUrl: string) {
if (this.target !== location || this.hasRegisteredLoad) {
return;
}
const measure = performance.measure('mobile_load', 'nativeLaunchStart');
this.ensureBatcher(serverUrl).addToBatch({
metric: 'mobile_load',
value: measure.duration,
timestamp: Date.now(),
});
performance.clearMeasures('mobile_load');
this.hasRegisteredLoad = true;
}
public startMetric(metricName: MetricName) {
performance.mark(metricName);
}
public endMetric(metricName: MetricName, serverUrl: string) {
const marks = performance.getEntriesByName(metricName, 'mark');
if (!marks.length) {
return;
}
const measureName = `${metricName}_measure`;
const measure = performance.measure(measureName, metricName);
this.ensureBatcher(serverUrl).addToBatch({
metric: metricName,
value: measure.duration,
timestamp: Date.now(),
});
performance.clearMarks(metricName);
performance.clearMeasures(measureName);
}
}
export default new PerformanceMetricsManager();

View file

@ -0,0 +1,195 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import NetworkManager from '@managers/network_manager';
import {mockApiClient} from '@test/mock_api_client';
import TestHelper from '@test/test_helper';
import Batcher from './performance_metrics_batcher';
import {getBaseReportRequest} from './test_utils';
import type ServerDataOperator from '@database/operator/server_data_operator';
jest.mock('@utils/log', () => ({
logDebug: () => '',
}));
describe('perfromance metrics batcher', () => {
const serverUrl = 'http://www.someserverurl.com';
const expectedUrl = `${serverUrl}/api/v4/client_perf`;
const measure1: PerformanceReportMeasure = {
metric: 'someMetric',
timestamp: 1234,
value: 1.5,
};
let operator: ServerDataOperator;
const measure2: PerformanceReportMeasure = {
metric: 'someOtherMetric',
timestamp: 1235,
value: 2.5,
};
const measure3: PerformanceReportMeasure = {
metric: 'yetAnother',
timestamp: 1236,
value: 0.5,
};
async function setMetricsConfig(value: string) {
await operator.handleConfigs({configs: [{id: 'EnableClientMetrics', value}], configsToDelete: [], prepareRecordsOnly: false});
}
beforeEach(async () => {
NetworkManager.createClient(serverUrl);
operator = (await TestHelper.setupServerDatabase(serverUrl)).operator;
await setMetricsConfig('true');
jest.useFakeTimers({doNotFake: [
'Date',
'cancelAnimationFrame',
'cancelIdleCallback',
'clearImmediate',
'clearInterval',
'clearTimeout',
'hrtime',
'nextTick',
'performance',
'queueMicrotask',
'requestAnimationFrame',
'requestIdleCallback',
'setImmediate',
'setInterval',
]});
});
afterEach(async () => {
jest.useRealTimers();
NetworkManager.invalidateClient(serverUrl);
await TestHelper.tearDown();
});
it('properly send batches only after timeout', async () => {
const batcher = new Batcher(serverUrl);
const expectedRequest = getBaseReportRequest(measure1.timestamp, measure2.timestamp);
expectedRequest.body.histograms = [measure1, measure2];
batcher.addToBatch(measure1);
await TestHelper.tick();
expect(mockApiClient.post).not.toHaveBeenCalled();
batcher.addToBatch(measure2);
await TestHelper.tick();
expect(mockApiClient.post).not.toHaveBeenCalled();
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl, expectedRequest);
});
it('properly set end after start when only one element', async () => {
const batcher = new Batcher(serverUrl);
const expectedRequest = getBaseReportRequest(measure1.timestamp, measure1.timestamp + 1);
expectedRequest.body.histograms = [measure1];
batcher.addToBatch(measure1);
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl, expectedRequest);
});
it('send the batch directly after maximum batch size is reached', async () => {
const batcher = new Batcher(serverUrl);
const expectedRequest = getBaseReportRequest(measure1.timestamp, measure2.timestamp);
for (let i = 0; i < 99; i++) {
batcher.addToBatch(measure1);
expectedRequest.body.histograms.push(measure1);
}
await TestHelper.tick();
expect(mockApiClient.post).not.toHaveBeenCalled();
batcher.addToBatch(measure2);
expectedRequest.body.histograms.push(measure2);
await TestHelper.tick();
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl, expectedRequest);
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).toHaveBeenCalledTimes(1);
});
it('do not send batches when the config is set to false', async () => {
await setMetricsConfig('false');
const batcher = new Batcher(serverUrl);
batcher.addToBatch(measure2);
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).not.toHaveBeenCalled();
});
it('do not send batches when the config is set to false even on force send', async () => {
await setMetricsConfig('false');
const batcher = new Batcher(serverUrl);
batcher.addToBatch(measure2);
batcher.forceSend();
await TestHelper.tick();
expect(mockApiClient.post).not.toHaveBeenCalled();
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).not.toHaveBeenCalled();
});
it('old elements do not drip into the next batch', async () => {
const batcher = new Batcher(serverUrl);
let expectedRequest = getBaseReportRequest(measure1.timestamp, measure1.timestamp + 1);
expectedRequest.body.histograms = [measure1];
batcher.addToBatch(measure1);
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).toHaveBeenLastCalledWith(expectedUrl, expectedRequest);
expectedRequest = getBaseReportRequest(measure2.timestamp, measure2.timestamp + 1);
expectedRequest.body.histograms = [];
for (let i = 0; i < 100; i++) {
batcher.addToBatch(measure2);
expectedRequest.body.histograms.push(measure2);
}
await TestHelper.tick();
expect(mockApiClient.post).toHaveBeenLastCalledWith(expectedUrl, expectedRequest);
expectedRequest = getBaseReportRequest(measure3.timestamp, measure3.timestamp + 1);
expectedRequest.body.histograms = [measure3];
batcher.addToBatch(measure3);
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).toHaveBeenLastCalledWith(expectedUrl, expectedRequest);
});
it('force send sends the batch, and does not get resent after the timeout', async () => {
const batcher = new Batcher(serverUrl);
const expectedRequest = getBaseReportRequest(measure1.timestamp, measure2.timestamp);
expectedRequest.body.histograms = [measure1, measure2];
batcher.addToBatch(measure1);
await TestHelper.tick();
expect(mockApiClient.post).not.toHaveBeenCalled();
batcher.addToBatch(measure2);
await TestHelper.tick();
expect(mockApiClient.post).not.toHaveBeenCalled();
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl, expectedRequest);
mockApiClient.post.mockClear();
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,103 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Platform} from 'react-native';
import {sendPerformanceReport} from '@actions/remote/performance';
import DatabaseManager from '@database/manager';
import {getConfigValue} from '@queries/servers/system';
import {toMilliseconds} from '@utils/datetime';
import {logDebug} from '@utils/log';
const MAX_BATCH_SIZE = 100;
const INTERVAL_TIME = toMilliseconds({seconds: 60});
class Batcher {
private batch: PerformanceReportMeasure[] = [];
private serverUrl: string;
private sendTimeout: NodeJS.Timeout | undefined;
constructor(serverUrl: string) {
this.serverUrl = serverUrl;
}
private started() {
return Boolean(this.sendTimeout);
}
private clearTimeout() {
clearTimeout(this.sendTimeout);
this.sendTimeout = undefined;
}
private start() {
this.clearTimeout();
this.sendTimeout = setTimeout(() => this.sendBatch(), INTERVAL_TIME);
}
private async sendBatch() {
this.clearTimeout();
if (this.batch.length === 0) {
return;
}
const toSend = this.getReport();
// Empty the batch as soon as possible to avoid race conditions
this.batch = [];
const database = DatabaseManager.serverDatabases[this.serverUrl]?.database;
if (!database) {
return;
}
const clientPerformanceSetting = await getConfigValue(database, 'EnableClientMetrics');
if (clientPerformanceSetting !== 'true') {
return;
}
await sendPerformanceReport(this.serverUrl, toSend);
}
private getReport(): PerformanceReport {
let start = this.batch[0].timestamp;
let end = this.batch[0].timestamp;
for (const measure of this.batch) {
start = Math.min(start, measure.timestamp);
end = Math.max(end, measure.timestamp);
}
if (start === end) {
end += 1;
}
return {
version: '0.1.0',
labels: {
platform: Platform.select({ios: 'ios', default: 'android'}),
agent: 'rnapp',
},
start,
end,
counters: [],
histograms: this.batch,
};
}
public addToBatch(measure: PerformanceReportMeasure) {
if (!this.started()) {
this.start();
}
logDebug('Performance metric:', measure);
this.batch.push(measure);
if (this.batch.length >= MAX_BATCH_SIZE) {
this.sendBatch();
}
}
public forceSend() {
this.sendBatch();
}
}
export default Batcher;

View file

@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export function getBaseReportRequest(start: number, end: number): {body: PerformanceReport; headers: {}} {
return {
body: {
version: '0.1.0',
start,
end,
labels: {agent: 'rnapp', platform: 'ios'},
histograms: [],
counters: [],
},
headers: {},
};
}

View file

@ -0,0 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
type PerformanceReportMeasure = {
metric: string;
value: number;
timestamp: number;
}
type PerformanceReport = {
version: '0.1.0';
labels: {
platform: PlatformLabel;
agent: 'rnapp';
};
start: number;
end: number;
counters: PerformanceReportMeasure[];
histograms: PerformanceReportMeasure[];
}

View file

@ -2,14 +2,19 @@
// See LICENSE.txt for license information.
import React from 'react';
import {DeviceEventEmitter} from 'react-native';
import {renderWithEverything} from '@test/intl-test-helper';
import {Events} from '@constants';
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
import {renderWithEverything, act, waitFor, screen, waitForElementToBeRemoved} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import Categories from '.';
import type Database from '@nozbe/watermelondb/Database';
jest.mock('@managers/performance_metrics_manager');
describe('components/channel_list/categories', () => {
let database: Database;
beforeAll(async () => {
@ -17,6 +22,10 @@ describe('components/channel_list/categories', () => {
database = server.database;
});
afterAll(async () => {
await TestHelper.tearDown();
});
it('render without error', () => {
const wrapper = renderWithEverything(
<Categories/>,
@ -26,3 +35,37 @@ describe('components/channel_list/categories', () => {
expect(wrapper.toJSON()).toBeTruthy();
});
});
describe('performance metrics', () => {
let database: Database;
const serverUrl = 'http://www.someserverurl.com';
beforeAll(async () => {
const server = await TestHelper.setupServerDatabase(serverUrl);
database = server.database;
});
afterAll(async () => {
await TestHelper.tearDown();
});
it('properly send metric on load', () => {
renderWithEverything(<Categories/>, {database, serverUrl});
expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledWith('mobile_team_switch', serverUrl);
});
it('properly call again after switching teams', async () => {
renderWithEverything(<Categories/>, {database, serverUrl});
expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledTimes(1);
act(() => {
DeviceEventEmitter.emit(Events.TEAM_SWITCH, true);
});
await waitFor(() => expect(screen.queryByTestId('categories.loading')).toBeVisible());
expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledTimes(1);
act(() => {
DeviceEventEmitter.emit(Events.TEAM_SWITCH, false);
});
await waitForElementToBeRemoved(() => screen.queryByTestId('categories.loading'));
expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledTimes(2);
expect(PerformanceMetricsManager.endMetric).toHaveBeenLastCalledWith('mobile_team_switch', serverUrl);
});
});

View file

@ -10,6 +10,7 @@ import Loading from '@components/loading';
import {useServerUrl} from '@context/server';
import {useIsTablet} from '@hooks/device';
import {useTeamSwitch} from '@hooks/team_switch';
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
import CategoryBody from './body';
import LoadCategoriesError from './error';
@ -68,6 +69,7 @@ const Categories = ({
const [initiaLoad, setInitialLoad] = useState(!categoriesToShow.length);
const onChannelSwitch = useCallback(async (c: Channel | ChannelModel) => {
PerformanceMetricsManager.startMetric('mobile_channel_switch');
switchToChannelById(serverUrl, c.id);
}, [serverUrl]);
@ -103,6 +105,14 @@ const Categories = ({
return () => clearTimeout(t);
}, []);
useEffect(() => {
if (switchingTeam) {
return;
}
PerformanceMetricsManager.endMetric('mobile_team_switch', serverUrl);
}, [switchingTeam]);
if (!categories.length) {
return <LoadCategoriesError/>;
}
@ -139,6 +149,7 @@ const Categories = ({
<Loading
size='large'
themeColor='sidebarText'
testID='categories.loading'
/>
</View>
)}

View file

@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {type ComponentProps} from 'react';
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
import {renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import ChannelListScreen from './channel_list';
import type {Database} from '@nozbe/watermelondb';
jest.mock('@managers/performance_metrics_manager');
jest.mock('@react-navigation/native', () => ({
useIsFocused: () => true,
useNavigation: () => ({isFocused: () => true}),
useRoute: () => ({}),
}));
function getBaseProps(): ComponentProps<typeof ChannelListScreen> {
return {
hasChannels: true,
hasCurrentUser: true,
hasMoreThanOneTeam: true,
hasTeams: true,
isCRTEnabled: true,
isLicensed: true,
launchType: 'normal',
showIncomingCalls: true,
showToS: false,
currentUserId: 'someId',
};
}
describe('performance metrics', () => {
let database: Database;
const serverUrl = 'http://www.someserverurl.com';
beforeAll(async () => {
const server = await TestHelper.setupServerDatabase(serverUrl);
database = server.database;
});
it('finish load on load', () => {
jest.useFakeTimers();
const props = getBaseProps();
renderWithEverything(<ChannelListScreen {...props}/>, {database, serverUrl});
expect(PerformanceMetricsManager.finishLoad).toHaveBeenCalledWith('HOME', serverUrl);
jest.useRealTimers();
});
});

View file

@ -18,6 +18,7 @@ import {Navigation as NavigationConstants, Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
import {resetToTeams, openToS} from '@screens/navigation';
import NavigationStore from '@store/navigation_store';
import {isMainActivity} from '@utils/helpers';
@ -169,6 +170,10 @@ const ChannelListScreen = (props: ChannelProps) => {
}
}, []);
useEffect(() => {
PerformanceMetricsManager.finishLoad('HOME', serverUrl);
}, []);
return (
<>
<Animated.View style={top}/>

View file

@ -952,6 +952,8 @@ PODS:
- react-native-paste-input (0.7.1):
- React-Core
- Swime (= 3.0.6)
- react-native-performance (5.1.2):
- React-Core
- react-native-safe-area-context (4.9.0):
- React-Core
- react-native-video (5.2.1):
@ -1267,6 +1269,7 @@ DEPENDENCIES:
- "react-native-network-client (from `../node_modules/@mattermost/react-native-network-client`)"
- react-native-notifications (from `../node_modules/react-native-notifications`)
- "react-native-paste-input (from `../node_modules/@mattermost/react-native-paste-input`)"
- react-native-performance (from `../node_modules/react-native-performance`)
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
- react-native-video (from `../node_modules/react-native-video`)
- react-native-webrtc (from `../node_modules/react-native-webrtc`)
@ -1424,6 +1427,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-notifications"
react-native-paste-input:
:path: "../node_modules/@mattermost/react-native-paste-input"
react-native-performance:
:path: "../node_modules/react-native-performance"
react-native-safe-area-context:
:path: "../node_modules/react-native-safe-area-context"
react-native-video:
@ -1581,6 +1586,7 @@ SPEC CHECKSUMS:
react-native-network-client: a7e0e465f0de5ea75cef5c557df0d9dc0adbf6a9
react-native-notifications: 4601a5a8db4ced6ae7cfc43b44d35fe437ac50c4
react-native-paste-input: d2136a8269eb8ad57d81407ee2b8a646f738a694
react-native-performance: ff93f8af3b2ee9519fd7879896aa9b8b8272691d
react-native-safe-area-context: b97eb6f9e3b7f437806c2ce5983f479f8eb5de4b
react-native-video: c26780b224543c62d5e1b2a7244a5cd1b50e8253
react-native-webrtc: 255a1172fd31525b952b36aef7b8e9a41de325e5

9
package-lock.json generated
View file

@ -79,6 +79,7 @@
"react-native-math-view": "3.9.5",
"react-native-navigation": "7.39.1",
"react-native-notifications": "5.1.0",
"react-native-performance": "5.1.2",
"react-native-permissions": "4.1.5",
"react-native-reanimated": "3.8.1",
"react-native-safe-area-context": "4.9.0",
@ -17258,6 +17259,14 @@
"react-native": "*"
}
},
"node_modules/react-native-performance": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/react-native-performance/-/react-native-performance-5.1.2.tgz",
"integrity": "sha512-l5JOJphNzox9a9icL3T6O/gEqZuqWqcbejW04WPa10m0UanBdIYrNkPFl48B3ivWw3MabpjB6GiDYv7old9/fw==",
"peerDependencies": {
"react-native": "*"
}
},
"node_modules/react-native-permissions": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/react-native-permissions/-/react-native-permissions-4.1.5.tgz",

View file

@ -80,6 +80,7 @@
"react-native-math-view": "3.9.5",
"react-native-navigation": "7.39.1",
"react-native-notifications": "5.1.0",
"react-native-performance": "5.1.2",
"react-native-permissions": "4.1.5",
"react-native-reanimated": "3.8.1",
"react-native-safe-area-context": "4.9.0",

View file

@ -2,11 +2,12 @@
// See LICENSE.txt for license information.
import {DatabaseProvider} from '@nozbe/watermelondb/react';
import {render} from '@testing-library/react-native';
import {render, type RenderOptions} from '@testing-library/react-native';
import React, {type ReactElement} from 'react';
import {IntlProvider} from 'react-intl';
import {SafeAreaProvider} from 'react-native-safe-area-context';
import ServerUrlProvider from '@context/server';
import {ThemeContext, getDefaultThemeByAppearance} from '@context/theme';
import {getTranslations} from '@i18n';
@ -48,13 +49,13 @@ export function renderWithIntlAndTheme(ui: ReactElement, {locale = 'en', ...rend
return render(ui, {wrapper: Wrapper, ...renderOptions});
}
export function renderWithEverything(ui: ReactElement, {locale = 'en', database, ...renderOptions}: {locale?: string; database?: Database; renderOptions?: any} = {}) {
export function renderWithEverything(ui: ReactElement, {locale = 'en', database, serverUrl, ...renderOptions}: {locale?: string; database?: Database; serverUrl?: string; renderOptions?: RenderOptions} = {}) {
function Wrapper({children}: {children: ReactElement}) {
if (!database) {
return null;
}
return (
const wrapper = (
<DatabaseProvider database={database}>
<IntlProvider
locale={locale}
@ -68,6 +69,16 @@ export function renderWithEverything(ui: ReactElement, {locale = 'en', database,
</IntlProvider>
</DatabaseProvider>
);
if (serverUrl) {
return (
<ServerUrlProvider server={{displayName: serverUrl, url: serverUrl}}>
{wrapper}
</ServerUrlProvider>
);
}
return wrapper;
}
return render(ui, {wrapper: Wrapper, ...renderOptions});

11
test/mock_api_client.ts Normal file
View file

@ -0,0 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {RequestOptions} from '@mattermost/react-native-network-client';
export const mockApiClient = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
get: jest.fn((url: string, options?: RequestOptions) => ({status: 200, ok: true})),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
post: jest.fn((url: string, options?: RequestOptions) => ({status: 200, ok: true})),
};

View file

@ -8,6 +8,9 @@ import * as ReactNative from 'react-native';
import mockSafeAreaContext from 'react-native-safe-area-context/jest/mock';
import {v4 as uuidv4} from 'uuid';
import {mockApiClient} from './mock_api_client';
import type {RequestOptions} from '@mattermost/react-native-network-client';
import type {ReadDirItem, StatResult} from 'react-native-fs';
import 'react-native-gesture-handler/jestSetup';
@ -186,17 +189,11 @@ jest.doMock('react-native', () => {
jest.mock('react-native-vector-icons', () => {
const React = jest.requireActual('react');
const PropTypes = jest.requireActual('prop-types');
class CompassIcon extends React.PureComponent {
render() {
return React.createElement('Icon', this.props);
}
}
CompassIcon.propTypes = {
name: PropTypes.string,
size: PropTypes.number,
style: PropTypes.oneOfType([PropTypes.array, PropTypes.number, PropTypes.object]),
};
CompassIcon.getImageSource = jest.fn().mockResolvedValue({});
return {
createIconSet: () => CompassIcon,
@ -256,6 +253,8 @@ jest.mock('react-native-device-info', () => {
hasNotch: jest.fn(() => true),
isTablet: jest.fn(() => false),
getApplicationName: jest.fn(() => 'Mattermost'),
getSystemName: jest.fn(() => 'ios'),
getSystemVersion: jest.fn(() => '0.0.0'),
};
});
@ -367,6 +366,8 @@ jest.mock('@screens/navigation', () => ({
popToRoot: jest.fn(() => Promise.resolve()),
dismissModal: jest.fn(() => Promise.resolve()),
dismissAllModals: jest.fn(() => Promise.resolve()),
dismissAllModalsAndPopToScreen: jest.fn(),
dismissAllModalsAndPopToRoot: jest.fn(),
dismissOverlay: jest.fn(() => Promise.resolve()),
}));
@ -386,6 +387,22 @@ jest.mock('@mattermost/react-native-emm', () => ({
useManagedConfig: () => ({}),
}));
jest.mock('@react-native-clipboard/clipboard', () => ({}));
jest.mock('react-native-document-picker', () => ({}));
jest.mock('@mattermost/react-native-network-client', () => ({
getOrCreateAPIClient: (serverUrl: string) => ({client: {
baseUrl: serverUrl,
get: (url: string, options?: RequestOptions) => mockApiClient.get(`${serverUrl}${url}`, options),
post: (url: string, options?: RequestOptions) => mockApiClient.post(`${serverUrl}${url}`, options),
invalidate: jest.fn(),
}}),
RetryTypes: {
EXPONENTIAL_RETRY: 'exponential',
},
}));
jest.mock('react-native-safe-area-context', () => mockSafeAreaContext);
jest.mock('react-native-reanimated', () => require('react-native-reanimated/mock'));
@ -399,7 +416,15 @@ jest.mock('react-native-haptic-feedback', () => {
};
});
declare const global: {requestAnimationFrame: (callback: any) => void};
declare const global: {
requestAnimationFrame: (callback: () => void) => void;
performance: {
now: () => number;
};
};
global.requestAnimationFrame = (callback) => {
setTimeout(callback, 0);
};
global.performance.now = () => Date.now();

View file

@ -10,6 +10,7 @@ import nock from 'nock';
import Config from '@assets/config.json';
import {Client} from '@client/rest';
import {ActionType} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import {PUSH_PROXY_STATUS_VERIFIED} from '@constants/push_proxy';
import DatabaseManager from '@database/manager';
@ -18,7 +19,6 @@ import {generateId} from '@utils/general';
import type {APIClientInterface} from '@mattermost/react-native-network-client';
const PASSWORD = 'password1';
const DEFAULT_LOCALE = 'en';
class TestHelper {
@ -51,8 +51,8 @@ class TestHelper {
this.basicRoles = null;
}
setupServerDatabase = async () => {
const serverUrl = 'https://appv1.mattermost.com';
setupServerDatabase = async (url?: string) => {
const serverUrl = url || 'https://appv1.mattermost.com';
await DatabaseManager.init([serverUrl]);
const {database, operator} = DatabaseManager.serverDatabases[serverUrl]!;
@ -112,6 +112,13 @@ class TestHelper {
systems: [{id: SYSTEM_IDENTIFIERS.PUSH_VERIFICATION_STATUS, value: PUSH_PROXY_STATUS_VERIFIED}],
});
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_NEW,
order: [this.basicPost!.id],
posts: [this.basicPost!],
prepareRecordsOnly: false,
});
return {database, operator};
};
@ -291,7 +298,7 @@ class TestHelper {
return 'success' + this.generateId() + '@simulator.amazonses.com';
};
fakePost = (channelId: string) => {
fakePost = (channelId: string, userId?: string): Post => {
const time = Date.now();
return {
@ -301,6 +308,17 @@ class TestHelper {
update_at: time,
message: `Unit Test ${this.generateId()}`,
type: '',
delete_at: 0,
edit_at: 0,
hashtags: '',
is_pinned: false,
metadata: {},
original_id: '',
pending_post_id: '',
props: {},
reply_count: 0,
root_id: '',
user_id: userId || this.generateId(),
};
};
@ -314,7 +332,7 @@ class TestHelper {
};
};
fakeTeam = () => {
fakeTeam = (): Team => {
const name = this.generateId();
let inviteId = this.generateId();
if (inviteId.length > 32) {
@ -322,6 +340,7 @@ class TestHelper {
}
return {
id: this.generateId(),
name,
display_name: `Unit Test ${name}`,
type: 'O' as const,
@ -334,6 +353,9 @@ class TestHelper {
allow_open_invite: true,
group_constrained: false,
last_team_icon_update: 0,
create_at: 0,
delete_at: 0,
update_at: 0,
};
};
@ -361,11 +383,9 @@ class TestHelper {
};
};
fakeUser = () => {
fakeUser = (): UserProfile => {
return {
email: this.fakeEmail(),
allow_marketing: true,
password: PASSWORD,
locale: DEFAULT_LOCALE,
username: this.generateId(),
first_name: this.generateId(),
@ -373,6 +393,27 @@ class TestHelper {
create_at: Date.now(),
delete_at: 0,
roles: 'system_user',
auth_service: '',
id: this.generateId(),
nickname: '',
notify_props: this.fakeNotifyProps(),
position: '',
update_at: 0,
};
};
fakeNotifyProps = (): UserNotifyProps => {
return {
channel: 'false',
comments: 'root',
desktop: 'default',
desktop_sound: 'false',
email: 'false',
first_name: 'false',
highlight_keys: '',
mention_keys: '',
push: 'default',
push_status: 'away',
};
};
@ -665,6 +706,7 @@ class TestHelper {
};
wait = (time: number) => new Promise((resolve) => setTimeout(resolve, time));
tick = () => new Promise((r) => setImmediate(r));
}
export default new TestHelper();

View file

@ -46,6 +46,7 @@ interface ClientConfig {
EnableBanner: string;
EnableBotAccountCreation: string;
EnableChannelViewedMessages: string;
EnableClientMetrics?: string;
EnableCluster: string;
EnableCommands: string;
EnableCompliance: string;