E2E: Initial Detox setup for Mobile UI automation (#4535)

* initial Detox setup for mobile UI automation

* fix iOS allow permission on opening the app, add npm script to root folder, fix test when using longer site URL and add detox dependency to root for android build dependency

* remove detox proguardFile

* update packages and emulator

* change folder build on local and CI

* add test to post a message, ability to have test in isolation, server API commands, server config, dependency updates and update folder structure

* update snapshot

* update detox and do clean up

* update dependencies
This commit is contained in:
Saturnino Abril 2020-08-29 07:18:41 +08:00 committed by GitHub
parent 619153ebe0
commit f05faaa949
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 9430 additions and 9 deletions

View file

@ -53,6 +53,23 @@
"env": {
"jest": true
}
},
{
"files": ["detox/e2e/**"],
"globals": {
"by": true,
"detox": true,
"device": true,
"element": true,
"waitFor": true
},
"rules": {
"func-names": 0,
"import/no-unresolved": 0,
"max-nested-callbacks": 0,
"no-process-env": 0,
"no-unused-expressions": 0
}
}
]
}

View file

@ -138,7 +138,8 @@ android {
ndk {
abiFilters 'armeabi-v7a','arm64-v8a','x86','x86_64'
}
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}
signingConfigs {
release {
@ -260,6 +261,8 @@ dependencies {
// For WebP support, including animated WebP
implementation 'com.facebook.fresco:animated-webp:2.0.0'
implementation 'com.facebook.fresco:webpsupport:2.0.0'
androidTestImplementation('com.wix:detox:+')
}
// Run this once to be able to run the application with BUCK

View file

@ -0,0 +1,28 @@
package com.mattermost.rnbeta;
import com.wix.detox.Detox;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
import androidx.test.rule.ActivityTestRule;
@RunWith(AndroidJUnit4.class)
@LargeTest
public class DetoxTest {
@Rule
public ActivityTestRule<MainActivity> mActivityRule = new ActivityTestRule<>(MainActivity.class, false, false);
@Test
public void runDetoxTests() {
Detox.DetoxIdlePolicyConfig idlePolicyConfig = new Detox.DetoxIdlePolicyConfig();
idlePolicyConfig.masterTimeoutSec = 60;
idlePolicyConfig.idleResourceTimeoutSec = 30;
Detox.runTests(mActivityRule, idlePolicyConfig);
}
}

View file

@ -66,5 +66,8 @@ allprojects {
maven {
url ("https://dl.bintray.com/rudderstack/rudderstack")
}
maven {
url "$rootDir/../node_modules/detox/Detox-android"
}
}
}

View file

@ -20,6 +20,7 @@ exports[`ErrorText should match snapshot 1`] = `
},
]
}
testID="error_text"
>
Username must begin with a letter and contain between 3 and 22 characters including numbers, lowercase letters, and the symbols
</Text>

View file

@ -29,6 +29,7 @@ export default class ErrorText extends PureComponent {
if (intl) {
return (
<FormattedText
testID='error_text'
id={intl.id}
defaultMessage={intl.defaultMessage}
values={intl.values}
@ -38,7 +39,10 @@ export default class ErrorText extends PureComponent {
}
return (
<Text style={[GlobalStyles.errorLabel, style.errorLabel, textStyle]}>
<Text
testID='error_text'
style={[GlobalStyles.errorLabel, style.errorLabel, textStyle]}
>
{error.message || error}
</Text>
);

View file

@ -11,6 +11,7 @@ export default class FormattedText extends React.PureComponent {
id: PropTypes.string.isRequired,
defaultMessage: PropTypes.string,
values: PropTypes.object,
testID: PropTypes.string,
};
static defaultProps = {

View file

@ -5,6 +5,7 @@ exports[`PasteableTextInput should render pasteable text input 1`] = `
allowFontScaling={true}
onPaste={[Function]}
rejectResponderTermination={true}
testID="post_input"
underlineColorAndroid="transparent"
>
My Text

View file

@ -47,6 +47,7 @@ export class PasteableTextInput extends React.PureComponent {
return (
<TextInput
testID='post_input'
{...props}
onPaste={this.onPaste}
ref={forwardRef}

View file

@ -315,6 +315,7 @@ exports[`PostDraft Should render the DraftInput 1`] = `
"paddingTop": 6,
}
}
testID="post_input"
underlineColorAndroid="transparent"
/>
<View
@ -519,6 +520,7 @@ exports[`PostDraft Should render the DraftInput 1`] = `
"paddingRight": 8,
}
}
testID="disabled_send_button"
>
<View
style={

View file

@ -8,6 +8,7 @@ exports[`SendAction should change theme backgroundColor to 0.3 opacity 1`] = `
"paddingRight": 8,
}
}
testID="disabled_send_button"
>
<View
style={
@ -44,6 +45,7 @@ exports[`SendAction should match snapshot 1`] = `
"paddingRight": 8,
}
}
testID="send_button"
type="opacity"
>
<View
@ -76,6 +78,7 @@ exports[`SendAction should render theme backgroundColor 1`] = `
"paddingRight": 8,
}
}
testID="send_button"
type="opacity"
>
<View

View file

@ -20,7 +20,10 @@ function SendButton(props) {
if (props.disabled) {
return (
<View style={style.sendButtonContainer}>
<View
testID='disabled_send_button'
style={style.sendButtonContainer}
>
<View style={[style.sendButton, style.disableButton]}>
<PaperPlane
height={16}
@ -34,6 +37,7 @@ function SendButton(props) {
return (
<TouchableWithFeedback
testID='send_button'
onPress={props.handleSendMessage}
style={style.sendButtonContainer}
type={'opacity'}

View file

@ -264,6 +264,7 @@ export default class DrawerLayout extends Component {
return (
<View
testID={this.props.testID}
style={containerStyles}
{...panHandlers}
>

View file

@ -124,6 +124,7 @@ export default class MainSidebarIOS extends MainSidebarBase {
return (
<DrawerLayout
testID={this.props.testID}
ref={this.drawerRef}
renderNavigationView={this.renderNavigationView}
onDrawerClose={this.handleDrawerClose}

View file

@ -66,7 +66,10 @@ export default class ChannelAndroid extends ChannelBase {
if (!component) {
component = (
<KeyboardLayout>
<View style={style.flex}>
<View
testID='channel_screen'
style={style.flex}
>
<ChannelPostList registerTypingAnimation={this.registerTypingAnimation}/>
</View>
<PostDraft

View file

@ -106,7 +106,10 @@ export default class ChannelIOS extends ChannelBase {
);
return (
<MainSidebar ref={this.mainSidebarRef}>
<MainSidebar
testID='channel_screen'
ref={this.mainSidebarRef}
>
<SettingsSidebar ref={this.settingsSidebarRef}>
<View style={style.backdrop}>
{drawerContent}

View file

@ -339,6 +339,7 @@ export default class Login extends PureComponent {
proceed = (
<Button
testID='signin_button'
onPress={this.preSignIn}
containerStyle={[GlobalStyles.signupButton, additionalStyle]}
>
@ -384,7 +385,7 @@ export default class Login extends PureComponent {
<Image
source={require('@assets/images/logo.png')}
/>
<View>
<View testID='login_screen'>
<Text style={GlobalStyles.header}>
{this.props.config.SiteName}
</Text>
@ -396,6 +397,7 @@ export default class Login extends PureComponent {
</View>
<ErrorText error={this.state.error}/>
<TextInput
testID='username_input'
autoCapitalize='none'
autoCorrect={false}
blurOnSubmit={false}
@ -411,6 +413,7 @@ export default class Login extends PureComponent {
underlineColorAndroid='transparent'
/>
<TextInput
testID='password_input'
autoCapitalize='none'
autoCorrect={false}
disableFullscreenUI={true}

View file

@ -485,6 +485,7 @@ export default class SelectServer extends PureComponent {
return (
<SafeAreaView
testID='select_server_screen'
style={style.container}
>
<KeyboardAvoidingView
@ -511,6 +512,7 @@ export default class SelectServer extends PureComponent {
/>
</View>
<TextInput
testID='server_url_input'
ref={this.inputRef}
value={url}
editable={!inputDisabled}
@ -530,6 +532,7 @@ export default class SelectServer extends PureComponent {
disableFullscreenUI={true}
/>
<Button
testID='connect_button'
onPress={this.handleConnect}
containerStyle={[GlobalStyles.signupButton, style.connectButton]}
>

12
detox/.babelrc Normal file
View file

@ -0,0 +1,12 @@
{
"presets": ["@babel/preset-env"],
"plugins": [
"@babel/plugin-transform-runtime",
["module-resolver", {
"root": ["."],
"alias": {
"@support": "./e2e/support"
}
}]
]
}

42
detox/.detoxrc.json Normal file
View file

@ -0,0 +1,42 @@
{
"testRunner": "jest",
"runnerConfig": "e2e/config.json",
"configurations": {
"ios.sim.debug": {
"binaryPath": "../ios/Build/Products/Debug-iphonesimulator/Mattermost.app",
"type": "ios.simulator",
"device": {
"type": "iPhone 11"
}
},
"ios.sim.release": {
"type": "ios.simulator",
"binaryPath": "../ios/Build/Products/Release-iphonesimulator/Mattermost.app",
"build": "cd ../fastlane && NODE_ENV=production bundle exec fastlane ios simulator && cd ../detox",
"device": {
"type": "iPhone 11"
}
},
"android.emu.debug": {
"type": "android.emulator",
"binaryPath": "../android/app/build/outputs/apk/debug/app-debug.apk",
"build": "cd ../android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug && cd ../detox",
"device": {
"avdName": "detox_emu_api_30"
}
},
"android.emu.release": {
"type": "android.emulator",
"binaryPath": "../android/app/build/outputs/apk/release/app-release.apk",
"build": "cd ../android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release && cd ../detox",
"device": {
"avdName": "detox_emu_api_30"
}
}
},
"behavior": {
"init": {
"launchApp": false
}
}
}

9
detox/e2e/config.json Normal file
View file

@ -0,0 +1,9 @@
{
"setupFilesAfterEnv" : ["./init.js"],
"testEnvironment": "./environment",
"testRunner": "jest-circus/runner",
"testTimeout": 120000,
"testRegex": "\\.e2e\\.js$",
"reporters": ["detox/runners/jest/streamlineReporter"],
"verbose": true
}

25
detox/e2e/environment.js Normal file
View file

@ -0,0 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
const {
DetoxCircusEnvironment,
SpecReporter,
WorkerAssignReporter,
} = require('detox/runners/jest-circus');
class CustomDetoxEnvironment extends DetoxCircusEnvironment {
constructor(config) {
super(config);
this.initTimeout = 300000;
// This takes care of generating status logs on a per-spec basis. By default, Jest only reports at file-level.
// This is strictly optional.
this.registerListeners({
SpecReporter,
WorkerAssignReporter,
});
}
}
module.exports = CustomDetoxEnvironment;

21
detox/e2e/init.js Normal file
View file

@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {System, User} from '@support/server_api';
beforeAll(async () => {
// Login as sysadmin and reset server configuration
await User.apiAdminLogin();
await System.apiUpdateConfig();
await device.launchApp({
newInstance: false,
launchArgs: {detoxPrintBusyIdleResources: 'YES'},
permissions: {
notifications: 'YES',
camera: 'YES',
medialibrary: 'YES',
photos: 'YES',
},
});
});

View file

@ -0,0 +1,81 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {capitalize, getRandomId} from '@support/utils';
import client from './client';
import {getResponseFromError} from './common';
// ****************************************************************
// Channels
// See https://api.mattermost.com/#tag/channels
//
// Exported API function should have the following:
// - documented using JSDoc
// - meaningful description
// - match the referenced API endpoints
// - parameter/s defined by `@param`
// - return value defined by `@return`
// ****************************************************************
/**
* Create a channel.
* See https://api.mattermost.com/#tag/channels/paths/~1channels/post
* @param {string} option.teamId - The team ID of the team to create the channel on
* @param {string} option.type - 'O' (default) for a public channel, 'P' for a private channel
* @param {string} option.prefix - option to add prefix to name and display name
* @param {Object} option.channel - fix channel object to be created
* @return {Object} returns {channel} on success or {error, status} on error
*/
export const apiCreateChannel = async ({teamId = null, type = 'O', prefix = 'channel', channel = null} = {}) => {
try {
const response = await client.post(
'/api/v4/channels',
channel || generateRandomChannel(teamId, type, prefix),
);
return {channel: response.data};
} catch (err) {
return getResponseFromError(err);
}
};
/**
* Add user to channel.
* See https://api.mattermost.com/#tag/channels/paths/~1channels~1{channel_id}~1members/post
* @param {string} userId - The ID of user to add into the channel
* @param {string} channelId - The channel ID
* @return {Object} returns {member} on success or {error, status} on error
*/
export const apiAddUserToChannel = async (userId, channelId) => {
try {
const response = await client.post(
`/api/v4/channels/${channelId}/members`,
{user_id: userId},
);
return {member: response.data};
} catch (err) {
return getResponseFromError(err);
}
};
function generateRandomChannel(teamId, type, prefix) {
const randomId = getRandomId();
return {
team_id: teamId,
name: `${prefix}-${randomId}`,
display_name: `${capitalize(prefix)} ${randomId}`,
type,
purpose: `Channel purpose: ${prefix} ${randomId}`,
header: `Channel header: ${prefix} ${randomId}`,
};
}
export const Channel = {
apiAddUserToChannel,
apiCreateChannel,
};
export default Channel;

View file

@ -0,0 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import axios from 'axios';
import testConfig from '../test_config';
export const client = axios.create({
baseURL: testConfig.siteUrl,
headers: {'X-Requested-With': 'XMLHttpRequest'},
});
export default client;

View file

@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const getResponseFromError = (err) => {
const {response: {data, status}} = err;
return {error: data, status};
};

View file

@ -0,0 +1,296 @@
{
"ServiceSettings": {
"SiteURL": "http://localhost:8065",
"WebsocketURL": "",
"LicenseFileLocation": "",
"ListenAddress": ":8065",
"ConnectionSecurity": "",
"TLSCertFile": "",
"TLSKeyFile": "",
"TLSMinVer": "1.2",
"TLSStrictTransport": false,
"TLSStrictTransportMaxAge": 63072000,
"TLSOverwriteCiphers": [],
"UseLetsEncrypt": false,
"Forward80To443": false,
"TrustedProxyIPHeader": [],
"ReadTimeout": 300,
"WriteTimeout": 300,
"IdleTimeout": 300,
"MaximumLoginAttempts": 10,
"GoroutineHealthThreshold": -1,
"GoogleDeveloperKey": "",
"EnableOAuthServiceProvider": false,
"EnableIncomingWebhooks": true,
"EnableOutgoingWebhooks": true,
"EnableCommands": true,
"EnableOnlyAdminIntegrations": true,
"EnablePostUsernameOverride": false,
"EnablePostIconOverride": false,
"EnableLinkPreviews": false,
"EnableTesting": false,
"EnableDeveloper": false,
"EnableOpenTracing": false,
"EnableSecurityFixAlert": true,
"EnableInsecureOutgoingConnections": false,
"AllowedUntrustedInternalConnections": "localhost",
"EnableMultifactorAuthentication": false,
"EnforceMultifactorAuthentication": false,
"EnableUserAccessTokens": false,
"AllowCorsFrom": "",
"CorsExposedHeaders": "",
"CorsAllowCredentials": false,
"CorsDebug": false,
"AllowCookiesForSubdomains": false,
"ExtendSessionLengthWithActivity": true,
"SessionLengthWebInDays": 30,
"SessionLengthMobileInDays": 30,
"SessionLengthSSOInDays": 30,
"SessionCacheInMinutes": 10,
"SessionIdleTimeoutInMinutes": 43200,
"WebsocketSecurePort": 443,
"WebsocketPort": 80,
"WebserverMode": "gzip",
"EnableCustomEmoji": false,
"EnableEmojiPicker": true,
"EnableGifPicker": false,
"GfycatApiKey": "2_KtH_W5",
"GfycatApiSecret": "3wLVZPiswc3DnaiaFoLkDvB4X0IV6CpMkj4tf2inJRsBY6-FnkT08zGmppWFgeof",
"RestrictCustomEmojiCreation": "all",
"RestrictPostDelete": "all",
"AllowEditPost": "always",
"PostEditTimeLimit": -1,
"TimeBetweenUserTypingUpdatesMilliseconds": 5000,
"EnablePostSearch": true,
"MinimumHashtagLength": 3,
"EnableUserTypingMessages": true,
"EnableChannelViewedMessages": true,
"EnableUserStatuses": true,
"ExperimentalEnableAuthenticationTransfer": true,
"CloseUnusedDirectMessages": false,
"EnablePreviewFeatures": true,
"ExperimentalEnableDefaultChannelLeaveJoinMessages": true,
"ExperimentalGroupUnreadChannels": "disabled",
"ExperimentalChannelOrganization": false,
"ExperimentalChannelSidebarOrganization": "disabled",
"EnableAPITeamDeletion": true,
"ExperimentalEnableHardenedMode": false,
"DisableLegacyMFA": true,
"ExperimentalStrictCSRFEnforcement": false,
"EnableEmailInvitations": true,
"DisableBotsWhenOwnerIsDeactivated": true,
"EnableBotAccountCreation": true,
"EnableSVGs": true,
"EnableLatex": false
},
"TeamSettings": {
"SiteName": "Mattermost",
"MaxUsersPerTeam": 2000,
"EnableTeamCreation": true,
"EnableUserCreation": true,
"EnableOpenServer": true,
"EnableUserDeactivation": false,
"RestrictCreationToDomains": "",
"EnableCustomBrand": false,
"CustomBrandText": "",
"CustomDescriptionText": "",
"RestrictDirectMessage": "any",
"RestrictTeamInvite": "all",
"RestrictPublicChannelManagement": "all",
"RestrictPrivateChannelManagement": "all",
"RestrictPublicChannelCreation": "all",
"RestrictPrivateChannelCreation": "all",
"RestrictPublicChannelDeletion": "all",
"RestrictPrivateChannelDeletion": "all",
"RestrictPrivateChannelManageMembers": "all",
"EnableXToLeaveChannelsFromLHS": false,
"UserStatusAwayTimeout": 300,
"MaxChannelsPerTeam": 2000,
"MaxNotificationsPerChannel": 1000,
"EnableConfirmNotificationsToChannel": true,
"TeammateNameDisplay": "username",
"ExperimentalViewArchivedChannels": false,
"ExperimentalEnableAutomaticReplies": false,
"ExperimentalHideTownSquareinLHS": false,
"ExperimentalTownSquareIsReadOnly": false,
"LockTeammateNameDisplay": false,
"ExperimentalPrimaryTeam": "",
"ExperimentalDefaultChannels": []
},
"ClientRequirements": {
"AndroidLatestVersion": "",
"AndroidMinVersion": "",
"DesktopLatestVersion": "",
"DesktopMinVersion": "",
"IosLatestVersion": "",
"IosMinVersion": ""
},
"LogSettings": {
"EnableConsole": true
},
"NotificationLogSettings": {
"EnableConsole": true
},
"PasswordSettings": {
"MinimumLength": 5,
"Lowercase": false,
"Number": false,
"Uppercase": false,
"Symbol": false,
"Enable": false
},
"FileSettings": {
"EnableFileAttachments": true,
"EnableMobileUpload": true,
"EnableMobileDownload": true,
"MaxFileSize": 52428800,
"DriverName": "local",
"Directory": "./data/",
"EnablePublicLink": false,
"PublicLinkSalt": ""
},
"EmailSettings": {
"EnableSignUpWithEmail": true,
"EnableSignInWithEmail": true,
"EnableSignInWithUsername": true,
"SendEmailNotifications": true,
"UseChannelInEmailNotifications": false,
"RequireEmailVerification": false,
"FeedbackName": "",
"FeedbackEmail": "test@example.com",
"ReplyToAddress": "test@example.com",
"FeedbackOrganization": "",
"EnableSMTPAuth": false,
"SMTPUsername": "",
"SMTPPassword": "",
"SMTPServer": "localhost",
"SMTPPort": "10025",
"SMTPServerTimeout": 10,
"ConnectionSecurity": "",
"SendPushNotifications": true,
"PushNotificationServer": "https://push-test.mattermost.com",
"PushNotificationContents": "generic",
"EnableEmailBatching": false,
"EmailBatchingBufferSize": 256,
"EmailBatchingInterval": 30,
"EnablePreviewModeBanner": true,
"SkipServerCertificateVerification": false,
"EmailNotificationContentsType": "full",
"LoginButtonColor": "#0000",
"LoginButtonBorderColor": "#2389D7",
"LoginButtonTextColor": "#2389D7"
},
"RateLimitSettings": {
"Enable": false
},
"PrivacySettings": {
"ShowEmailAddress": true,
"ShowFullName": true
},
"SupportSettings": {
"TermsOfServiceLink": "https://about.mattermost.com/default-terms/",
"PrivacyPolicyLink": "https://about.mattermost.com/default-privacy-policy/",
"AboutLink": "https://about.mattermost.com/default-about/",
"HelpLink": "https://about.mattermost.com/default-help/",
"ReportAProblemLink": "https://about.mattermost.com/default-report-a-problem/",
"SupportEmail": "feedback@mattermost.com",
"CustomTermsOfServiceEnabled": false,
"CustomTermsOfServiceReAcceptancePeriod": 365,
"EnableAskCommunityLink": true
},
"AnnouncementSettings": {
"EnableBanner": false
},
"ThemeSettings": {
"EnableThemeSelection": true,
"DefaultTheme": "default",
"AllowCustomThemes": true,
"AllowedThemes": []
},
"GitLabSettings": {
"Enable": false
},
"GoogleSettings": {
"Enable": false
},
"Office365Settings": {
"Enable": false
},
"LdapSettings": {
"Enable": false
},
"ComplianceSettings": {
"Enable": false
},
"LocalizationSettings": {
"DefaultServerLocale": "en",
"DefaultClientLocale": "en",
"AvailableLocales": ""
},
"SamlSettings": {
"Enable": false
},
"NativeAppSettings": {
"AppDownloadLink": "https://mattermost.com/download/#mattermostApps",
"AndroidAppDownloadLink": "https://about.mattermost.com/mattermost-android-app/",
"IosAppDownloadLink": "https://about.mattermost.com/mattermost-ios-app/"
},
"ClusterSettings": {
"Enable": false
},
"MetricsSettings": {
"Enable": false
},
"ExperimentalSettings": {
"ClientSideCertEnable": false,
"ClientSideCertCheck": "secondary",
"LinkMetadataTimeoutMilliseconds": 5000,
"RestrictSystemAdmin": false,
"UseNewSAMLLibrary": false
},
"AnalyticsSettings": {
"MaxUsersForStatistics": 2500
},
"ElasticsearchSettings": {
"ConnectionUrl": "http://localhost:9200",
"Username": "elastic",
"Password": "changeme",
"EnableIndexing": false,
"EnableSearching": false,
"EnableAutocomplete": false,
"Sniff": true
},
"DataRetentionSettings": {
"EnableMessageDeletion": false,
"EnableFileDeletion": false,
"MessageRetentionDays": 365,
"FileRetentionDays": 365,
"DeletionJobStartTime": "02:00"
},
"MessageExportSettings": {
"EnableExport": false
},
"JobSettings": {
"RunJobs": true,
"RunScheduler": true
},
"PluginSettings": {
"Enable": false
},
"DisplaySettings": {
"CustomUrlSchemes": [],
"ExperimentalTimezone": false
},
"GuestAccountsSettings": {
"Enable": true,
"AllowEmailAccounts": true,
"EnforceMultifactorAuthentication": false,
"RestrictCreationToDomains": ""
},
"ImageProxySettings": {
"Enable": true,
"ImageProxyType": "local",
"RemoteImageProxyURL": "",
"RemoteImageProxyOptions": ""
}
}

View file

@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Channel from './channel';
import Setup from './setup';
import System from './system';
import Team from './team';
import User from './user';
export {
Channel,
Setup,
System,
Team,
User,
};

View file

@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Channel from './channel';
import Team from './team';
import User from './user';
import {getResponseFromError} from './common';
/**
* Creates new user, channel and team for test isolation.
* @param {Object} options - may pass options to predefine channel, team and user creation
* @return {Object} returns {channel, team, user} on success or {error, status} on error
*/
export const apiInit = async ({
channelOptions = {type: 'O', prefix: 'channel'},
teamOptions = {type: 'O', prefix: 'team'},
userOptions = {prefix: 'user'},
} = {}) => {
try {
const {team} = await Team.apiCreateTeam(teamOptions);
const {channel} = await Channel.apiCreateChannel({...channelOptions, teamId: team.id});
const {user} = await User.apiCreateUser(userOptions);
await Team.apiAddUserToTeam(user.id, team.id);
await Channel.apiAddUserToChannel(user.id, channel.id);
return {
channel,
team,
user,
};
} catch (err) {
return getResponseFromError(err);
}
};
export const Setup = {
apiInit,
};
export default Setup;

View file

@ -0,0 +1,72 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import merge from 'merge-deep';
import testConfig from '@support/test_config';
import client from './client';
import {getResponseFromError} from './common';
import defaultServerConfig from './default_config.json';
// ****************************************************************
// System
// See https://api.mattermost.com/#tag/system
//
// Exported API function should have the following:
// - documented using JSDoc
// - meaningful description
// - match the referenced API endpoints
// - parameter/s defined by `@param`
// - return value defined by `@return`
// ****************************************************************
/**
* Get configuration.
* See https://api.mattermost.com/#tag/system/paths/~1config/get
*/
export const apiGetConfig = async () => {
try {
const response = await client.get('/api/v4/config');
return {config: response.data};
} catch (err) {
return getResponseFromError(err);
}
};
/**
* Update configuration.
* See https://api.mattermost.com/#tag/system/paths/~1config/put
* @param {Object} newConfig - specific config to update
*/
export const apiUpdateConfig = async (newConfig = {}) => {
try {
const {config: currentConfig} = await apiGetConfig();
const config = merge(currentConfig, getDefaultConfig(), newConfig);
const response = await client.put(
'/api/v4/config',
config,
);
return {config: response.data};
} catch (err) {
return getResponseFromError(err);
}
};
const getDefaultConfig = () => {
const fromEnv = {
ServiceSettings: {SiteURL: testConfig.siteUrl},
};
return merge(defaultServerConfig, fromEnv);
};
export const System = {
apiGetConfig,
apiUpdateConfig,
};
export default System;

View file

@ -0,0 +1,77 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {capitalize, getRandomId} from '@support/utils';
import client from './client';
import {getResponseFromError} from './common';
// ****************************************************************
// Teams
// See https://api.mattermost.com/#tag/teams
//
// Exported API function should have the following:
// - documented using JSDoc
// - meaningful description
// - match the referenced API endpoints
// - parameter/s defined by `@param`
// - return value defined by `@return`
// ****************************************************************
/**
* Create a team.
* See https://api.mattermost.com/#tag/teams/paths/~1teams/post
* @param {string} option.type - 'O' (default) for open, 'I' for invite only
* @param {string} option.prefix - option to add prefix to name and display name
* @param {Object} team - fix team object to be created
* @return {Object} returns {team} on success or {error, status} on error
*/
export const apiCreateTeam = async ({type = 'O', prefix = 'team', team = null} = {}) => {
try {
const response = await client.post(
'/api/v4/teams',
team || generateRandomTeam(type, prefix),
);
return {team: response.data};
} catch (err) {
return getResponseFromError(err);
}
};
/**
* Add user to team.
* See https://api.mattermost.com/#tag/teams/paths/~1teams~1{team_id}~1members/post
* @param {string} userId - The ID of user to add into the team
* @param {string} teamId - The team ID
* @return {Object} returns {member} on success or {error, status} on error
*/
export const apiAddUserToTeam = async (userId, teamId) => {
try {
const response = await client.post(
`/api/v4/teams/${teamId}/members`,
{team_id: teamId, user_id: userId},
);
return {member: response.data};
} catch (err) {
return getResponseFromError(err);
}
};
function generateRandomTeam(type, prefix) {
const randomId = getRandomId();
return {
name: `${prefix}-${randomId}`,
display_name: `${capitalize(prefix)} ${randomId}`,
type,
};
}
export const Team = {
apiAddUserToTeam,
apiCreateTeam,
};
export default Team;

View file

@ -0,0 +1,136 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import testConfig from '@support/test_config';
import {getRandomId} from '@support/utils';
import client from './client';
import {getResponseFromError} from './common';
// ****************************************************************
// Users
// See https://api.mattermost.com/#tag/users
//
// Exported API function should have the following:
// - documented using JSDoc
// - meaningful description
// - match the referenced API endpoints
// - parameter/s defined by `@param`
// - return value defined by `@return`
// ****************************************************************
/**
* Login to Mattermost server.
* See https://api.mattermost.com/#tag/users/paths/~1users~1login/post
* @param {string} user.username - username of a user
* @param {string} user.password - password of a user
*/
export const apiLogin = async (user) => {
try {
const response = await client.post(
'/api/v4/users/login',
{login_id: user.username, password: user.password},
);
const {data, headers, status} = response;
// Get MMAUTHTOKEN cookie and attach to the client instance
const [mmAuthToken] = headers['set-cookie'];
client.defaults.headers.Cookie = mmAuthToken;
return {
status,
user: data,
};
} catch (err) {
return getResponseFromError(err);
}
};
/**
* Login to Mattermost server as sysadmin.
*/
export const apiAdminLogin = async () => {
return apiLogin({
username: testConfig.adminUsername,
password: testConfig.adminPassword,
});
};
/**
* Logout from the Mattermost server.
* See https://api.mattermost.com/#tag/users/paths/~1users~1logout/post
*/
export const apiLogout = async () => {
const response = await client.post('/api/v4/users/logout');
client.defaults.headers.Cookie = '';
return response.data;
};
/**
* Create a user.
* See https://api.mattermost.com/#tag/users/paths/~1users/post
* @param {Object} user - user object to be created
*/
export const apiCreateUser = async ({prefix = 'user', user = null} = {}) => {
try {
const newUser = user || generateRandomUser(prefix);
const response = await client.post(
'/api/v4/users',
newUser,
);
return {user: {...response.data, password: newUser.password}};
} catch (err) {
return getResponseFromError(err);
}
};
/**
* Get user from a current session.
*/
export const apiGetMe = () => {
return apiGetUserById('me');
};
/**
* Get a user by ID.
* See https://api.mattermost.com/#tag/users/paths/~1users~1{user_id}/get
* @param {string} userId
*/
export const apiGetUserById = async (userId) => {
try {
const response = await client.get(`/api/v4/users/${userId}`);
return {user: response.data};
} catch (err) {
return getResponseFromError(err);
}
};
function generateRandomUser(prefix) {
const randomId = getRandomId();
return {
email: `${prefix}${randomId}@sample.mattermost.com`,
username: `${prefix}${randomId}`,
password: 'passwd',
first_name: `First${randomId}`,
last_name: `Last${randomId}`,
nickname: `Nickname${randomId}`,
};
}
export const User = {
apiAdminLogin,
apiLogin,
apiLogout,
apiCreateUser,
apiGetMe,
apiGetUserById,
};
export default User;

View file

@ -0,0 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
module.exports = {
serverUrl: process.env.SITE_URL || (process.env.IOS ? 'http://localhost:8065' : 'http://10.0.2.2:8065'),
siteUrl: process.env.SITE_URL || 'http://localhost:8065',
adminUsername: process.env.ADMIN_USERNAME || 'sysadmin',
adminPassword: process.env.ADMIN_PASSWORD || 'Sys@dmin-sample1',
};

View file

@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {serverUrl} from '../test_config';
// ****************************************************************
// Select Server Screen
// ****************************************************************
/**
* fulfillSelectServerScreen enters server URL on Select Server screen and then tap connect button.
* @param {String} url - server URL
*/
export async function fulfillSelectServerScreen(url) {
await expect(element(by.id('select_server_screen'))).toBeVisible();
await element(by.id('server_url_input')).replaceText(url);
// # Tap anywhere to hide keyboard
await element(by.text('Enter Server URL')).tap();
await element(by.id('connect_button')).tap();
}
// ****************************************************************
// Login Screen
// ****************************************************************
/**
* fulfillLoginScreen enters credential on Login screen and then tap "Sign in" button to log in.
* @param {Object} user - user to login with username and password
*/
export async function fulfillLoginScreen(user = {}) {
await expect(element(by.id('login_screen'))).toBeVisible();
await element(by.id('username_input')).replaceText(user.username);
// # Tap anywhere to hide keyboard
await element(by.text('Mattermost')).tap();
await element(by.id('password_input')).replaceText(user.password);
// # Tap anywhere to hide keyboard
await element(by.text('Mattermost')).tap();
await element(by.id('signin_button')).tap();
}
// ****************************************************************
// Channel Screen
// ****************************************************************
export async function toChannelScreen(user) {
await fulfillSelectServerScreen(serverUrl);
await fulfillLoginScreen(user);
}

View file

@ -0,0 +1,53 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {v4 as uuidv4} from 'uuid';
/**
* Explicit `wait` should not normally used but made available for special cases.
* @param {number} ms - duration in millisecond
*/
export const wait = async (ms) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
export const isAndroid = () => {
return !process.env.IOS;
};
export const isIos = () => {
return process.env.IOS;
};
/**
* @param {number} length - length on random string to return, e.g. 6 (default)
* @return {string} random string
*/
export const getRandomId = (length = 6) => {
const MAX_SUBSTRING_INDEX = 27;
return uuidv4().replace(/-/g, '').substring(MAX_SUBSTRING_INDEX - length, MAX_SUBSTRING_INDEX);
};
/**
* @param {string} text
* @return {string} capitalized text
*/
export const capitalize = (text) => {
return text.charAt(0).toUpperCase() + text.slice(1);
};
const SECOND = 1000;
const MINUTE = 60 * 1000;
export const timeouts = {
HALF_SEC: SECOND / 2,
ONE_SEC: SECOND,
TWO_SEC: SECOND * 2,
FOUR_SEC: SECOND * 4,
TEN_SEC: SECOND * 10,
HALF_MIN: MINUTE / 2,
ONE_MIN: MINUTE,
TWO_MIN: MINUTE * 2,
FOUR_MIN: MINUTE * 4,
};

View file

@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {toChannelScreen} from '@support/ui/screen';
import {Setup} from '@support/server_api';
describe('Messaging', () => {
beforeAll(async () => {
const {user} = await Setup.apiInit();
await toChannelScreen(user);
});
it('should post a message on tap to paper send button', async () => {
await expect(element(by.id('channel_screen'))).toBeVisible();
await expect(element(by.id('post_input'))).toExist();
await expect(element(by.id('send_button'))).not.toExist();
await element(by.id('post_input')).tap();
const text = Date.now().toString();
await element(by.id('post_input')).typeText(text);
await expect(element(by.id('send_button'))).toBeVisible();
await element(by.id('send_button')).tap();
await expect(element(by.text(text))).toExist();
});
});

View file

@ -0,0 +1,163 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Setup} from '@support/server_api';
import {serverUrl} from '@support/test_config';
import {fulfillSelectServerScreen} from '@support/ui/screen';
import {isAndroid, timeouts, wait} from '@support/utils';
describe('On boarding', () => {
let user;
beforeAll(async () => {
({user} = await Setup.apiInit());
});
beforeEach(async () => {
await device.reloadReactNative();
});
it('should show Select server screen on initial load', async () => {
// Verify basic elements on Select Server screen
await expect(element(by.id('select_server_screen'))).toBeVisible();
await expect(element(by.id('server_url_input'))).toBeVisible();
await expect(element(by.id('connect_button'))).toBeVisible();
});
it('should show error on invalid server URL', async () => {
await expect(element(by.id('select_server_screen'))).toBeVisible();
// Enter invalid server URL
await element(by.id('server_url_input')).typeText('http://invalid:8065');
// Tap anywhere to hide keyboard
await element(by.text('Enter Server URL')).tap();
// Verify that the error message does not exist
await waitFor(element(by.id('error_text'))).not.toExist().withTimeout(timeouts.HALF_SEC);
// Tap connect button
await element(by.id('connect_button')).tap();
// Explicitly wait on Android before verifying error message
if (isAndroid()) {
await wait(timeouts.ONE_MIN);
}
// Verify error message
await waitFor(element(by.id('error_text'))).toBeVisible().withTimeout(timeouts.ONE_MIN);
await expect(element(by.id('error_text'))).toHaveText('Cannot connect to the server. Please check your server URL and internet connection.');
});
it('should move to Login screen on valid server URL', async () => {
await expect(element(by.id('select_server_screen'))).toBeVisible();
// Enter valid server URL
await element(by.id('server_url_input')).replaceText(serverUrl);
// Tap connect button
await element(by.id('connect_button')).tap();
// Verify that it goes into Login screen
await expect(element(by.id('login_screen'))).toBeVisible();
});
it('should match elements on Login screen', async () => {
await fulfillSelectServerScreen(serverUrl);
// Verify basic elements on Login screen
await expect(element(by.id('login_screen'))).toBeVisible();
await expect(element(by.id('username_input'))).toBeVisible();
await expect(element(by.id('password_input'))).toBeVisible();
await expect(element(by.id('signin_button'))).toBeVisible();
});
it('should show error on missing any of the username or password', async () => {
await fulfillSelectServerScreen(serverUrl);
await expect(element(by.id('login_screen'))).toBeVisible();
// On Login screen, enter invalid username
await element(by.id('username_input')).typeText('any');
// Tap anywhere to hide keyboard
await element(by.text('Mattermost')).tap();
// Tap "Sign in" button
await element(by.id('signin_button')).tap();
// Verify that the error message is shown as expected
await expect(element(by.id('error_text'))).toBeVisible();
await expect(element(by.id('error_text'))).toHaveText('Please enter your password');
// Clear input to username and enter invalid password
await element(by.id('username_input')).replaceText('');
await element(by.id('password_input')).typeText('any');
// Tap anywhere to hide keyboard
await element(by.text('Mattermost')).tap();
// Tap "Sign in" button
await element(by.id('signin_button')).tap();
// Verify that the error message is shown as expected
await expect(element(by.id('error_text'))).toBeVisible();
await expect(element(by.id('error_text'))).toHaveText('Please enter your email or username');
});
it('should show error on incorrect credential', async () => {
await fulfillSelectServerScreen(serverUrl);
await expect(element(by.id('login_screen'))).toBeVisible();
// Enter invalid username
await element(by.id('username_input')).replaceText('any');
// Tap anywhere to hide keyboard
await element(by.text('Mattermost')).tap();
// Enter invalid password
await element(by.id('password_input')).replaceText('any');
// Tap anywhere to hide keyboard
await element(by.text('Mattermost')).tap();
// Tap "Sign in" button
await element(by.id('signin_button')).tap();
// Verify that the error message is shown as expected
await expect(element(by.id('error_text'))).toBeVisible();
await expect(element(by.id('error_text'))).toHaveText('Enter a valid email or username and/or password.');
});
it('should move to Channel screen on successful login', async () => {
await fulfillSelectServerScreen(serverUrl);
await expect(element(by.id('login_screen'))).toBeVisible();
// Enter valid username
await element(by.id('username_input')).replaceText(user.username);
// # Tap anywhere to hide keyboard
await element(by.text('Mattermost')).tap();
// Enter valid password
await element(by.id('password_input')).replaceText(user.password);
// # Tap anywhere to hide keyboard
await element(by.text('Mattermost')).tap();
// Tap "Sign in" button
await element(by.id('signin_button')).tap();
// Verify that it goes into Channel screen
await expect(element(by.id('channel_screen'))).toBeVisible();
});
it('should directly go into Channel screen on reload', async () => {
// On reload and after successful login, verify that it goes straight into Channel screen
await expect(element(by.id('channel_screen'))).toBeVisible();
});
});

7681
detox/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

35
detox/package.json Normal file
View file

@ -0,0 +1,35 @@
{
"name": "mattermost-mobile-e2e",
"description": "End-to-end testing for Mattermost Mobile",
"repository": "git@github.com:mattermost/mattermost-mobile.git",
"author": "Mattermost, Inc.",
"devDependencies": {
"@babel/plugin-transform-modules-commonjs": "7.10.4",
"@babel/plugin-transform-runtime": "7.11.0",
"@babel/preset-env": "7.11.0",
"axios": "0.20.0",
"babel-jest": "26.3.0",
"babel-plugin-module-resolver": "4.0.0",
"detox": "17.4.4",
"jest": "26.4.2",
"jest-circus": "26.4.2",
"jest-cli": "26.4.2",
"merge-deep": "3.0.2",
"uuid": "8.3.0"
},
"scripts": {
"e2e:android-create-emulator": "avdmanager create avd -n detox_emu_api_30 -k 'system-images;android-30;google_apis;x86' -g google_apis -d 'pixel'",
"e2e:android-build": "detox build -c android.emu.debug",
"e2e:android-test": "detox test -c android.emu.debug",
"e2e:android-build-release": "detox build -c android.emu.release",
"e2e:android-test-release": "detox test -c android.emu.release",
"e2e:ios-test": "IOS=true detox test -c ios.sim.debug",
"e2e:ios-build-release": "detox build -c ios.sim.release",
"e2e:ios-test-release": "IOS=true detox test -c ios.sim.release"
},
"jest": {
"transform": {
"^.+\\.js?$": "babel-jest"
}
}
}

View file

@ -358,8 +358,15 @@ platform :ios do
replace_assets
sh 'cd ../ios && xcodebuild -workspace Mattermost.xcworkspace -scheme Mattermost -arch x86_64 -sdk iphonesimulator -configuration Release -parallelizeTargets -derivedDataPath ../build-ios ENABLE_BITCODE=NO CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO ENABLE_BITCODE=NO'
sh 'cd ../build-ios/Build/Products/Release-iphonesimulator && zip -r Mattermost-simulator-x86_64.app.zip Mattermost.app'
sh 'mv ../build-ios/Build/Products/Release-iphonesimulator/Mattermost-simulator-x86_64.app.zip ../'
data_path = 'ios'
if ENV['CIRCLECI'] == 'true'
data_path = 'build-ios'
end
sh "cd ../#{data_path}/Build/Products/Release-iphonesimulator && zip -r Mattermost-simulator-x86_64.app.zip Mattermost.app"
sh "mv ../#{data_path}/Build/Products/Release-iphonesimulator/Mattermost-simulator-x86_64.app.zip ../"
upload_file_to_s3({
:os_type => "iOS",
:file => "Mattermost-simulator-x86_64.app.zip"

454
package-lock.json generated
View file

@ -6907,6 +6907,28 @@
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz",
"integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A=="
},
"bunyan": {
"version": "1.8.14",
"resolved": "https://registry.npmjs.org/bunyan/-/bunyan-1.8.14.tgz",
"integrity": "sha512-LlahJUxXzZLuw/hetUQJmRgZ1LF6+cr5TPpRj6jf327AsiIq2jhYEH4oqUUkVKTor+9w2BT3oxVwhzE5lw9tcg==",
"dev": true,
"requires": {
"dtrace-provider": "~0.8",
"moment": "^2.19.3",
"mv": "~2",
"safe-json-stringify": "~1"
}
},
"bunyan-debug-stream": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/bunyan-debug-stream/-/bunyan-debug-stream-1.1.1.tgz",
"integrity": "sha512-jJbQ1gXUL6vMmZVdbaTFK1v1sGa7axLrSQQwkB6HU9HCPTzsw2HsKcPHm1vgXZlEck/4IvEuRwg/9+083YelCg==",
"dev": true,
"requires": {
"colors": "^1.0.3",
"exception-formatter": "^1.0.4"
}
},
"bytes": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
@ -7096,6 +7118,29 @@
}
}
},
"child-process-promise": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/child-process-promise/-/child-process-promise-2.2.1.tgz",
"integrity": "sha1-RzChHvYQ+tRQuPIjx50x172tgHQ=",
"dev": true,
"requires": {
"cross-spawn": "^4.0.2",
"node-version": "^1.0.0",
"promise-polyfill": "^6.0.1"
},
"dependencies": {
"cross-spawn": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-4.0.2.tgz",
"integrity": "sha1-e5JHYhwjrf3ThWAEqCPL45dCTUE=",
"dev": true,
"requires": {
"lru-cache": "^4.0.1",
"which": "^1.2.9"
}
}
}
},
"chokidar": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.8.tgz",
@ -7274,6 +7319,12 @@
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.1.tgz",
"integrity": "sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw=="
},
"colors": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz",
"integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==",
"dev": true
},
"combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@ -7756,6 +7807,232 @@
"integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==",
"dev": true
},
"detox": {
"version": "17.4.4",
"resolved": "https://registry.npmjs.org/detox/-/detox-17.4.4.tgz",
"integrity": "sha512-yImcH38K6u3uX0uFCi8bXpHS1rdbFX8TyjbnlHmqAPq7ahC4iZLM5mn/QR8XorlckSdHQAfSt2rBSGvmrw5f+g==",
"dev": true,
"requires": {
"bunyan": "^1.8.12",
"bunyan-debug-stream": "^1.1.0",
"chalk": "^2.4.2",
"child-process-promise": "^2.2.0",
"find-up": "^4.1.0",
"fs-extra": "^4.0.2",
"funpermaproxy": "^1.0.1",
"get-port": "^2.1.0",
"ini": "^1.3.4",
"lodash": "^4.17.5",
"minimist": "^1.2.0",
"proper-lockfile": "^3.0.2",
"sanitize-filename": "^1.6.1",
"shell-utils": "^1.0.9",
"signal-exit": "^3.0.3",
"tail": "^2.0.0",
"telnet-client": "1.2.8",
"tempfile": "^2.0.0",
"which": "^1.3.1",
"ws": "^3.3.1",
"yargs": "^13.0.0",
"yargs-parser": "^13.0.0"
},
"dependencies": {
"cliui": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz",
"integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==",
"dev": true,
"requires": {
"string-width": "^3.1.0",
"strip-ansi": "^5.2.0",
"wrap-ansi": "^5.1.0"
}
},
"emoji-regex": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz",
"integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==",
"dev": true
},
"find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"requires": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
}
},
"fs-extra": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-4.0.3.tgz",
"integrity": "sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg==",
"dev": true,
"requires": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
}
},
"get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true
},
"locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"requires": {
"p-locate": "^4.1.0"
}
},
"p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"requires": {
"p-limit": "^2.2.0"
}
},
"path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true
},
"proper-lockfile": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-3.2.0.tgz",
"integrity": "sha512-iMghHHXv2bsxl6NchhEaFck8tvX3F9cknEEh1SUpguUOBjN7PAAW9BLzmbc1g/mCD1gY3EE2EABBHPJfFdHFmA==",
"dev": true,
"requires": {
"graceful-fs": "^4.1.11",
"retry": "^0.12.0",
"signal-exit": "^3.0.2"
}
},
"require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"dev": true
},
"retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
"integrity": "sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs=",
"dev": true
},
"string-width": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
"integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==",
"dev": true,
"requires": {
"emoji-regex": "^7.0.1",
"is-fullwidth-code-point": "^2.0.0",
"strip-ansi": "^5.1.0"
}
},
"ultron": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ultron/-/ultron-1.1.1.tgz",
"integrity": "sha512-UIEXBNeYmKptWH6z8ZnqTeS8fV74zG0/eRU9VGkpzz+LIJNs8W/zM/L+7ctCkRrgbNnnR0xxw4bKOr0cW0N0Og==",
"dev": true
},
"wrap-ansi": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz",
"integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==",
"dev": true,
"requires": {
"ansi-styles": "^3.2.0",
"string-width": "^3.0.0",
"strip-ansi": "^5.0.0"
}
},
"ws": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-3.3.3.tgz",
"integrity": "sha512-nnWLa/NwZSt4KQJu51MYlCcSQ5g7INpOrOMt4XV8j4dqTXdmlUmSHQ8/oLC069ckre0fRsgfvsKwbTdtKLCDkA==",
"dev": true,
"requires": {
"async-limiter": "~1.0.0",
"safe-buffer": "~5.1.0",
"ultron": "~1.1.0"
}
},
"yargs": {
"version": "13.3.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz",
"integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==",
"dev": true,
"requires": {
"cliui": "^5.0.0",
"find-up": "^3.0.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^3.0.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^13.1.2"
},
"dependencies": {
"find-up": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
"integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==",
"dev": true,
"requires": {
"locate-path": "^3.0.0"
}
},
"locate-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz",
"integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==",
"dev": true,
"requires": {
"p-locate": "^3.0.0",
"path-exists": "^3.0.0"
}
},
"p-locate": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz",
"integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==",
"dev": true,
"requires": {
"p-limit": "^2.0.0"
}
},
"path-exists": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
"integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=",
"dev": true
}
}
},
"yargs-parser": {
"version": "13.1.2",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz",
"integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==",
"dev": true,
"requires": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
}
}
}
},
"diff-sequences": {
"version": "25.2.6",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-25.2.6.tgz",
@ -7838,6 +8115,16 @@
"domelementtype": "1"
}
},
"dtrace-provider": {
"version": "0.8.8",
"resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.8.8.tgz",
"integrity": "sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg==",
"dev": true,
"optional": true,
"requires": {
"nan": "^2.14.0"
}
},
"ecc-jsbn": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
@ -8664,6 +8951,15 @@
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz",
"integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q=="
},
"exception-formatter": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/exception-formatter/-/exception-formatter-1.0.7.tgz",
"integrity": "sha512-zV45vEsjytJrwfGq6X9qd1Ll56cW4NC2mhCO6lqwMk4ZpA1fZ6C3UiaQM/X7if+7wZFmCgss3ahp9B/uVFuLRw==",
"dev": true,
"requires": {
"colors": "^1.0.3"
}
},
"exec-sh": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.3.4.tgz",
@ -9502,6 +9798,12 @@
"integrity": "sha512-j48B/ZI7VKs3sgeI2cZp7WXWmZXu7Iq5pl5/vptV5N2mq+DGFuS/ulaDjtaoLpYzuD6u8UgrUKHfgo7fDTSiBA==",
"dev": true
},
"funpermaproxy": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/funpermaproxy/-/funpermaproxy-1.0.1.tgz",
"integrity": "sha512-9pEzs5vnNtR7ZGihly98w/mQ7blsvl68Wj30ZCDAXy7qDN4CWLLjdfjtH/P2m6whsnaJkw15hysCNHMXue+wdA==",
"dev": true
},
"fuse.js": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.4.1.tgz",
@ -9523,6 +9825,15 @@
"integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
"dev": true
},
"get-port": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/get-port/-/get-port-2.1.0.tgz",
"integrity": "sha1-h4P53OvR7qSVozThpqJR54iHqxo=",
"dev": true,
"requires": {
"pinkie-promise": "^2.0.0"
}
},
"get-stdin": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-6.0.0.tgz",
@ -16517,6 +16828,44 @@
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz",
"integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s="
},
"mv": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz",
"integrity": "sha1-rmzg1vbV4KT32JN5jQPB6pVZtqI=",
"dev": true,
"optional": true,
"requires": {
"mkdirp": "~0.5.1",
"ncp": "~2.0.0",
"rimraf": "~2.4.0"
},
"dependencies": {
"glob": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz",
"integrity": "sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI=",
"dev": true,
"optional": true,
"requires": {
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "2 || 3",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"rimraf": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz",
"integrity": "sha1-7nEM5dk6j9uFb7Xqj/Di11k0sto=",
"dev": true,
"optional": true,
"requires": {
"glob": "^6.0.1"
}
}
}
},
"nan": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.14.1.tgz",
@ -16552,6 +16901,13 @@
"integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
"dev": true
},
"ncp": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz",
"integrity": "sha1-GVoh1sRuNh0vsSgbo4uR6d9727M=",
"dev": true,
"optional": true
},
"nearley": {
"version": "2.19.4",
"resolved": "https://registry.npmjs.org/nearley/-/nearley-2.19.4.tgz",
@ -16683,6 +17039,12 @@
"resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.11.2.tgz",
"integrity": "sha512-cowCX+OyzS3tN2i4BMMFxCr/pE6cQlEMTbVCugmos0TNEJQNtcG04tR41CY8lumO1I7F5GFiLaU4WavomJthaA=="
},
"node-version": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/node-version/-/node-version-1.2.0.tgz",
"integrity": "sha512-ma6oU4Sk0qOoKEAymVoTvk8EdXEobdS7m/mAGhDJ8Rouugho48crHBORAmy5BoOcv8wraPM6xumapQp5hl4iIQ==",
"dev": true
},
"normalize-package-data": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
@ -17430,6 +17792,21 @@
"resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
"integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="
},
"pinkie": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz",
"integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=",
"dev": true
},
"pinkie-promise": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz",
"integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=",
"dev": true,
"requires": {
"pinkie": "^2.0.0"
}
},
"pirates": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.1.tgz",
@ -17606,6 +17983,12 @@
"asap": "~2.0.3"
}
},
"promise-polyfill": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-6.1.0.tgz",
"integrity": "sha1-36lpQ+qcEh/KTem1hoyznTRy4Fc=",
"dev": true
},
"prompts": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.3.2.tgz",
@ -19335,6 +19718,13 @@
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"safe-json-stringify": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz",
"integrity": "sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==",
"dev": true,
"optional": true
},
"safe-regex": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz",
@ -19364,6 +19754,15 @@
"walker": "~1.0.5"
}
},
"sanitize-filename": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz",
"integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==",
"dev": true,
"requires": {
"truncate-utf8-bytes": "^1.0.0"
}
},
"sax": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
@ -19552,6 +19951,15 @@
}
}
},
"shell-utils": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/shell-utils/-/shell-utils-1.0.10.tgz",
"integrity": "sha512-p1xuqhj3jgcXiV8wGoF1eL/NOvapN9tyGDoObqKwvZTUZn7fIzK75swLTEHfGa7sObeN9vxFplHw/zgYUYRTsg==",
"dev": true,
"requires": {
"lodash": "4.x.x"
}
},
"shellwords": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz",
@ -20205,12 +20613,27 @@
}
}
},
"tail": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/tail/-/tail-2.0.4.tgz",
"integrity": "sha512-xHkZdNWIzO++g+V/rHGqVoHd2LRxz+8t8bj6FGelfb8FHBjg5yjkX7Su/8sQSBo5alIspYkRp/fU0A2SM5h+5A==",
"dev": true
},
"tapable": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz",
"integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==",
"dev": true
},
"telnet-client": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/telnet-client/-/telnet-client-1.2.8.tgz",
"integrity": "sha512-W+w4k3QAmULVNhBVT2Fei369kGZCh/TH25M7caJAXW+hLxwoQRuw0di3cX4l0S9fgH3Mvq7u+IFMoBDpEw/eIg==",
"dev": true,
"requires": {
"bluebird": "^3.5.4"
}
},
"temp": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/temp/-/temp-0.8.3.tgz",
@ -20227,6 +20650,22 @@
}
}
},
"temp-dir": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz",
"integrity": "sha1-CnwOom06Oa+n4OvqnB/AvE2qAR0=",
"dev": true
},
"tempfile": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/tempfile/-/tempfile-2.0.0.tgz",
"integrity": "sha1-awRGhWqbERTRhW/8vlCczLCXcmU=",
"dev": true,
"requires": {
"temp-dir": "^1.0.0",
"uuid": "^3.0.1"
}
},
"terminal-link": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz",
@ -20381,6 +20820,15 @@
"punycode": "^2.1.1"
}
},
"truncate-utf8-bytes": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
"integrity": "sha1-QFkjkJWS1W94pYGENLC3hInKXys=",
"dev": true,
"requires": {
"utf8-byte-length": "^1.0.1"
}
},
"ts-jest": {
"version": "26.3.0",
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-26.3.0.tgz",
@ -20795,6 +21243,12 @@
"object-assign": "^4.1.1"
}
},
"utf8-byte-length": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz",
"integrity": "sha1-9F8VDExm7uloGGUFq5P8u4rWv2E=",
"dev": true
},
"util": {
"version": "0.12.3",
"resolved": "https://registry.npmjs.org/util/-/util-0.12.3.tgz",

View file

@ -112,6 +112,7 @@
"babel-plugin-module-resolver": "4.0.0",
"babel-plugin-transform-remove-console": "6.9.4",
"deep-freeze": "0.0.1",
"detox": "17.4.4",
"enzyme": "3.11.0",
"enzyme-adapter-react-16": "1.15.3",
"enzyme-to-json": "3.5.0",
@ -160,6 +161,8 @@
"test:watch": "jest --watch --runInBand --detectOpenHandles",
"test:coverage": "jest --coverage",
"updatesnapshot": "jest --updateSnapshot",
"mmjstool": "mmjstool"
"mmjstool": "mmjstool",
"e2e:android": "cd detox && npm run e2e:android-build && npm run e2e:android-test && cd ..",
"e2e:ios": "cd detox && npm run e2e:ios-test && cd .."
}
}