diff --git a/.travis.yml b/.travis.yml index a3290fa1..d5a9b579 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,12 +1,38 @@ matrix: include: - - language: generic - services: - - docker + # - language: generic + # services: + # - docker + # before_script: + # - travis/backend/build.sh + # script: + # - travis/backend/test.sh + - language: android + jdk: oraclejdk8 + sudo: required + before_cache: + - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock + - rm -fr $HOME/.gradle/caches/*/plugin-resolution/ + cache: + directories: + - $HOME/.gradle/caches/ + - $HOME/.gradle/wrapper/ + - $HOME/.android/build-cache + android: + components: + - platform-tools + - tools + - build-tools-28.0.3 + - android-16 + - android-28 + - sys-img-armeabi-v7a-android-28 + - extra-android-m2repository + - extra-google-m2repository + - extra-google-google_play_services before_script: - - travis/backend/build.sh + - travis/frontend/build.sh script: - - travis/backend/test.sh + - travis/frontend/test.sh branches: only: diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 504c41b3..f5ea70d7 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -4,6 +4,8 @@ module.exports = { browser: true, es6: true, node: true, + jest: true, + jasmine: true, }, extends: [ 'airbnb', @@ -21,6 +23,7 @@ module.exports = { }, plugins: [ 'react', + 'detox' ], rules: { "react/prop-types": ["error", { "ignore": ["navigation"] }], diff --git a/frontend/android/app/build.gradle b/frontend/android/app/build.gradle index 3dba9f53..c8754aff 100644 --- a/frontend/android/app/build.gradle +++ b/frontend/android/app/build.gradle @@ -138,6 +138,8 @@ android { targetSdkVersion rootProject.ext.targetSdkVersion versionCode 1 versionName "1.0" + testBuildType System.getProperty('testBuildType', 'debug') // This will later be used to control the test apk build type + testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' } splits { abi { @@ -213,6 +215,8 @@ dependencies { } else { implementation jscFlavor } + androidTestImplementation('com.wix:detox:+') { transitive = true } + androidTestImplementation 'junit:junit:4.12' } // Run this once to be able to run the application with BUCK diff --git a/frontend/android/app/src/androidTest/java/com/jobtogo/DetoxTest.java b/frontend/android/app/src/androidTest/java/com/jobtogo/DetoxTest.java new file mode 100644 index 00000000..22f3e564 --- /dev/null +++ b/frontend/android/app/src/androidTest/java/com/jobtogo/DetoxTest.java @@ -0,0 +1,29 @@ +package com.jobtogo; + +import com.wix.detox.Detox; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import static org.junit.Assert.fail; + +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 mActivityRule = new ActivityTestRule<>(MainActivity.class, false, false); + + @Test + public void runDetoxTests() { + try { + Detox.runTests(mActivityRule); + } catch (Exception e) { + fail(); + } + } +} \ No newline at end of file diff --git a/frontend/android/build.gradle b/frontend/android/build.gradle index 2b4a2ea8..342418c9 100644 --- a/frontend/android/build.gradle +++ b/frontend/android/build.gradle @@ -2,8 +2,9 @@ buildscript { ext { + kotlinVersion = '1.3.10' buildToolsVersion = "28.0.3" - minSdkVersion = 16 + minSdkVersion = 18 compileSdkVersion = 28 targetSdkVersion = 28 supportLibVersion = "28.0.0" @@ -17,6 +18,7 @@ buildscript { dependencies { classpath("com.android.tools.build:gradle:3.4.1") classpath 'com.google.gms:google-services:4.3.2' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files @@ -36,6 +38,10 @@ allprojects { url("$rootDir/../node_modules/jsc-android/dist") } + maven { + // All of Detox' artifacts are provided via the npm module + url "$rootDir/../node_modules/detox/Detox-android" + } jcenter() } } diff --git a/frontend/e2e/config.json b/frontend/e2e/config.json new file mode 100644 index 00000000..9ca293f6 --- /dev/null +++ b/frontend/e2e/config.json @@ -0,0 +1,6 @@ +{ + "setupFilesAfterEnv": ["./init.js"], + "testEnvironment": "node", + "reporters": ["detox/runners/jest/streamlineReporter"], + "verbose": true +} \ No newline at end of file diff --git a/frontend/e2e/firstTest.spec.js b/frontend/e2e/firstTest.spec.js new file mode 100644 index 00000000..9ddac552 --- /dev/null +++ b/frontend/e2e/firstTest.spec.js @@ -0,0 +1,197 @@ +/* eslint-env detox/detox, mocha */ + +beforeEach(async () => { + await device.reloadReactNative(); + await element(by.id('signInAuth')).tap(); + element(by.id('email')).typeText('a\n'); + await waitFor(element(by.id('password'))).toBeVisible().withTimeout(2000); + element(by.id('password')).typeText('a\n'); + await waitFor(element(by.id('signIn'))).toBeVisible().withTimeout(2000); + await element(by.id('signIn')).tap(); +}); + +describe('Auth Screens', () => { + it('Check AuthLanding component', async () => { + await device.reloadReactNative(); + await expect(element(by.id('logoLight'))).toBeVisible(); + await expect(element(by.id('jobSeeker'))).toBeVisible(); + await expect(element(by.id('google'))).toBeVisible(); + await expect(element(by.id('signInAuth'))).toBeVisible(); + + await element(by.id('signInAuth')).tap(); + await expect(element(by.id('logoSignin'))).toBeVisible(); + await expect(element(by.id('email'))).toBeVisible(); + await expect(element(by.id('password'))).toBeVisible(); + await expect(element(by.id('signIn'))).toBeVisible(); + }); + + it('Correct login sequence', async () => { + await device.reloadReactNative(); + await element(by.id('signInAuth')).tap(); + await element(by.id('email')).typeText('a\n'); + await element(by.id('signInAuth')).tap(); + await waitFor(element(by.id('password'))).toBeVisible().withTimeout(2000); + await element(by.id('password')).typeText('a\n'); + await waitFor(element(by.id('signIn'))).toBeVisible().withTimeout(2000); + await element(by.id('signIn')).tap(); + await waitFor(element(by.id('jobSwipe'))).toBeVisible(); + }); + + it('Incorrect login sequence', async () => { + await device.reloadReactNative(); + await element(by.id('signInAuth')).tap(); + await element(by.id('email')).typeText('\n'); + await waitFor(element(by.id('password'))).toBeVisible().withTimeout(2000); + await element(by.id('password')).typeText('\n'); + await waitFor(element(by.id('signIn'))).toBeVisible().withTimeout(2000); + // await element(by.id('signIn')).tap(); + await expect(element(by.id('loginErrorMsg'))).toBeVisible(); + // TODO: test for difference error responses + }); +}); + +describe('Home Screen', () => { + it('Check Jobswipe components', async () => { + await expect(element(by.id('mainHeader'))).toBeVisible(); + await expect(element(by.id('inbox'))).toBeVisible(); + // Check the inbox model + await element(by.id('inbox')).tap(); + await expect(element(by.id('mainHeader'))).toBeVisible(); + await expect(element(by.id('infoDisplay'))).toBeVisible(); + + // Switch back from the inbox model + await element(by.id('inbox')).tap(); + await expect(element(by.id('mainHeader'))).toBeVisible(); + }); + + it('Check share modal', async () => { + // Check the share job modal + await element(by.id('card0')).tap(); + await expect(element(by.id('shareModal'))).toBeVisible(); + + // Close the share job modal + await element(by.id('closeShare')).tap(); + await expect(element(by.id('shareModal'))).toBeNotVisible(); + await expect(element(by.id('mainHeader'))).toBeVisible(); + }); + + it('Check info modal', async () => { + // Check the share job modal + await element(by.id('card0Open')).tap(); + await waitFor(element(by.id('card0Description'))).toBeVisible().withTimeout(2000); + await expect(element(by.id('card0Description'))).toBeVisible(); + + // Close the share job modal + await element(by.id('card0Close')).tap(); + await expect(element(by.id('card0Description'))).toBeNotVisible(); + }); +}); + +describe('Liked Screen', () => { + it('Check Liked components', async () => { + await element(by.id('liked')).tap(); + await expect(element(by.id('navHeaderLiked'))).toBeVisible(); + await expect(element(by.id('search'))).toBeVisible(); + await expect(element(by.id('user'))).toBeVisible(); + await expect(element(by.text('Liked Jobs'))).toBeVisible(); + + await expect(element(by.id('sendJobs'))).toBeVisible(); + await expect(element(by.id('likedJobs'))).toBeVisible(); + await expect(element(by.id('jobItem0'))).toBeVisible(); + }); +}); + +describe('Friends Screen', () => { + it('Check Friends components', async () => { + await element(by.id('friends')).tap(); + + // Nav header + await expect(element(by.id('navHeaderFriends'))).toBeVisible(); + await expect(element(by.id('search'))).toBeVisible(); + await expect(element(by.id('user'))).toBeVisible(); + + // Switch navigator + await expect(element(by.id('switchNav'))).toBeVisible(); + await expect(element(by.id('switchNavOption1'))).toBeVisible(); + await expect(element(by.id('switchNavOption2'))).toBeVisible(); + await expect(element(by.text('Pending Friends'))).toBeVisible(); + await expect(element(by.text('Your Friends'))).toBeVisible(); + + // User should have no pending friends + await expect(element(by.id('infoDisplay'))).toBeVisible(); + }); + + it('Navigate between Pending/Your Friends', async () => { + await element(by.id('friends')).tap(); + await element(by.id('switchNavOption2')).tap(); + await element(by.id('switchNavOption1')).tap(); + + // xfail once friends are added to user + await expect(element(by.id('infoDisplay'))).toBeVisible(); + }); + + it('Display 100 friends', async () => { + const NON_FUNC_REQ_FRIEND_COUNT = 100; + await element(by.id('friends')).tap(); + await element(by.id('switchNavOption2')).tap(); + const userNums = Array.from(Array(NON_FUNC_REQ_FRIEND_COUNT).keys()); + + await Promise.all(userNums.map(async num => { + await waitFor(element(by.id(`userItem${num}`))) + .toBeVisible().whileElement(by.id('userList')).scroll(300, 'down'); + })); + }); + + it('Check user search component', async () => { + await element(by.id('friends')).tap(); + await element(by.id('search')).tap(); + await expect(element(by.id('searchBack'))).toBeVisible(); + await expect(element(by.id('searchInput'))).toBeVisible(); + }); + + it('Users search sequence', async () => { + await element(by.id('friends')).tap(); + await element(by.id('search')).tap(); + + await expect(element(by.id('userList'))).toBeNotVisible(); + + element(by.id('searchInput')).typeText('search query\n'); + await element(by.id('searchBack')).tap(); + + await expect(element(by.id('userList'))).toBeVisible(); + + // Go back to previous screen + await expect(element(by.id('editFriends'))).toBeVisible(); + }); +}); + +describe('Resume Page', () => { + it('Check resume components', async () => { + await element(by.id('resume')).tap(); + await expect(element(by.id('navHeaderSkills'))).toBeVisible(); + await expect(element(by.id('textSkills'))).toBeVisible(); + await expect(element(by.id('checkingDoc'))).toBeVisible(); + await expect(element(by.id('uploadResume'))).toBeVisible(); + // await element(by.id('uploadResume')).tap(); + // todo + }); +}); + +describe('Navigation', () => { + it('Navigation between pages', async () => { + // Check navigation buttons + await expect(element(by.id('home'))).toBeVisible(); + await expect(element(by.id('liked'))).toBeVisible(); + await expect(element(by.id('friends'))).toBeVisible(); + await expect(element(by.id('resume'))).toBeVisible(); + + await element(by.id('friends')).tap(); + await expect(element(by.id('editFriends'))).toBeVisible(); + await element(by.id('resume')).tap(); + await expect(element(by.id('editSkills'))).toBeVisible(); + await element(by.id('home')).tap(); + await expect(element(by.id('jobSwipe'))).toBeVisible(); + await element(by.id('liked')).tap(); + await expect(element(by.id('sendLikedJobs'))).toBeVisible(); + }); +}); diff --git a/frontend/e2e/init.js b/frontend/e2e/init.js new file mode 100644 index 00000000..7bb7b02d --- /dev/null +++ b/frontend/e2e/init.js @@ -0,0 +1,27 @@ +/* eslint-env detox/detox, mocha */ + +/* eslint-disable import/no-extraneous-dependencies */ +import detox from 'detox'; +import adapter from 'detox/runners/jest/adapter'; +import specReporter from 'detox/runners/jest/specReporter'; +import packageConfig from '../package.json'; + +const config = packageConfig.detox; + +// Set the default timeout +jest.setTimeout(120000); +jasmine.getEnv().addReporter(adapter); +jasmine.getEnv().addReporter(specReporter); + +beforeAll(async () => { + await detox.init(config); +}); + +beforeEach(async () => { + await adapter.beforeEach(); +}); + +afterAll(async () => { + await adapter.afterAll(); + await detox.cleanup(); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c975e050..2aa853d5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1103,6 +1103,12 @@ "esutils": "^2.0.2" } }, + "eslint-plugin-jest": { + "version": "22.4.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-22.4.1.tgz", + "integrity": "sha512-gcLfn6P2PrFAVx3AobaOzlIEevpAEf9chTpFZz7bYfc7pz8XRv7vuKTIE4hxPKZSha6XWKKplDQ0x9Pq8xX2mg==", + "dev": true + }, "eslint-plugin-react": { "version": "7.12.4", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.12.4.tgz", @@ -3068,6 +3074,15 @@ } } }, + "eslint-plugin-detox": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-detox/-/eslint-plugin-detox-1.0.0.tgz", + "integrity": "sha1-LZwBMOjrxM7Vbvtu6vDQ9cFjOY0=", + "dev": true, + "requires": { + "requireindex": "~1.1.0" + } + }, "eslint-plugin-eslint-comments": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-3.1.2.tgz", @@ -3118,10 +3133,10 @@ } } }, - "eslint-plugin-jest": { - "version": "22.4.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-22.4.1.tgz", - "integrity": "sha512-gcLfn6P2PrFAVx3AobaOzlIEevpAEf9chTpFZz7bYfc7pz8XRv7vuKTIE4hxPKZSha6XWKKplDQ0x9Pq8xX2mg==", + "eslint-plugin-jasmine": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jasmine/-/eslint-plugin-jasmine-4.1.0.tgz", + "integrity": "sha512-Vfuk2Sm1ULR7MqGjVIOOEdQWyoFBfSwvwUeo9MrajVGJB3C24c9Mmj1Cgf8Qwmf3aS2bezPt1sckpKXWpd74Dw==", "dev": true }, "eslint-plugin-jsx-a11y": { @@ -8064,6 +8079,12 @@ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz", "integrity": "sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE=" }, + "requireindex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.1.0.tgz", + "integrity": "sha1-5UBLgVV+91225JxacgBIk/4D4WI=", + "dev": true + }, "resolve": { "version": "1.12.0", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.12.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 60523126..d5b3b4e3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,8 @@ "scripts": { "start": "react-native start", "test": "jest", - "lint": "eslint src" + "lint": "eslint src", + "postinstall": "npx jetify" }, "dependencies": { "axios": "^0.19.0", @@ -39,7 +40,9 @@ "detox": "^14.8.0", "eslint": "^6.6.0", "eslint-config-airbnb": "^18.0.1", + "eslint-plugin-detox": "^1.0.0", "eslint-plugin-import": "^2.18.2", + "eslint-plugin-jasmine": "^4.1.0", "eslint-plugin-jsx-a11y": "^6.2.3", "eslint-plugin-react": "^7.16.0", "eslint-plugin-react-hooks": "^1.7.0", @@ -54,5 +57,18 @@ }, "jest": { "preset": "react-native" + }, + "detox": { + "test-runner": "jest", + "configurations": { + "android.emu.debug": { + "binaryPath": "android/app/build/outputs/apk/debug/app-debug.apk", + "build": "cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug && cd ..", + "type": "android.emulator", + "device": { + "avdName": "Pixel_API_28" + } + } + } } } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 86faabec..baec0082 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -130,6 +130,7 @@ const AppStack = createBottomTabNavigator( screen: JobSwipe, navigationOptions: { tabBarLabel: 'Home', + tabBarTestID: 'home', tabBarIcon: HomeTabIcon, }, }, @@ -137,6 +138,7 @@ const AppStack = createBottomTabNavigator( screen: SendLikedJobs, navigationOptions: { tabBarLabel: 'Liked', + tabBarTestID: 'liked', tabBarIcon: LikedTabIcon, }, }, @@ -144,6 +146,7 @@ const AppStack = createBottomTabNavigator( screen: EditFriends, navigationOptions: { tabBarLabel: 'Friends', + tabBarTestID: 'friends', tabBarIcon: FriendsTabIcon, }, }, @@ -151,6 +154,7 @@ const AppStack = createBottomTabNavigator( screen: EditSkills, navigationOptions: { tabBarLabel: 'Resume', + tabBarTestID: 'resume', tabBarIcon: ResumeTabIcon, }, }, diff --git a/frontend/src/components/Button/Button.jsx b/frontend/src/components/Button/Button.jsx index 29de3a47..bbb77cbe 100644 --- a/frontend/src/components/Button/Button.jsx +++ b/frontend/src/components/Button/Button.jsx @@ -5,9 +5,10 @@ import PropTypes from 'prop-types'; import styles from './styles'; const Button = ({ - style, backgroundColor, textColor, title, enable, onPress, + style, backgroundColor, textColor, title, enable, onPress, testID, }) => ( ( @@ -20,6 +21,7 @@ const IconButton = ({ IconButton.defaultProps = { color: colours.gray, size: sizes.icon, + testID: '', }; IconButton.propTypes = { @@ -27,6 +29,7 @@ IconButton.propTypes = { color: string, size: number, onPress: func.isRequired, + testID: string, }; export default IconButton; diff --git a/frontend/src/components/ImageButton/ImageButton.jsx b/frontend/src/components/ImageButton/ImageButton.jsx index 8fc5dfb4..d236f837 100644 --- a/frontend/src/components/ImageButton/ImageButton.jsx +++ b/frontend/src/components/ImageButton/ImageButton.jsx @@ -1,11 +1,12 @@ import React from 'react'; import { TouchableOpacity, Image } from 'react-native'; -import { number, func } from 'prop-types'; +import { number, func, string } from 'prop-types'; import styles from './styles'; -const ImageButton = ({ source, onPress }) => ( +const ImageButton = ({ source, onPress, testID }) => ( @@ -17,11 +18,13 @@ const ImageButton = ({ source, onPress }) => ( ImageButton.defaultProps = { onPress: () => {}, + testID: '', }; ImageButton.propTypes = { source: number.isRequired, onPress: func, + testID: string, }; export default ImageButton; diff --git a/frontend/src/components/InfoDisplay/InfoDisplay.jsx b/frontend/src/components/InfoDisplay/InfoDisplay.jsx index f899293f..50dff7c1 100644 --- a/frontend/src/components/InfoDisplay/InfoDisplay.jsx +++ b/frontend/src/components/InfoDisplay/InfoDisplay.jsx @@ -8,7 +8,7 @@ import { errors } from '../../constants/messages'; import styles from './styles'; const InfoDisplay = ({ message, source }) => ( - + ( @@ -35,8 +35,12 @@ class JobDetails extends Component { transparent visible={modalVisible} > - + - + diff --git a/frontend/src/components/MainHeader/MainHeader.jsx b/frontend/src/components/MainHeader/MainHeader.jsx index 033deb35..69811266 100644 --- a/frontend/src/components/MainHeader/MainHeader.jsx +++ b/frontend/src/components/MainHeader/MainHeader.jsx @@ -8,9 +8,10 @@ import styles from './styles'; import { colours, sizes } from '../../styles'; const MainHeader = ({ onPress }) => ( - + { let button; if (buttonOption === 'search') { button = ( + {button} - {title} + {title} ); @@ -58,12 +60,14 @@ const NavHeader = ({ NavHeader.defaultProps = { buttonOption: 'none', onPressButton: () => {}, + testID: '', }; NavHeader.propTypes = { title: string.isRequired, buttonOption: oneOf(['search', 'back', 'none']), onPressButton: func, + testID: string, }; export default NavHeader; diff --git a/frontend/src/components/SearchBar/SearchBar.jsx b/frontend/src/components/SearchBar/SearchBar.jsx index ad2fae25..cea0d790 100644 --- a/frontend/src/components/SearchBar/SearchBar.jsx +++ b/frontend/src/components/SearchBar/SearchBar.jsx @@ -9,12 +9,14 @@ import { colours, sizes } from '../../styles'; const SearchBar = ({ value, onChangeText, onEndSearch }) => ( ( - + ( - + @@ -21,6 +22,7 @@ const SwitchableNav = ({ diff --git a/frontend/src/screens/AuthLanding/AuthLanding.jsx b/frontend/src/screens/AuthLanding/AuthLanding.jsx index 8239c0e0..397df402 100644 --- a/frontend/src/screens/AuthLanding/AuthLanding.jsx +++ b/frontend/src/screens/AuthLanding/AuthLanding.jsx @@ -55,15 +55,18 @@ export default class AuthLanding extends Component { return (