diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml index edadad1fdca..7c11b1448a1 100644 --- a/.github/workflows/testBuild.yml +++ b/.github/workflows/testBuild.yml @@ -7,7 +7,7 @@ on: description: Pull Request number for correct placement of apps required: true pull_request_target: - types: [opened, synchronize] + types: [opened, synchronize, labeled] branches: ['*ci-test/**'] env: @@ -17,19 +17,32 @@ jobs: validateActor: runs-on: ubuntu-latest outputs: - IS_TEAM_MEMBER: ${{ fromJSON(steps.isUserDeployer.outputs.isTeamMember) }} + READY_TO_BUILD: ${{steps.readyToBuild.outputs.READY_TO_BUILD}} steps: - - id: isUserDeployer + - id: isUserTeamMember uses: tspascoal/get-user-teams-membership@baf2e6adf4c3b897bd65a7e3184305c165aec872 with: GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} username: ${{ github.actor }} - team: mobile-deployers + team: expensify-expensify + - name: Remove label if it was added by an unauthorized user + if: ${{ !fromJSON(steps.isUserTeamMember.outputs.isTeamMember) && github.event.label.name == 'Ready To Build' }} + uses: actions-ecosystem/action-remove-labels@v1 + with: + labels: 'Ready To Build' + - name: Throw exception if label was added by an unauthorized user + if: ${{ !fromJSON(steps.isUserTeamMember.outputs.isTeamMember) && github.event.label.name == 'Ready To Build' }} + run: | + echo "The 'Ready To Build' label was added by an unauthorized user" + exit 1 + - id: readyToBuild + name: Set READY_TO_BUILD flag + run: echo "READY_TO_BUILD=${{ fromJSON(steps.isUserTeamMember.outputs.isTeamMember) || contains(github.event.pull_request.labels.*.name, 'Ready To Build') }}" >> "$GITHUB_OUTPUT" getBranchRef: runs-on: ubuntu-latest needs: validateActor - if: ${{ fromJSON(needs.validateActor.outputs.IS_TEAM_MEMBER) }} + if: ${{ needs.validateActor.outputs.READY_TO_BUILD == 'true' }} outputs: REF: ${{steps.getHeadRef.outputs.REF}} steps: @@ -49,7 +62,7 @@ jobs: android: name: Build and deploy Android for testing needs: [validateActor, getBranchRef] - if: ${{ fromJSON(needs.validateActor.outputs.IS_TEAM_MEMBER) }} + if: ${{ needs.validateActor.outputs.READY_TO_BUILD == 'true' }} runs-on: ubuntu-latest env: PULL_REQUEST_NUMBER: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }} @@ -99,7 +112,7 @@ jobs: iOS: name: Build and deploy iOS for testing needs: [validateActor, getBranchRef] - if: ${{ fromJSON(needs.validateActor.outputs.IS_TEAM_MEMBER) }} + if: ${{ needs.validateActor.outputs.READY_TO_BUILD == 'true' }} env: PULL_REQUEST_NUMBER: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }} runs-on: macos-12 @@ -155,7 +168,7 @@ jobs: desktop: name: Build and deploy Desktop for testing needs: [validateActor, getBranchRef] - if: ${{ fromJSON(needs.validateActor.outputs.IS_TEAM_MEMBER) }} + if: ${{ needs.validateActor.outputs.READY_TO_BUILD == 'true' }} env: PULL_REQUEST_NUMBER: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }} runs-on: macos-12 @@ -192,7 +205,7 @@ jobs: web: name: Build and deploy Web needs: [validateActor, getBranchRef] - if: ${{ fromJSON(needs.validateActor.outputs.IS_TEAM_MEMBER) }} + if: ${{ needs.validateActor.outputs.READY_TO_BUILD == 'true' }} env: PULL_REQUEST_NUMBER: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }} runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 768ab38507e..8265d5fd272 100644 --- a/.gitignore +++ b/.gitignore @@ -93,4 +93,4 @@ storybook-static .jest-cache # E2E test reports -tests/e2e/.results/ +tests/e2e/results/ diff --git a/android/app/build.gradle b/android/app/build.gradle index bddf3a7d18b..d9113769a1d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -156,8 +156,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001024301 - versionName "1.2.43-1" + versionCode 1001024600 + versionName "1.2.46-0" buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString() if (isNewArchitectureEnabled()) { diff --git a/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java b/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java index 7c1f4a245cd..9b8f64d51a5 100644 --- a/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java +++ b/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java @@ -124,6 +124,9 @@ private void createAndRegisterNotificationChannel(@NonNull Context context) { * @param bitmap The bitmap image to modify. */ public Bitmap getCroppedBitmap(Bitmap bitmap) { + // Convert hardware bitmap to software bitmap so it can be drawn on the canvas + bitmap = bitmap.copy(Config.ARGB_8888, true); + Bitmap output = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Config.ARGB_8888); Canvas canvas = new Canvas(output); diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index dc18ff815bb..7f282a292bd 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.2.43 + 1.2.46 CFBundleSignature ???? CFBundleURLTypes @@ -30,7 +30,7 @@ CFBundleVersion - 1.2.43.1 + 1.2.46.0 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 7af9e83e7de..96a59774077 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.2.43 + 1.2.46 CFBundleSignature ???? CFBundleVersion - 1.2.43.1 + 1.2.46.0 diff --git a/package-lock.json b/package-lock.json index 54b0c3234ef..a3ac89f9a68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.2.43-1", + "version": "1.2.46-0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.2.43-1", + "version": "1.2.46-0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -73,7 +73,7 @@ "react-native-pdf": "^6.6.2", "react-native-performance": "^2.0.0", "react-native-permissions": "^3.0.1", - "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#7f09b2c15ffae320d769788f75bdf8948714bb10", + "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#77cc9d42c474a693755941b10ee4c2d6f50e5346", "react-native-plaid-link-sdk": "^7.2.0", "react-native-reanimated": "3.0.0-rc.6", "react-native-render-html": "6.3.1", @@ -35528,8 +35528,8 @@ }, "node_modules/react-native-picker-select": { "version": "8.0.4", - "resolved": "git+ssh://git@github.com/Expensify/react-native-picker-select.git#7f09b2c15ffae320d769788f75bdf8948714bb10", - "integrity": "sha512-fKuK7NBPYmf0rfQIPcOK1OrM31DIlQVEtdEBANWxudBXwj+okAakY9hIXPXkCd1Ow7gj5P3Z2XNle2ak6NtYPg==", + "resolved": "git+ssh://git@github.com/Expensify/react-native-picker-select.git#77cc9d42c474a693755941b10ee4c2d6f50e5346", + "integrity": "sha512-KhadZYEWeoTQv/dj2tXpCRQvoY3L9tMGcVnopiYNSzlPdbnDzJUdvdDwf2bVdR3zQXrmHjzsYUVUJx3FFu6LAA==", "license": "MIT", "dependencies": { "lodash.isequal": "^4.5.0" @@ -69867,9 +69867,9 @@ "requires": {} }, "react-native-picker-select": { - "version": "git+ssh://git@github.com/Expensify/react-native-picker-select.git#7f09b2c15ffae320d769788f75bdf8948714bb10", - "integrity": "sha512-fKuK7NBPYmf0rfQIPcOK1OrM31DIlQVEtdEBANWxudBXwj+okAakY9hIXPXkCd1Ow7gj5P3Z2XNle2ak6NtYPg==", - "from": "react-native-picker-select@git+https://github.com/Expensify/react-native-picker-select.git#7f09b2c15ffae320d769788f75bdf8948714bb10", + "version": "git+ssh://git@github.com/Expensify/react-native-picker-select.git#77cc9d42c474a693755941b10ee4c2d6f50e5346", + "integrity": "sha512-KhadZYEWeoTQv/dj2tXpCRQvoY3L9tMGcVnopiYNSzlPdbnDzJUdvdDwf2bVdR3zQXrmHjzsYUVUJx3FFu6LAA==", + "from": "react-native-picker-select@git+https://github.com/Expensify/react-native-picker-select.git#77cc9d42c474a693755941b10ee4c2d6f50e5346", "requires": { "lodash.isequal": "^4.5.0" } diff --git a/package.json b/package.json index 47e984a63f4..c589aa337ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.2.43-1", + "version": "1.2.46-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -38,7 +38,7 @@ "analyze-packages": "ANALYZE_BUNDLE=true webpack --config config/webpack/webpack.common.js --env envFile=.env.production", "symbolicate:android": "npx metro-symbolicate android/app/build/generated/sourcemaps/react/release/index.android.bundle.map", "symbolicate:ios": "npx metro-symbolicate main.jsbundle.map", - "test:e2e": "node tests/e2e/testRunner.js" + "test:e2e": "node tests/e2e/testRunner.js --development" }, "dependencies": { "@expensify/react-native-web": "0.18.9", @@ -104,7 +104,7 @@ "react-native-pdf": "^6.6.2", "react-native-performance": "^2.0.0", "react-native-permissions": "^3.0.1", - "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#7f09b2c15ffae320d769788f75bdf8948714bb10", + "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#77cc9d42c474a693755941b10ee4c2d6f50e5346", "react-native-plaid-link-sdk": "^7.2.0", "react-native-reanimated": "3.0.0-rc.6", "react-native-render-html": "6.3.1", diff --git a/scripts/android-repackage-app-bundle-and-sign.sh b/scripts/android-repackage-app-bundle-and-sign.sh new file mode 100755 index 00000000000..fe4ee1e4b8f --- /dev/null +++ b/scripts/android-repackage-app-bundle-and-sign.sh @@ -0,0 +1,105 @@ +#!/bin/bash + +### +# Takes an android app that has been built with the debug keystore, +# and re-packages it with an alternative JS bundle to run. +# It then signs the APK again, so you can simply install the app on a device. +# This is useful if you quickly want to test changes to the JS code with a +# release app, without having to rebuild the whole app. +# +# There are many outdated resources on how to re-sign an app. The main +# flow and commands have been taken from: +# - https://gist.github.com/floyd-fuh/7f7408b560672ece3ea78348559d47b6#file-repackage_apk_for_burp-py-L276-L319 +# +# This script uses `apktool` instead of manually unzipping and zipping the app. +# Only with apktool it worked without any errors, so you need to install it. +### + +BUILD_TOOLS=$ANDROID_SDK_ROOT/build-tools/31.0.0 +APK=$1 +NEW_BUNDLE_FILE=$2 +OUTPUT_APK=$3 + +### Helper function to use echo but print text in bold +echo_bold() { + echo -e "\033[1m$*\033[0m" +} + +### Validating inputs + +if [ -z "$APK" ] || [ -z "$NEW_BUNDLE_FILE" ] || [ -z "$OUTPUT_APK" ]; then + echo "Usage: $0 " + exit 1 +fi +APK=$(realpath "$APK") +if [ ! -f "$APK" ]; then + echo "APK not found: $APK" + exit 1 +fi +NEW_BUNDLE_FILE=$(realpath "$NEW_BUNDLE_FILE") +if [ ! -f "$NEW_BUNDLE_FILE" ]; then + echo "Bundle file not found: $NEW_BUNDLE_FILE" + exit 1 +fi +OUTPUT_APK=$(realpath "$OUTPUT_APK") +# check if "apktool" command is available +if ! command -v apktool &> /dev/null +then + echo "apktool could not be found. Please install it." + exit 1 +fi +# check if "jarsigner" command is available +if ! command -v jarsigner &> /dev/null +then + echo "jarsigner could not be found. Please install it." + exit 1 +fi + +KEYSTORE="$(realpath ./android/app/debug.keystore)" +ORIGINAL_WD=$(pwd) + +### Copy apk to a temp dir + +TMP_DIR=$(mktemp -d) +cp "$APK" "$TMP_DIR" +cd "$TMP_DIR" || exit + +### Dissemble app + +echo_bold "Dissembling app..." +apktool d "$APK" -o app > /dev/null + +### Copy new bundle into assets + +echo_bold "Copying new bundle into assets..." +rm app/assets/index.android.bundle +cp "$NEW_BUNDLE_FILE" app/assets/index.android.bundle + +### Reassemble app + +echo_bold "Reassembling app..." +apktool b app -o app.apk > /dev/null + +### Do jarsigner + +echo_bold "Signing app..." +jarsigner -verbose -keystore "$KEYSTORE" -storepass android -keypass android app.apk androiddebugkey + +### Do zipalign + +echo_bold "Zipaligning app..." +"$BUILD_TOOLS"/zipalign -p -v 4 app.apk app-aligned.apk + +### Do apksigner + +echo_bold "Signing app with apksigner..." +"$BUILD_TOOLS"/apksigner sign --v4-signing-enabled true --ks "$KEYSTORE" --ks-pass pass:android --ks-key-alias androiddebugkey --key-pass pass:android app-aligned.apk + +### Copy back to original location + +echo_bold "Copying back to original location..." +cp app-aligned.apk "$OUTPUT_APK" +echo "Done. Repacked app is at $OUTPUT_APK" +rm -rf "$TMP_DIR" +cd "$ORIGINAL_WD" || exit + diff --git a/src/CONST.js b/src/CONST.js index 77cdb683c3e..ae375f27ae5 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -496,11 +496,11 @@ const CONST = { ADD_PAYMENT_MENU_POSITION_X: 356, EMOJI_PICKER_SIZE: { WIDTH: 320, - HEIGHT: 400, + HEIGHT: 390, }, - NON_NATIVE_EMOJI_PICKER_LIST_HEIGHT: 298, + NON_NATIVE_EMOJI_PICKER_LIST_HEIGHT: 288, EMOJI_PICKER_ITEM_HEIGHT: 32, - EMOJI_PICKER_HEADER_HEIGHT: 38, + EMOJI_PICKER_HEADER_HEIGHT: 32, COMPOSER_MAX_HEIGHT: 125, CHAT_FOOTER_MIN_HEIGHT: 65, CHAT_SKELETON_VIEW: { diff --git a/src/components/AddPlaidBankAccount.js b/src/components/AddPlaidBankAccount.js index 3aa31b8e7d3..29d76102b4b 100644 --- a/src/components/AddPlaidBankAccount.js +++ b/src/components/AddPlaidBankAccount.js @@ -63,6 +63,7 @@ const defaultProps = { bankAccounts: [], isLoading: false, error: '', + errors: {}, }, selectedPlaidAccountID: '', plaidLinkToken: '', @@ -112,6 +113,7 @@ class AddPlaidBankAccount extends React.Component { label: `${account.addressName} ${account.mask}`, })); const {icon, iconSize} = getBankIcon(); + const plaidDataErrorMessage = !_.isEmpty(this.props.plaidData.errors) ? _.chain(this.props.plaidData.errors).values().first().value() : this.props.plaidData.error; // Plaid Link view if (!plaidBankAccounts.length) { @@ -123,9 +125,9 @@ class AddPlaidBankAccount extends React.Component { )} - {Boolean(this.props.plaidData.error) && ( + {Boolean(plaidDataErrorMessage) && ( - {this.props.plaidData.error} + {plaidDataErrorMessage} )} {Boolean(token) && ( diff --git a/src/components/DatePicker/index.android.js b/src/components/DatePicker/index.android.js index e034af725c1..ec1f23f70bd 100644 --- a/src/components/DatePicker/index.android.js +++ b/src/components/DatePicker/index.android.js @@ -5,6 +5,7 @@ import _ from 'underscore'; import TextInput from '../TextInput'; import CONST from '../../CONST'; import {propTypes, defaultProps} from './datepickerPropTypes'; +import styles from '../../styles/styles'; class DatePicker extends React.Component { constructor(props) { @@ -49,6 +50,7 @@ class DatePicker extends React.Component { placeholder={this.props.placeholder} errorText={this.props.errorText} containerStyles={this.props.containerStyles} + textInputContainerStyles={this.state.isPickerVisible ? [styles.borderColorFocus] : []} onPress={this.showPicker} editable={false} disabled={this.props.disabled} diff --git a/src/components/DatePicker/index.ios.js b/src/components/DatePicker/index.ios.js index 062134ea468..c9d076b7836 100644 --- a/src/components/DatePicker/index.ios.js +++ b/src/components/DatePicker/index.ios.js @@ -75,6 +75,7 @@ class DatePicker extends React.Component { placeholder={this.props.placeholder} errorText={this.props.errorText} containerStyles={this.props.containerStyles} + textInputContainerStyles={this.state.isPickerVisible ? [styles.borderColorFocus] : []} onPress={this.showPicker} editable={false} disabled={this.props.disabled} diff --git a/src/components/DragAndDrop/index.js b/src/components/DragAndDrop/index.js index b35c43126c9..09ce3c07dd1 100644 --- a/src/components/DragAndDrop/index.js +++ b/src/components/DragAndDrop/index.js @@ -17,6 +17,9 @@ const propTypes = { /** Guard for accepting drops in drop zone. Drag event is passed to this function as first parameter. This prop is necessary to be inlined to satisfy the linter */ shouldAcceptDrop: PropTypes.func, + /** Whether drag & drop should be disabled */ + disabled: PropTypes.bool, + /** Rendered child component */ children: PropTypes.node.isRequired, }; @@ -33,6 +36,7 @@ const defaultProps = { } return false; }, + disabled: false, }; export default class DragAndDrop extends React.Component { @@ -52,6 +56,32 @@ export default class DragAndDrop extends React.Component { } componentDidMount() { + if (this.props.disabled) { + return; + } + this.addEventListeners(); + } + + componentDidUpdate(prevProps) { + const isDisabled = this.props.disabled; + if (isDisabled === prevProps.disabled) { + return; + } + if (isDisabled) { + this.removeEventListeners(); + } else { + this.addEventListeners(); + } + } + + componentWillUnmount() { + if (this.props.disabled) { + return; + } + this.removeEventListeners(); + } + + addEventListeners() { this.dropZone = document.getElementById(this.props.dropZoneId); this.dropZoneRect = this.calculateDropZoneClientReact(); document.addEventListener('dragover', this.dropZoneDragListener); @@ -61,7 +91,7 @@ export default class DragAndDrop extends React.Component { window.addEventListener('resize', this.throttledDragNDropWindowResizeListener); } - componentWillUnmount() { + removeEventListeners() { document.removeEventListener('dragover', this.dropZoneDragListener); document.removeEventListener('dragenter', this.dropZoneDragListener); document.removeEventListener('dragleave', this.dropZoneDragListener); diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js index ab68b51802c..87e3356b7c7 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js @@ -87,6 +87,7 @@ class EmojiPickerMenu extends Component { this.onSelectionChange = this.onSelectionChange.bind(this); this.updatePreferredSkinTone = this.updatePreferredSkinTone.bind(this); this.setFirstNonHeaderIndex = this.setFirstNonHeaderIndex.bind(this); + this.getItemLayout = this.getItemLayout.bind(this); this.currentScrollOffset = 0; this.firstNonHeaderIndex = 0; @@ -190,6 +191,20 @@ class EmojiPickerMenu extends Component { document.addEventListener('mousemove', this.mouseMoveHandler); } + /** + * This function will be used with FlatList getItemLayout property for optimization purpose that allows skipping + * the measurement of dynamic content if we know the size (height or width) of items ahead of time. + * Generate and return an object with properties length(height of each individual row), + * offset(distance of the current row from the top of the FlatList), index(current row index) + * + * @param {*} data FlatList item + * @param {Number} index row index + * @returns {Object} + */ + getItemLayout(data, index) { + return {length: CONST.EMOJI_PICKER_ITEM_HEIGHT, offset: CONST.EMOJI_PICKER_ITEM_HEIGHT * index, index}; + } + /** * Cleanup all mouse/keydown event listeners that we've set up */ @@ -513,6 +528,7 @@ class EmojiPickerMenu extends Component { } stickyHeaderIndices={this.state.headerIndices} onScroll={e => this.currentScrollOffset = e.nativeEvent.contentOffset.y} + getItemLayout={this.getItemLayout} /> )} - )} - + ); } diff --git a/src/components/Picker/index.js b/src/components/Picker/index.js index 62cb9c2b427..5fa62675198 100644 --- a/src/components/Picker/index.js +++ b/src/components/Picker/index.js @@ -10,6 +10,7 @@ import Text from '../Text'; import styles from '../../styles/styles'; import themeColors from '../../styles/themes/default'; import pickerStyles from './pickerStyles'; +import {ScrollContext} from '../ScrollViewWithContext'; const propTypes = { /** Picker label */ @@ -149,6 +150,7 @@ class Picker extends PureComponent { render() { const hasError = !_.isEmpty(this.props.errorText); + return ( <> @@ -201,6 +205,7 @@ class Picker extends PureComponent { Picker.propTypes = propTypes; Picker.defaultProps = defaultProps; +Picker.contextType = ScrollContext; // eslint-disable-next-line react/jsx-props-no-spreading export default React.forwardRef((props, ref) => ); diff --git a/src/components/ReportActionItem/IOUAction.js b/src/components/ReportActionItem/IOUAction.js index affa1035eb6..486f3d10fda 100644 --- a/src/components/ReportActionItem/IOUAction.js +++ b/src/components/ReportActionItem/IOUAction.js @@ -1,7 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; -import lodashGet from 'lodash/get'; import ONYXKEYS from '../../ONYXKEYS'; import IOUQuote from './IOUQuote'; import reportActionPropTypes from '../../pages/home/report/reportActionPropTypes'; @@ -61,7 +60,6 @@ const IOUAction = (props) => { /> {shouldShowIOUPreview && ( ( {/* Get first word of IOU message */} {Str.htmlDecode(fragment.text.split(' ')[0])} - + {/* Get remainder of IOU message */} {Str.htmlDecode(fragment.text.substring(fragment.text.indexOf(' ')))} diff --git a/src/components/ScrollViewWithContext.js b/src/components/ScrollViewWithContext.js new file mode 100644 index 00000000000..6d6c74c3324 --- /dev/null +++ b/src/components/ScrollViewWithContext.js @@ -0,0 +1,64 @@ +import React from 'react'; +import {ScrollView} from 'react-native'; + +const MIN_SMOOTH_SCROLL_EVENT_THROTTLE = 16; + +const ScrollContext = React.createContext(); + +// eslint-disable-next-line react/forbid-foreign-prop-types +const propTypes = ScrollView.propTypes; + +/* +* is a wrapper around that provides a ref to the . +* can be used as a direct replacement for +* if it contains one or more / components. +* Using this wrapper will automatically handle scrolling to the picker's +* when the picker modal is opened +*/ +class ScrollViewWithContext extends React.Component { + constructor(props) { + super(props); + + this.state = { + contentOffsetY: 0, + }; + this.scrollViewRef = React.createRef(null); + + this.setContextScrollPosition = this.setContextScrollPosition.bind(this); + } + + setContextScrollPosition(event) { + if (this.props.onScroll) { + this.props.onScroll(event); + } + this.setState({contentOffsetY: event.nativeEvent.contentOffset.y}); + } + + render() { + return ( + + + {this.props.children} + + + ); + } +} +ScrollViewWithContext.propTypes = propTypes; + +export default ScrollViewWithContext; +export { + ScrollContext, +}; diff --git a/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js b/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js index ae15b244388..9630c047f47 100755 --- a/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js +++ b/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js @@ -122,9 +122,7 @@ class BaseVideoChatButtonAndMenu extends Component { > diff --git a/src/libs/actions/CloseAccount.js b/src/libs/actions/CloseAccount.js index 52539844686..ea45ae86c76 100644 --- a/src/libs/actions/CloseAccount.js +++ b/src/libs/actions/CloseAccount.js @@ -6,7 +6,7 @@ import CONST from '../../CONST'; * Clear CloseAccount error message to hide modal */ function clearError() { - Onyx.merge(ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM, {error: ''}); + Onyx.merge(ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM, {error: '', errors: null}); } /** diff --git a/src/libs/actions/Plaid.js b/src/libs/actions/Plaid.js index f6691b8f4a3..393b5bbc996 100644 --- a/src/libs/actions/Plaid.js +++ b/src/libs/actions/Plaid.js @@ -1,7 +1,6 @@ import getPlaidLinkTokenParameters from '../getPlaidLinkTokenParameters'; import ONYXKEYS from '../../ONYXKEYS'; import * as API from '../API'; -import * as Localize from '../Localize'; import CONST from '../../CONST'; /** @@ -33,6 +32,7 @@ function openPlaidBankAccountSelector(publicToken, bankName, allowDebit) { value: { isLoading: true, error: '', + errors: null, bankName, }, }], @@ -42,6 +42,7 @@ function openPlaidBankAccountSelector(publicToken, bankName, allowDebit) { value: { isLoading: false, error: '', + errors: null, }, }], failureData: [{ @@ -49,7 +50,6 @@ function openPlaidBankAccountSelector(publicToken, bankName, allowDebit) { key: ONYXKEYS.PLAID_DATA, value: { isLoading: false, - error: Localize.translateLocal('bankAccount.error.noBankAccountAvailable'), }, }], }); diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index eb55a8996e0..5b665c525b3 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -236,7 +236,10 @@ class ReportScreen extends React.Component { placeholder={( <> - + + + + )} > @@ -312,9 +315,10 @@ class ReportScreen extends React.Component { {/* Note: The report should be allowed to mount even if the initial report actions are not loaded. If we prevent rendering the report while they are loading then we'll unnecessarily unmount the ReportActionsView which will clear the new marker lines initial state. */} {(!this.isReportReadyForDisplay() || isLoadingInitialReportActions) && ( - + <> + + + )} diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index 05074699003..271132da004 100644 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -88,7 +88,10 @@ const propTypes = { isFocused: PropTypes.bool.isRequired, /** Is the composer full size */ - isComposerFullSize: PropTypes.bool.isRequired, + isComposerFullSize: PropTypes.bool, + + /** Whether user interactions should be disabled */ + disabled: PropTypes.bool, // The NVP describing a user's block status blockedFromConcierge: PropTypes.shape({ @@ -539,7 +542,7 @@ class ReportActionCompose extends React.Component { {shouldShowReportRecipientLocalTime && } e.preventDefault()} style={styles.composerSizeButton} - disabled={isBlockedFromConcierge} + disabled={isBlockedFromConcierge || this.props.disabled} > @@ -591,7 +594,7 @@ class ReportActionCompose extends React.Component { // Keep focus on the composer when Expand button is clicked. onMouseDown={e => e.preventDefault()} style={styles.composerSizeButton} - disabled={isBlockedFromConcierge} + disabled={isBlockedFromConcierge || this.props.disabled} > @@ -608,7 +611,7 @@ class ReportActionCompose extends React.Component { this.setMenuVisibility(true); }} style={styles.chatItemAttachButton} - disabled={isBlockedFromConcierge} + disabled={isBlockedFromConcierge || this.props.disabled} > @@ -654,6 +657,7 @@ class ReportActionCompose extends React.Component { this.setState({isDraggingOver: false}); }} + disabled={this.props.disabled} > this.setTextInputShouldClear(false)} - isDisabled={isComposeDisabled || isBlockedFromConcierge} + isDisabled={isComposeDisabled || isBlockedFromConcierge || this.props.disabled} selection={this.state.selection} onSelectionChange={this.onSelectionChange} isFullComposerAvailable={this.state.isFullComposerAvailable} @@ -686,7 +690,7 @@ class ReportActionCompose extends React.Component { {canUseTouchScreen() && this.props.isMediumScreenWidth ? null : ( this.focus(true)} onEmojiSelected={this.addEmojiToTextBox} /> @@ -702,7 +706,7 @@ class ReportActionCompose extends React.Component { // Keep focus on the composer when Send message is clicked. // eslint-disable-next-line react/jsx-props-no-multi-spaces onMouseDown={e => e.preventDefault()} - disabled={this.state.isCommentEmpty || isBlockedFromConcierge || hasExceededMaxCommentLength} + disabled={this.state.isCommentEmpty || isBlockedFromConcierge || this.props.disabled || hasExceededMaxCommentLength} hitSlop={{ top: 3, right: 3, bottom: 3, left: 3, }} diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js index 915b5b35474..bccb119da3a 100755 --- a/src/pages/home/report/ReportActionsView.js +++ b/src/pages/home/report/ReportActionsView.js @@ -236,10 +236,6 @@ class ReportActionsView extends React.Component { } componentWillUnmount() { - if (this.keyboardEvent) { - this.keyboardEvent.remove(); - } - if (this.unsubscribeVisibilityListener) { this.unsubscribeVisibilityListener(); } diff --git a/src/pages/home/report/ReportFooter.js b/src/pages/home/report/ReportFooter.js index 779337e489b..29fea5e2ed1 100644 --- a/src/pages/home/report/ReportFooter.js +++ b/src/pages/home/report/ReportFooter.js @@ -21,16 +21,16 @@ import reportPropTypes from '../../reportPropTypes'; const propTypes = { /** Report object for the current report */ - report: reportPropTypes.isRequired, + report: reportPropTypes, /** Report actions for the current report */ - reportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)).isRequired, + reportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), /** Offline status */ isOffline: PropTypes.bool.isRequired, /** Callback fired when the comment is submitted */ - onSubmitComment: PropTypes.func.isRequired, + onSubmitComment: PropTypes.func, /** Any errors associated with an attempt to create a chat */ // eslint-disable-next-line react/forbid-prop-types @@ -42,13 +42,20 @@ const propTypes = { /** Whether the composer input should be shown */ shouldShowComposeInput: PropTypes.bool, + /** Whether user interactions should be disabled */ + shouldDisableCompose: PropTypes.bool, + ...windowDimensionsPropTypes, }; const defaultProps = { - shouldShowComposeInput: true, + report: {reportID: '0'}, + reportActions: {}, + onSubmitComment: () => {}, errors: {}, pendingAction: null, + shouldShowComposeInput: true, + shouldDisableCompose: false, }; class ReportFooter extends React.Component { @@ -99,6 +106,7 @@ class ReportFooter extends React.Component { reportActions={this.props.reportActions} report={this.props.report} isComposerFullSize={this.props.isComposerFullSize} + disabled={this.props.shouldDisableCompose} /> diff --git a/src/pages/iou/IOUDetailsModal.js b/src/pages/iou/IOUDetailsModal.js index e4c253e9fa7..e45d24a95d2 100644 --- a/src/pages/iou/IOUDetailsModal.js +++ b/src/pages/iou/IOUDetailsModal.js @@ -22,6 +22,7 @@ import SettlementButton from '../../components/SettlementButton'; import ROUTES from '../../ROUTES'; import FixedFooter from '../../components/FixedFooter'; import networkPropTypes from '../../components/networkPropTypes'; +import reportActionPropTypes from '../home/report/reportActionPropTypes'; const propTypes = { /** URL Route params */ @@ -67,6 +68,9 @@ const propTypes = { email: PropTypes.string, }).isRequired, + /** Actions from the ChatReport */ + reportActions: PropTypes.shape(reportActionPropTypes), + /** Information about the network */ network: networkPropTypes.isRequired, @@ -75,6 +79,7 @@ const propTypes = { const defaultProps = { iou: {}, + reportActions: {}, iouReport: undefined, }; @@ -132,9 +137,17 @@ class IOUDetailsModal extends Component { } } + // Finds if there is a reportAction pending for this IOU + findPendingAction() { + return _.find(this.props.reportActions, reportAction => reportAction.originalMessage + && Number(reportAction.originalMessage.IOUReportID) === Number(this.props.route.params.iouReportID) + && !_.isEmpty(reportAction.pendingAction)); + } + render() { const sessionEmail = lodashGet(this.props.session, 'email', null); const reportIsLoading = _.isUndefined(this.props.iouReport); + const pendingAction = this.findPendingAction(); return ( `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${route.params.chatReportID}`, + canEvict: false, + }, }), )(IOUDetailsModal); diff --git a/src/pages/workspace/WorkspaceMembersPage.js b/src/pages/workspace/WorkspaceMembersPage.js index 92ac6559f89..65e7d556c57 100644 --- a/src/pages/workspace/WorkspaceMembersPage.js +++ b/src/pages/workspace/WorkspaceMembersPage.js @@ -257,7 +257,7 @@ class WorkspaceMembersPage extends React.Component { this.dismissError(item)} pendingAction={item.pendingAction} errors={item.errors}> this.willTooltipShowForLogin(item.login, true)} onHoverOut={() => this.setState({showTooltipForLogin: ''})}> this.toggleUser(item.login, item.pendingAction)} activeOpacity={0.7} > diff --git a/src/pages/workspace/WorkspacePageWithSections.js b/src/pages/workspace/WorkspacePageWithSections.js index 0816075726f..cfe5cc40aa4 100644 --- a/src/pages/workspace/WorkspacePageWithSections.js +++ b/src/pages/workspace/WorkspacePageWithSections.js @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import {View, ScrollView} from 'react-native'; +import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import lodashGet from 'lodash/get'; import _ from 'underscore'; @@ -20,6 +20,7 @@ import withPolicy from './withPolicy'; import {withNetwork} from '../../components/OnyxProvider'; import networkPropTypes from '../../components/networkPropTypes'; import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView'; +import ScrollViewWithContext from '../../components/ScrollViewWithContext'; const propTypes = { shouldSkipVBBACall: PropTypes.bool, @@ -121,7 +122,7 @@ class WorkspacePageWithSections extends React.Component { /> {this.props.shouldUseScrollView ? ( - @@ -130,7 +131,7 @@ class WorkspacePageWithSections extends React.Component { {this.props.children(hasVBA, policyID, isUsingECard)} - + ) : this.props.children(hasVBA, policyID, isUsingECard)} {this.props.footer} diff --git a/src/styles/styles.js b/src/styles/styles.js index 37811e82c43..58cfdc62df2 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -1444,7 +1444,7 @@ const styles = { }, emojiPickerList: { - height: 300, + height: 288, width: '100%', ...spacing.ph4, }, @@ -1455,7 +1455,9 @@ const styles = { emojiHeaderStyle: { backgroundColor: themeColors.componentBG, width: '100%', - ...spacing.pv3, + height: 32, + display: 'flex', + alignItems: 'center', fontFamily: fontFamily.EXP_NEUE_BOLD, fontWeight: fontWeightBold, color: themeColors.heading, diff --git a/tests/e2e/ADDING_TESTS.md b/tests/e2e/ADDING_TESTS.md index 43deeaf2f0b..39cdb97ebed 100644 --- a/tests/e2e/ADDING_TESTS.md +++ b/tests/e2e/ADDING_TESTS.md @@ -94,3 +94,9 @@ test file: Done! When you now start the test runner, your new test will be executed as well. +## Quickly test your test + +To check your new test you can simply run `npm run test:e2e`, which uses the +`--development` flag. This will run the tests on the branch you are currently on +and will do fewer iterations. + diff --git a/tests/e2e/README.md b/tests/e2e/README.md index be45d677bae..3262770bc55 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -14,9 +14,36 @@ To run the e2e tests: 2. Make sure Fastlane was initialized by running `bundle install` 3. Run the tests with `npm run test:e2e`. + > šŸ’” Tip: To run the tests locally faster, and you are only making changes to JS, it's recommended to + build the app once with `npm run android-build-e2e` and from then on run the tests with + `npm run test:e2e -- --buildMode js-only`. This will only rebuild the JS code, and not the + whole native app! Ideally you want to run these tests on your branch before you want to merge your new feature to `main`. +## Available CLI options + +The tests can be run with the following CLI options: + +- `--config`: Extend/Overwrite the default config with your values, e.g. `--config config.local.js` +- `--includes`: Expects a string/regexp to filter the tests to run, e.g. `--includes "login|signup"` +- `--skipInstallDeps`: Skips the `npm install` step, useful during development +- `--development`: Applies some default configurations: + - Sets the config to `config.local.js`, which executes the tests with fewer iterations + - Runs the tests only on the current branch +- `--buildMode`: There are three build modes, the default is `full`: + 1. **full**: rebuilds the full native app in (e2e) release mode + 2. **js-only**: only rebuilds the js bundle, and then re-packages + the existing native app with the new package. If there + is no existing native app, it will fallback to mode "full" + 3. **skip**: does not rebuild anything, and just runs the existing native app + +## Available environment variables + +The tests can be run with the following environment variables: + +- `baseline`: Change the baseline to run the tests again (default is `main`). + ## Performance regression testing The output of the tests is a set of performance metrics (see video above). diff --git a/tests/e2e/config.js b/tests/e2e/config.js index aad6b742b58..34f81467866 100644 --- a/tests/e2e/config.js +++ b/tests/e2e/config.js @@ -1,4 +1,4 @@ -const OUTPUT_DIR = process.env.WORKING_DIRECTORY || './results'; +const OUTPUT_DIR = process.env.WORKING_DIRECTORY || './tests/e2e/results'; /** * @typedef TestConfig @@ -10,9 +10,25 @@ const TEST_NAMES = { AppStartTime: 'App start time', }; +/** + * Default config, used by CI by default. + * You can modify these values for your test run by creating a + * separate config file and pass it to the test runner like this: + * + * ```bash + * npm run test:e2e -- --config ./path/to/your/config.js + * ``` + */ module.exports = { APP_PACKAGE: 'com.expensify.chat', + APP_PATHS: { + baseline: './app-e2eRelease-baseline.apk', + compare: './app-e2eRelease-compare.apk', + }, + + ENTRY_FILE: 'src/libs/E2E/reactNativeLaunchingTest.js', + // The port of the testing server that communicates with the app SERVER_PORT: 4723, @@ -21,9 +37,6 @@ module.exports = { DEFAULT_BASELINE_BRANCH: 'main', - // The amount of outliers to remove from a dataset before calculating the average - DROP_WORST: 8, - // The amount of runs that should happen without counting test results WARM_UP_RUNS: 3, diff --git a/tests/e2e/config.local.js b/tests/e2e/config.local.js new file mode 100644 index 00000000000..cd0b04d7c3c --- /dev/null +++ b/tests/e2e/config.local.js @@ -0,0 +1,8 @@ +module.exports = { + WARM_UP_RUNS: 1, + RUNS: 8, + APP_PATHS: { + baseline: './android/app/build/outputs/apk/e2eRelease/app-e2eRelease.apk', + compare: './android/app/build/outputs/apk/e2eRelease/app-e2eRelease.apk', + }, +}; diff --git a/tests/e2e/testRunner.js b/tests/e2e/testRunner.js index a5165db1ac6..a82dc3a9212 100644 --- a/tests/e2e/testRunner.js +++ b/tests/e2e/testRunner.js @@ -9,14 +9,7 @@ /* eslint-disable @lwc/lwc/no-async-await,no-restricted-syntax,no-await-in-loop */ const fs = require('fs'); const _ = require('underscore'); -const { - DEFAULT_BASELINE_BRANCH, - OUTPUT_DIR, - LOG_FILE, - RUNS, - WARM_UP_RUNS, - TESTS_CONFIG, -} = require('./config'); +const defaultConfig = require('./config'); const compare = require('./compare/compare'); const Logger = require('./utils/logger'); const execAsync = require('./utils/execAsync'); @@ -27,20 +20,57 @@ const installApp = require('./utils/installApp'); const math = require('./measure/math'); const writeTestStats = require('./measure/writeTestStats'); const withFailTimeout = require('./utils/withFailTimeout'); +const reversePort = require('./utils/androidReversePort'); +const getCurrentBranchName = require('./utils/getCurrentBranchName'); const args = process.argv.slice(2); -const baselineBranch = process.env.baseline || DEFAULT_BASELINE_BRANCH; +let config = defaultConfig; +const setConfigPath = (configPathParam) => { + let configPath = configPathParam; + if (!configPath.startsWith('.')) { + configPath = `./${configPath}`; + } + const customConfig = require(configPath); + config = _.extend(defaultConfig, customConfig); +}; + +let baselineBranch = process.env.baseline || config.DEFAULT_BASELINE_BRANCH; + +// There are three build modes: +// 1. full: rebuilds the full native app in (e2e) release mode +// 2. js-only: only rebuilds the js bundle, and then re-packages +// the existing native app with the new package. If there +// is no existing native app, it will fallback to mode "full" +// 3. skip: does not rebuild anything, and just runs the existing native app +let buildMode = 'full'; + +// When we are in dev mode we want to apply certain default params and configs +const isDevMode = args.includes('--development'); +if (isDevMode) { + setConfigPath('config.local.js'); + baselineBranch = getCurrentBranchName(); + buildMode = 'js-only'; +} + +if (args.includes('--config')) { + const configPath = args[args.indexOf('--config') + 1]; + setConfigPath(configPath); +} // Clear all files from previous jobs try { - fs.rmSync(OUTPUT_DIR, {recursive: true, force: true}); - fs.mkdirSync(OUTPUT_DIR); + fs.rmSync(config.OUTPUT_DIR, {recursive: true, force: true}); + fs.mkdirSync(config.OUTPUT_DIR); } catch (error) { // Do nothing console.error(error); } +if (isDevMode) { + Logger.note(`Running in development mode. Set baseline branch to same as current ${baselineBranch}`); +} + const restartApp = async () => { Logger.log('Killing app ā€¦'); await killApp('android'); @@ -49,26 +79,52 @@ const restartApp = async () => { }; const runTestsOnBranch = async (branch, baselineOrCompare) => { - if (!args.includes('--skipInstallDeps') && !args.includes('--skipBuild')) { - // Switch branch and install dependencies - Logger.log(`Preparing ${baselineOrCompare} tests on branch '${branch}'`); - await execAsync(`git checkout ${branch}`); + if (args.includes('--buildMode')) { + buildMode = args[args.indexOf('--buildMode') + 1]; + } + let appPath = baselineOrCompare === 'baseline' ? config.APP_PATHS.baseline : config.APP_PATHS.compare; + + // check if using buildMode "js-only" or "none" is possible + if (buildMode !== 'full') { + const appExists = fs.existsSync(appPath); + if (!appExists) { + Logger.warn(`Build mode "${buildMode}" is not possible, because the app does not exist. Falling back to build mode "full".`); + buildMode = 'full'; + } } + // Switch branch + Logger.log(`Preparing ${baselineOrCompare} tests on branch '${branch}'`); + await execAsync(`git checkout ${branch}`); + if (!args.includes('--skipInstallDeps')) { Logger.log(`Preparing ${baselineOrCompare} tests on branch '${branch}' - npm install`); await execAsync('npm i'); } // Build app - if (!args.includes('--skipBuild')) { + if (buildMode === 'full') { Logger.log(`Preparing ${baselineOrCompare} tests on branch '${branch}' - building app`); await execAsync('npm run android-build-e2e'); + } else if (buildMode === 'js-only') { + Logger.log(`Preparing ${baselineOrCompare} tests on branch '${branch}' - building js bundle`); + + // Build a new JS bundle + const tempDir = `${config.OUTPUT_DIR}/temp`; + const tempBundlePath = `${tempDir}/index.android.bundle`; + await execAsync(`rm -rf ${tempDir} && mkdir ${tempDir}`); + await execAsync(`npx react-native bundle --platform android --dev false --entry-file ${config.ENTRY_FILE} --bundle-output ${tempBundlePath}`); + + // Repackage the existing native app with the new bundle + const tempApkPath = `${tempDir}/app-release.apk`; + await execAsync(`./scripts/android-repackage-app-bundle-and-sign.sh ${appPath} ${tempBundlePath} ${tempApkPath}`); + appPath = tempApkPath; } - // Install app - let progressLog = Logger.progressInfo('Installing app'); - await installApp('android', baselineOrCompare); + // Install app and reverse port + let progressLog = Logger.progressInfo('Installing app and reversing port'); + await installApp('android', appPath); + await reversePort(); progressLog.done(); // Start the HTTP server @@ -92,14 +148,26 @@ const runTestsOnBranch = async (branch, baselineOrCompare) => { }); // Run the tests - const numOfTests = _.values(TESTS_CONFIG).length; + const numOfTests = _.values(config.TESTS_CONFIG).length; for (let testIndex = 0; testIndex < numOfTests; testIndex++) { - const config = _.values(TESTS_CONFIG)[testIndex]; - server.setTestConfig(config); + const testConfig = _.values(config.TESTS_CONFIG)[testIndex]; + + // check if we want to skip the text + if (args.includes('--includes')) { + const includes = args[args.indexOf('--includes') + 1]; - const warmupLogs = Logger.progressInfo(`Running test '${config.name}'`); - for (let warmUpRuns = 0; warmUpRuns < WARM_UP_RUNS; warmUpRuns++) { - const progressText = `(${testIndex + 1}/${numOfTests}) Warmup for test '${config.name}' (iteration ${warmUpRuns + 1}/${WARM_UP_RUNS})`; + // assume that "includes" is a regexp + if (!testConfig.name.match(includes)) { + // eslint-disable-next-line no-continue + continue; + } + } + + server.setTestConfig(testConfig); + + const warmupLogs = Logger.progressInfo(`Running test '${testConfig.name}'`); + for (let warmUpRuns = 0; warmUpRuns < config.WARM_UP_RUNS; warmUpRuns++) { + const progressText = `(${testIndex + 1}/${numOfTests}) Warmup for test '${testConfig.name}' (iteration ${warmUpRuns + 1}/${config.WARM_UP_RUNS})`; warmupLogs.updateText(progressText); await restartApp(); @@ -116,8 +184,8 @@ const runTestsOnBranch = async (branch, baselineOrCompare) => { // We run each test multiple time to average out the results const testLog = Logger.progressInfo(''); - for (let i = 0; i < RUNS; i++) { - const progressText = `(${testIndex + 1}/${numOfTests}) Running test '${config.name}' (iteration ${i + 1}/${RUNS})`; + for (let i = 0; i < config.RUNS; i++) { + const progressText = `(${testIndex + 1}/${numOfTests}) Running test '${testConfig.name}' (iteration ${i + 1}/${config.RUNS})`; testLog.updateText(progressText); await restartApp(); @@ -142,7 +210,7 @@ const runTestsOnBranch = async (branch, baselineOrCompare) => { // Calculate statistics and write them to our work file progressLog = Logger.progressInfo('Calculating statics and writing results'); - const outputFileName = `${OUTPUT_DIR}/${baselineOrCompare}.json`; + const outputFileName = `${config.OUTPUT_DIR}/${baselineOrCompare}.json`; for (const testName of _.keys(durationsByTestName)) { const stats = math.getStats(durationsByTestName[testName]); await writeTestStats( @@ -175,12 +243,17 @@ const runTests = async () => { Logger.info('\n\nE2E test suite failed due to error:', e, '\nPrinting full logs:\n\n'); // Write logcat, meminfo, emulator info to file as well: - require('node:child_process').execSync(`adb logcat -d > ${OUTPUT_DIR}/logcat.txt`); - require('node:child_process').execSync(`adb shell "cat /proc/meminfo" > ${OUTPUT_DIR}/meminfo.txt`); - require('node:child_process').execSync(`cat ~/.android/avd/${process.env.AVD_NAME || 'test'}.avd/config.ini > ${OUTPUT_DIR}/emulator-config.ini`); - require('node:child_process').execSync(`adb shell "getprop" > ${OUTPUT_DIR}/emulator-properties.txt`); + require('node:child_process').execSync(`adb logcat -d > ${config.OUTPUT_DIR}/logcat.txt`); + require('node:child_process').execSync(`adb shell "cat /proc/meminfo" > ${config.OUTPUT_DIR}/meminfo.txt`); + require('node:child_process').execSync(`adb shell "getprop" > ${config.OUTPUT_DIR}/emulator-properties.txt`); - require('node:child_process').execSync(`cat ${LOG_FILE}`); + require('node:child_process').execSync(`cat ${config.LOG_FILE}`); + try { + require('node:child_process').execSync(`cat ~/.android/avd/${process.env.AVD_NAME || 'test'}.avd/config.ini > ${config.OUTPUT_DIR}/emulator-config.ini`); + } catch (ignoredError) { + // the error is ignored, as the file might not exist if the test + // run wasn't started with an emulator + } process.exit(1); } }; diff --git a/tests/e2e/utils/androidReversePort.js b/tests/e2e/utils/androidReversePort.js new file mode 100644 index 00000000000..b644ca1538d --- /dev/null +++ b/tests/e2e/utils/androidReversePort.js @@ -0,0 +1,6 @@ +const {SERVER_PORT} = require('../config'); +const execAsync = require('./execAsync'); + +module.exports = function () { + return execAsync(`adb reverse tcp:${SERVER_PORT} tcp:${SERVER_PORT}`); +}; diff --git a/tests/e2e/utils/getCurrentBranchName.js b/tests/e2e/utils/getCurrentBranchName.js new file mode 100644 index 00000000000..3380bd23ef1 --- /dev/null +++ b/tests/e2e/utils/getCurrentBranchName.js @@ -0,0 +1,10 @@ +const {execSync} = require('node:child_process'); + +const getCurrentBranchName = () => { + const stdout = execSync('git rev-parse --abbrev-ref HEAD', { + encoding: 'utf8', + }); + return stdout.trim(); +}; + +module.exports = getCurrentBranchName; diff --git a/tests/e2e/utils/installApp.js b/tests/e2e/utils/installApp.js index 32c2f44c953..48246bb67c5 100644 --- a/tests/e2e/utils/installApp.js +++ b/tests/e2e/utils/installApp.js @@ -2,27 +2,22 @@ const {APP_PACKAGE} = require('../config'); const execAsync = require('./execAsync'); const Logger = require('./logger'); -const BASELINE_APP_PATH_FROM_ROOT = './app-e2eRelease-baseline.apk'; -const COMPARE_APP_PATH_FROM_ROOT = './app-e2eRelease-compare.apk'; - /** * Installs the app on the currently connected device for the given platform. * It removes the app first if it already exists, so it's a clean installation. * * @param {String} platform - * @param {String} baselineOrCompare + * @param {String} path * @returns {Promise} */ -module.exports = function (platform = 'android', baselineOrCompare = 'baseline') { +module.exports = function (platform = 'android', path) { if (platform !== 'android') { throw new Error(`installApp() missing implementation for platform: ${platform}`); } - const apk = baselineOrCompare === 'baseline' ? BASELINE_APP_PATH_FROM_ROOT : COMPARE_APP_PATH_FROM_ROOT; - // Uninstall first, then install return execAsync(`adb uninstall ${APP_PACKAGE}`).catch((e) => { // Ignore errors Logger.warn('Failed to uninstall app:', e); - }).finally(() => execAsync(`adb install ${apk}`)); + }).finally(() => execAsync(`adb install ${path}`)); }; diff --git a/tests/e2e/utils/logger.js b/tests/e2e/utils/logger.js index a12fecd1efa..aa198aec300 100644 --- a/tests/e2e/utils/logger.js +++ b/tests/e2e/utils/logger.js @@ -71,10 +71,17 @@ const warn = (...args) => { log(...lines); }; +const note = (...args) => { + const lines = [`\nšŸ’”${COLOR_DIM}`, ...args, `${COLOR_RESET}\n`]; + console.debug(...lines); + log(...lines); +}; + module.exports = { log, info, warn, + note, progressInfo, setLogLevelVerbose, };