diff --git a/README.md b/README.md index 5f925f08b8..e59ba2a33b 100644 --- a/README.md +++ b/README.md @@ -44,15 +44,29 @@ A debug build can also be built through Xcode. Running a build on a physical device requires the appropriate code signing certificates. -## Testing and Formatting +## Testing -Testing uses Jest. The following script enables watch and testing coverage display as well. +We use Jest for unit tests, and [detox](https://github.com/wix/Detox) + Jest for end-to-end tests. +To run unit tests, with watch and testing coverage display enabled: ```bash yarn test --watch --coverage ``` -Use `yarn run` to display all scripts, e.g. for formatting. +To run e2e tests, first make sure to start a bundling server using `yarn start` + +To run e2e tests on device: +```bash +yarn e2e:android +``` + +To run e2e tests on emulator/simulator: +```bash +yarn e2e:android:emu +yarn e2e:ios:sim +``` + +## Code Style and Formatting - We use [ESLint](https://eslint.org/) and [Prettier](https://prettier.io/) to keep a consistent style across the codebase. - There are plugins available for a range of IDEs and text editors; automatic formatting on save is also supported in some editors. diff --git a/android/app/build.gradle b/android/app/build.gradle index 3f3daec3d4..ffdffbf68b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -111,6 +111,8 @@ android { versionName "1.7.0" vectorDrawables.useSupportLibrary = true missingDimensionStrategy 'react-native-camera', 'general' + testBuildType System.getProperty('testBuildType', 'debug') // This will later be used to control the test apk build type + testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' } splits { abi { @@ -139,6 +141,7 @@ android { debug { applicationIdSuffix ".debug" debuggable true + splits.abi.enable false } staging { initWith debug @@ -178,6 +181,8 @@ dependencies { implementation fileTree(dir: "libs", include: ["*.jar"]) implementation "com.android.support:appcompat-v7:${rootProject.ext.supportLibVersion}" implementation "com.facebook.react:react-native:+" // From node_modules + 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/android/app/src/androidTest/java/com/jolocomwallet/DetoxTest.java b/android/app/src/androidTest/java/com/jolocomwallet/DetoxTest.java new file mode 100644 index 0000000000..0302d133c6 --- /dev/null +++ b/android/app/src/androidTest/java/com/jolocomwallet/DetoxTest.java @@ -0,0 +1,24 @@ +package com.jolocomwallet; + +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 mActivityRule = new ActivityTestRule<>(MainActivity.class, false, false); + + @Test + public void runDetoxTests() { + Detox.runTests(mActivityRule); + } +} diff --git a/android/build.gradle b/android/build.gradle index 2eaa19a195..7001456ab9 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -3,10 +3,13 @@ buildscript { ext { buildToolsVersion = "28.0.3" - minSdkVersion = 16 + // detox requires at least version 18 + minSdkVersion = 18 compileSdkVersion = 28 targetSdkVersion = 28 supportLibVersion = "28.0.0" + // detox instrumentation app uses kotlin + kotlinVersion = "1.3.0" } repositories { google() @@ -14,6 +17,8 @@ buildscript { } dependencies { classpath 'com.android.tools.build:gradle:3.3.1' + // detox instrumentation app uses kotlin + 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 @@ -33,5 +38,9 @@ allprojects { // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm url "$rootDir/../node_modules/react-native/android" } + maven { + // All of Detox' artifacts are provided via the npm module + url "$rootDir/../node_modules/detox/Detox-android" + } } } diff --git a/e2e/01_new_user/01_landing.spec.ts b/e2e/01_new_user/01_landing.spec.ts new file mode 100644 index 0000000000..620be8012d --- /dev/null +++ b/e2e/01_new_user/01_landing.spec.ts @@ -0,0 +1,20 @@ +import { expect } from 'detox' + +describe('Landing', () => { + describe('Landing Screen', () => { + it('should show a landingCarousel', async () => { + const landingCarousel = element(by.id('landingCarousel')) + await expect(landingCarousel).toBeVisible() + }) + + it('should show a getStarted button', async () => { + const getStarted = element(by.id('getStarted')) + await expect(getStarted).toBeVisible() + }) + + it('should show a recoverIdentity button', async () => { + const recoverIdentity = element(by.id('recoverIdentity')) + await expect(recoverIdentity).toBeVisible() + }) + }) +}) diff --git a/e2e/01_new_user/02_creation.spec.ts b/e2e/01_new_user/02_creation.spec.ts new file mode 100644 index 0000000000..c535ac3039 --- /dev/null +++ b/e2e/01_new_user/02_creation.spec.ts @@ -0,0 +1,59 @@ +import { expect } from 'detox' +import jestExpect from 'expect' +import { readVisibleText } from 'e2e/utils' + +describe('Identity Creation', () => { + describe('Entropy Screen', () => { + let progressRegexp = /(\d+) %/ + + beforeAll(async () => { + // we must disable automatic synchronization because of the inifinite loop + // animation on the entropy screen + await device.disableSynchronization() + const getStarted = element(by.id('getStarted')) + await getStarted.tap() + // and manually synchronize by waiting for entropyMessage to be visible + // NOTE: waitFor polls + await waitFor(element(by.id('entropyMsg'))).toBeVisible().withTimeout(2000) + }) + + it('should show an entropyMsg help text at first', async () => { + const entropyMsg = element(by.id('entropyMsg')) + await expect(entropyMsg).toBeVisible() + const text = await readVisibleText('entropyMsg') + jestExpect(text).not.toMatch(progressRegexp) + }) + + it('should show a percentage of entropy collected on swipe in scratchArea', async () => { + const scratchArea = element(by.id('scratchArea')) + await scratchArea.swipe('right', 'slow') + + // at this point the animation is hidden + await device.enableSynchronization() + + const text = await readVisibleText('entropyMsg') + jestExpect(text).toMatch(progressRegexp) + + const swipeDirs: Detox.Direction[] = ['up', 'down', 'left', 'right'] + for (let progress = 0; progress < 100; ) { + let text; + try { + text = await readVisibleText('entropyMsg') + } catch (err) { + // if we made enough progress then screen will have changed and + // entropyMsg will not be visible + if (progress > 90) break + else throw err + } + const match = progressRegexp.exec(text) + jestExpect(match).toHaveLength(2) + progress = parseInt((match as Array)[1]) + await scratchArea.swipe(swipeDirs[progress % 4], 'fast') + } + }) + + it('should navigate home after successful creation', async () => { + await waitFor(element(by.id('claimsScreen'))).toBeVisible().withTimeout(30000) + }) + }) +}) diff --git a/e2e/01_new_user/03_recovery.spec.ts b/e2e/01_new_user/03_recovery.spec.ts new file mode 100644 index 0000000000..4dbfd70a07 --- /dev/null +++ b/e2e/01_new_user/03_recovery.spec.ts @@ -0,0 +1,83 @@ +import { expect } from 'detox' +import { readVisibleText } from 'e2e/utils'; +import jestExpect from 'expect' + +describe('Identity Recovery', () => { + beforeAll(async () => { + const recoverIdentity = element(by.id('recoverIdentity')) + await recoverIdentity.tap() + }) + + describe('Input Seed Phrase Screen', () => { + it('should show a recoveryMsg', async () => { + const recoveryMsg = element(by.id('recoveryMsg')) + await expect(recoveryMsg).toBeVisible() + }) + + it('should show a seedWordFld', async () => { + const seedWordFld = element(by.id('seedWordFld')) + await expect(seedWordFld).toBeVisible() + await seedWordFld.tap() + }) + + const seedPhraseWords = [ + 'school', 'leopard', 'pretty', 'shell', + 'soup', 'paddle', 'spot', 'absurd', + 'blame', 'morning', 'perfect', 'local', + ] + + it('should show word suggestions based on input prefix', async () => { + const seedWordFld = element(by.id('seedWordFld')) + + for (let w = 0; w < 4; w++) { + const word = seedPhraseWords[w] + + // type a part of the word and expect suggestions + const wordPrefix = word.slice(0, word.length/2) + await seedWordFld.replaceText(wordPrefix) + for (let e = 0; e < 10; e++) { + let suggestion + try { + suggestion = await readVisibleText(`seedSuggestion${e}`) + } catch (err) { + // we don't know how many suggestions there are, so just break + // TODO figure out how to count with detox + break + } + jestExpect(suggestion.slice(0, wordPrefix.length)).toMatch(wordPrefix) + } + } + }) + + it('should add words to seed phrase on tap', async () => { + const seedWordFld = element(by.id('seedWordFld')) + const seedPhraseMsg = element(by.id('seedPhraseMsg')) + await expect(seedPhraseMsg).toBeVisible() + + for (let w = 0; w < seedPhraseWords.length; w++) { + const word = seedPhraseWords[w] + // type the full word and expect to have it as the first suggestion + // TODO test that it is the only suggestion + await seedWordFld.replaceText(word) + const suggestionBtn = element(by.id('seedSuggestion0').withDescendant(by.text(word))) + await expect(suggestionBtn).toBeVisible() + await suggestionBtn.tap() + + // expect the seedPhrase displayed to be updated + const curSeedPhrase = await readVisibleText('seedPhraseMsg') + const expectedSeedPhrase = seedPhraseWords.slice(0, w+1).join('') + jestExpect(curSeedPhrase).toEqual(expectedSeedPhrase) + } + }) + + it('should show a restoreAccount button', async () => { + const restoreAccount = element(by.id('restoreAccount')) + await expect(restoreAccount).toBeVisible() + await restoreAccount.tap() + }) + + it('should navigate home after a successful restore', async () => { + await waitFor(element(by.id('claimsScreen'))).toBeVisible().withTimeout(10000) + }) + }) +}) diff --git a/e2e/02_interactions/01_payment.spec.ts b/e2e/02_interactions/01_payment.spec.ts new file mode 100644 index 0000000000..a7d6fb4e35 --- /dev/null +++ b/e2e/02_interactions/01_payment.spec.ts @@ -0,0 +1,6 @@ +describe('Payment', () => { + describe('Payment Screen', () => { + it('should be tested', () => { + }) + }) +}) diff --git a/e2e/02_interactions/02_authentication.spec.ts b/e2e/02_interactions/02_authentication.spec.ts new file mode 100644 index 0000000000..fa4223a582 --- /dev/null +++ b/e2e/02_interactions/02_authentication.spec.ts @@ -0,0 +1,6 @@ +describe('Authentication', () => { + describe('Authentication Screen', () => { + it('should be tested', () => { + }) + }) +}) diff --git a/e2e/02_interactions/03_credential_request.spec.ts b/e2e/02_interactions/03_credential_request.spec.ts new file mode 100644 index 0000000000..ed7f04c0df --- /dev/null +++ b/e2e/02_interactions/03_credential_request.spec.ts @@ -0,0 +1,6 @@ +describe('Credential Request', () => { + describe('Credential Request Screen', () => { + it('should be tested', () => { + }) + }) +}) diff --git a/e2e/02_interactions/04_credential_offer.spec.ts b/e2e/02_interactions/04_credential_offer.spec.ts new file mode 100644 index 0000000000..6bf21712ca --- /dev/null +++ b/e2e/02_interactions/04_credential_offer.spec.ts @@ -0,0 +1,6 @@ +describe('Credential Offer', () => { + describe('Credential Offer Screen', () => { + it('should be tested', () => { + }) + }) +}) diff --git a/e2e/02_interactions/05_errors.spec.ts b/e2e/02_interactions/05_errors.spec.ts new file mode 100644 index 0000000000..d748f94571 --- /dev/null +++ b/e2e/02_interactions/05_errors.spec.ts @@ -0,0 +1,4 @@ +describe('Interaction Errors', () => { + it('should be tested', () => { + }) +}) diff --git a/e2e/init.js b/e2e/init.js new file mode 100644 index 0000000000..245a05723e --- /dev/null +++ b/e2e/init.js @@ -0,0 +1,33 @@ +const detox = require('detox') +const getDetoxConfig = require('./utils').getDetoxConfig +const adapter = require('detox/runners/jest/adapter') +const specReporter = require('detox/runners/jest/specReporter') + +// Set the default timeout +jest.setTimeout(120000) +jasmine.getEnv().addReporter(adapter) + +// This takes care of generating status logs on a per-spec basis. By default, +// jest only reports at file-level. This is strictly optional. +jasmine.getEnv().addReporter(specReporter) + +beforeAll(async () => { + try { + const detoxConfig = await getDetoxConfig() + await detox.init(detoxConfig) + } catch (err) { + // when detox init fails we should really stop the tests otherwise we get a + // bunch of unrelated and misleading errors + console.error('Detox init failed!', err) + process.exit(1) + } +}); + +beforeEach(async () => { + await adapter.beforeEach() +}); + +afterAll(async () => { + await adapter.afterAll() + await detox.cleanup() +}); diff --git a/e2e/jest.config.js b/e2e/jest.config.js new file mode 100644 index 0000000000..7e2cda20d3 --- /dev/null +++ b/e2e/jest.config.js @@ -0,0 +1,41 @@ +module.exports = { + "setupFilesAfterEnv": ["./init.js"], + "testEnvironment": "node", + "reporters": ["detox/runners/jest/streamlineReporter"], + "verbose": true, + + globals: { + "ts-jest": { + babelConfig: true, + diagnostics: { + warnOnly: true + } + } + }, + + moduleNameMapper: { + "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/tests/assetsTransformer.js" + }, + + moduleFileExtensions: [ + "ts", + "tsx", + "js", + "jsx", + "json", + "node" + ], + + moduleDirectories: [ + "/../node_modules", + "/.." + ], + + testPathIgnorePatterns: [ + "/node_modules/.*" + ], + + transform: { + "^.+\\.tsx?$": "ts-jest" + } +} diff --git a/e2e/utils.ts b/e2e/utils.ts new file mode 100644 index 0000000000..fd0335c0f3 --- /dev/null +++ b/e2e/utils.ts @@ -0,0 +1,133 @@ +import { expect } from 'detox' + +export const getNativeType = (typeName: string) => { + // NOTE: we can only access 'device' after detox.init + + // type names are platform dependent based on: + // https://github.com/wix/Detox/blob/master/docs/APIRef.Matchers.md#bytypenativeviewtype + return device.getPlatform() == 'android' ? + `android.widget.${typeName}View` : + `RCT${typeName}View` +} + +export const getDetoxConfig = async () => { + const detoxConfig = require('../package.json').detox + const ADB = require('detox/src/devices/android/ADB') + const adb = new ADB() + const configs = detoxConfig.configurations + + // TODO figure out iOS configs + const newConfigs = detoxConfig.configurations = { + 'ios.sim.debug': configs['ios.sim.debug'] + } + + // detox device configurations are generated dynamically here after querying + // ADB for android devices and emulator, instead of hardcoding in package.json + + try { + const devices = await adb.devices() + devices.forEach((device: any) => { + const key = 'android' + (device.type == 'emulator' ? '.emu' : '') + const releaseTypes = ['debug', 'release'] + releaseTypes.forEach(releaseType => { + const configKey = `${key}.${releaseType}` + newConfigs[configKey] = configs[configKey] + newConfigs[configKey].name = device.name + }) + }) + } catch(err) { + console.error("Could not find android device/emulator") + throw err + } + + return detoxConfig +} + +/** + * @async + * @desc Returns the visible text from the element with testID + * initially based on https://github.com/wix/detox/issues/445#issuecomment-514801808 + * @param testID the testID of a element or a parent of one or more + * elements. If more than one is found the result is + * concatted + * @param index index of element value to return, instead of all + * concatted children. Leave undefined for default behavior + * @returns visibleText all text visible inside the element with testID + */ +export const readVisibleText = async (testID: string, index: number | undefined = undefined): Promise => { + let el = element(by.id(testID).and(by.type(getNativeType('Text')))) + if (index !== undefined) { + //console.error('with index', index) + el = el.atIndex(index) + } + try { + await expect(el).toBeVisible() + } catch (err) { + // try looking for a text child + el = element(by.type(getNativeType('Text')).withAncestor(by.id(testID))) + if (index !== undefined) { + //console.error('with index with text child', index) + el = el.atIndex(index) + } + + try { + //console.error('expect with child') + await expect(el).toBeVisible() + } catch (err) { + const msg = err.message.toString() + if (msg.indexOf('matches multiple views in the hierarchy') == -1) { + // if it is any other error than matching multiple views, we raise it + //console.error('expect with child error', err) + throw err + } + + // if there are multiple matching Text views, we try to get the + // concatenated text + const MAX_TEXT_CHILDREN = 100 + const texts = [] + for (let i = 0; i < MAX_TEXT_CHILDREN; i++) { + try { + const text = await readVisibleText(testID, i) + texts.push(text) + } catch (err) { + // TODO how do we know there are no more children vs. some other + // error? + break + } + } + + return texts.join('') + } + } + + try { + await expect(el).toHaveText('_you_cant_possible_have_this_text_') + throw 'are you kidding me?' + } catch (error) { + if (device.getPlatform() === 'ios') { + const start = `accessibilityLabel was "` + const end = '" on ' + const errorMessage = error.message.toString() + const [, restMessage] = errorMessage.split(start) + const [label] = restMessage.split(end) + return label + } else { + const start = 'Got:'; + const end = '}"'; + const errorMessage = error.message.toString(); + const [, restMessage] = errorMessage.split(start); + const [label] = restMessage.split(end); + const value = label.split(','); + let combineText = value.find((i: string) => i.includes('text=')) + if (!combineText) { + throw new Error( + `readVisibleText failed! '${testID}' must be a testID of a element (or a parent of one)` + ) + } else { + combineText = combineText.trim() + } + const [, elementText] = combineText.split('=') + return elementText + } + } +} diff --git a/jest.config.js b/jest.config.js index a20da47a09..437d07f439 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,10 +7,9 @@ module.exports = { "enzyme-to-json/serializer" ], transformIgnorePatterns: [ - "node_modules/(?!react-native|native-base|@?react-navigation|react-native-fabric)" + "node_modules/(?!react-native|native-base|@?react-navigation|react-native-fabric|typeorm)" ], globals: { - window: true, "ts-jest": { babelConfig: true } diff --git a/package.json b/package.json index 81db4ede76..fc49de3c37 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "@babel/runtime": "^7.4.5", "@storybook/react-native": "^5.2.0-beta.30", "@svgr/cli": "^4.2.0", + "@types/detox": "^12.8.4", "@types/enzyme": "^3.10.1", "@types/enzyme-adapter-react-16": "^1.0.5", "@types/i18n-js": "^3.0.1", @@ -17,7 +18,7 @@ "@types/ramda": "^0.26.8", "@types/react": "^16.8.19", "@types/react-native": "^0.57.60", - "@types/react-native-material-ui": "^1.31.1", + "@types/react-native-material-ui": "^1.32.0", "@types/react-native-snap-carousel": "^3.6.0", "@types/react-native-sqlite-storage": "^3.3.0", "@types/react-native-vector-icons": "^4.6.0", @@ -34,6 +35,7 @@ "class-transformer": "^0.1.9", "commitizen": "^4.0.3", "cz-conventional-changelog": "3.0.2", + "detox": "jolocom/Detox.git#detox-v9000.0.1-gitpkg", "enzyme": "^3.3.0", "enzyme-adapter-react-16": "^1.1.1", "enzyme-to-json": "^3.3.3", @@ -41,6 +43,7 @@ "eslint-config-prettier": "^4.1.0", "eslint-plugin-prettier": "^3.0.1", "eslint-plugin-react": "^7.12.4", + "fs-extra": "^8.1.0", "husky": "^3.0.5", "jest": "^24.8.0", "material-colors": "^1.2.5", @@ -65,6 +68,9 @@ "start": "adb reverse tcp:8081 tcp:8081 & react-native start", "test": "jest", "test:debug": "node --inspect-brk node_modules/.bin/jest --runInBand", + "e2e:android": "detox build -c android.debug && detox test -c android.debug", + "e2e:android:emu": "detox build -c android.emu.debug && detox test -c android.emu.debug", + "e2e:ios:sim": "detox build -c ios.sim.debug && detox test -c ios.sim.debug", "run:ios": "react-native run-ios", "run:android": "react-native run-android --appIdSuffix debug", "run:android:staging": "react-native run-android --variant staging --appIdSuffix debugStaging", @@ -83,6 +89,42 @@ "typeorm:node": "ts-node -O '{\"module\": \"commonjs\"}' -r ./bin/typeormNode.ts", "typeorm:repl": "TYPEORM_REPL=1 ts-node -O '{\"module\": \"commonjs\"}' -r ./bin/typeormNode.ts" }, + "detox": { + "configurations": { + "ios.sim.debug": { + "binaryPath": "ios/build/Build/Products/Debug-iphonesimulator/JolocomWallet.app", + "build": "xcodebuild -project ios/JolocomWallet.xcodeproj -scheme JolocomWallet -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build", + "type": "ios.simulator", + "name": "iPhone 7" + }, + "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", + "name": "dynamic" + }, + "android.emu.release": { + "binaryPath": "android/app/build/outputs/apk/release/app-release.apk", + "build": "cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release && cd ..", + "type": "android.emulator", + "name": "dynamic" + }, + "android.debug": { + "binaryPath": "android/app/build/outputs/apk/debug/app-debug.apk", + "build": "cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug && cd ..", + "type": "android.attached", + "name": "dynamic" + }, + "android.release": { + "binaryPath": "android/app/build/outputs/apk/release/app-release.apk", + "build": "cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release && cd ..", + "type": "android.attached", + "name": "dynamic" + } + }, + "test-runner": "jest", + "runner-config": "e2e/jest.config.js" + }, "husky": { "hooks": { "pre-commit": "precise-commits; node bin/precommit.js", diff --git a/src/config.ts b/src/config.ts index f78f3cde34..bb5537c86a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,7 @@ import typeOrmConf from '../ormconfig' +import { ConnectionOptions } from 'typeorm/browser' export default { fuelingEndpoint: 'https://faucet.jolocom.com/request', - typeOrmConfig: typeOrmConf, + typeOrmConfig: typeOrmConf as ConnectionOptions, } diff --git a/src/ui/home/containers/claims.tsx b/src/ui/home/containers/claims.tsx index 07c82788dc..b33e6c063d 100644 --- a/src/ui/home/containers/claims.tsx +++ b/src/ui/home/containers/claims.tsx @@ -21,7 +21,7 @@ export class ClaimsContainer extends React.Component { public render(): JSX.Element { const { did, claimsState, openClaimDetails } = this.props return ( - + { return ( { />