* 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:
parent
1594d5e77f
commit
78c01a74f7
32 changed files with 1383 additions and 28 deletions
201
app/actions/remote/entry/notification.test.ts
Normal file
201
app/actions/remote/entry/notification.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
67
app/actions/remote/performance.test.ts
Normal file
67
app/actions/remote/performance.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
20
app/actions/remote/performance.ts
Normal file
20
app/actions/remote/performance.ts
Normal 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};
|
||||
}
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ const withPost = withObservables(
|
|||
post: post.observe(),
|
||||
thread: isCRTEnabled ? observeThreadById(database, post.id) : of$(undefined),
|
||||
hasReactions,
|
||||
isLastPost: of$(!nextPost),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
75
app/components/post_list/post/post.test.tsx
Normal file
75
app/components/post_list/post/post.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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!);
|
||||
|
|
|
|||
209
app/managers/performance_metrics_manager/index.test.ts
Normal file
209
app/managers/performance_metrics_manager/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
85
app/managers/performance_metrics_manager/index.ts
Normal file
85
app/managers/performance_metrics_manager/index.ts
Normal 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();
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
16
app/managers/performance_metrics_manager/test_utils.ts
Normal file
16
app/managers/performance_metrics_manager/test_utils.ts
Normal 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: {},
|
||||
};
|
||||
}
|
||||
23
app/managers/performance_metrics_manager/types.d.ts
vendored
Normal file
23
app/managers/performance_metrics_manager/types.d.ts
vendored
Normal 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[];
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
51
app/screens/home/channel_list/channel_list.test.tsx
Normal file
51
app/screens/home/channel_list/channel_list.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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}/>
|
||||
|
|
|
|||
|
|
@ -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
9
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
11
test/mock_api_client.ts
Normal 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})),
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
1
types/api/config.d.ts
vendored
1
types/api/config.d.ts
vendored
|
|
@ -46,6 +46,7 @@ interface ClientConfig {
|
|||
EnableBanner: string;
|
||||
EnableBotAccountCreation: string;
|
||||
EnableChannelViewedMessages: string;
|
||||
EnableClientMetrics?: string;
|
||||
EnableCluster: string;
|
||||
EnableCommands: string;
|
||||
EnableCompliance: string;
|
||||
|
|
|
|||
Loading…
Reference in a new issue