diff --git a/.eslintrc b/.eslintrc index a7166a495..9d19514f1 100644 --- a/.eslintrc +++ b/.eslintrc @@ -2,7 +2,8 @@ env: { browser: true, es6: true, - node: true + node: true, + mocha: true }, "parserOptions": { "ecmaVersion": 6, diff --git a/.istanbul.yml b/.istanbul.yml new file mode 100644 index 000000000..d20933f11 --- /dev/null +++ b/.istanbul.yml @@ -0,0 +1,8 @@ +verbose: false +instrumentation: + root: 'src/js/' + extensions: + - .js + default-excludes: true + excludes: ['**/__tests__/**'] + include-all-sources: true diff --git a/package.json b/package.json index a05a7d84c..031fe8516 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "dist": "npm run release-js && npm run package && npm run codesign", "lint-js": "eslint 'src/js/' 'src/js/app.js' 'main.js'", "lint-sass": "sass-lint -c .sass-lint.yml -v -q", - "test": "npm run lint-js && npm run lint-sass", + "mocha": "./node_modules/mocha/bin/mocha --opts src/js/__tests__/__helpers__/mocha.opts", + "coverage": "babel-node ./node_modules/istanbul/lib/cli cover node_modules/mocha/bin/_mocha -- --opts src/js/__tests__/__helpers__/mocha.opts", + "test": "npm run lint-js && npm run lint-sass && npm run coverage", "start": "electron . --enable-logging" }, "repository": { @@ -68,12 +70,11 @@ "redux-storage": "=4.0.0", "redux-storage-decorator-filter": "=1.1.3", "redux-storage-engine-localstorage": "=1.1.0", - "reflux": "=0.4.1", "reloading": "0.0.6", - "superagent": "=1.8.3", "underscore": "=1.8.3" }, "devDependencies": { + "babel-cli": "=6.7.7", "babel-core": "=6.7.7", "babel-eslint": "=6.0.3", "babel-jest": "=11.0.2", @@ -81,16 +82,22 @@ "babel-preset-es2015": "=6.6.0", "babel-preset-react": "=6.5.0", "babel-preset-stage-0": "=6.5.0", + "chai": "=3.5.0", "css-loader": "=0.23.1", "electron-packager": "=7.0.1", "electron-prebuilt": "=0.37.7", + "enzyme": "=2.2.0", "eslint": "=2.8.0", "eslint-plugin-react": "=5.0.1", "file-loader": "=0.8.5", + "istanbul": "=1.0.0-alpha.2", + "jsdom": "=8.4.1", "json-loader": "=0.5.4", + "mocha": "=2.4.5", "node-sass": "=3.6.0", "sass-lint": "=1.7.0", "sass-loader": "=3.2.0", + "sinon": "=1.17.3", "style-loader": "=0.13.1", "webpack": "=1.13.0" } diff --git a/src/js/__tests__/__helpers__/mocha.opts b/src/js/__tests__/__helpers__/mocha.opts new file mode 100644 index 000000000..6ee861941 --- /dev/null +++ b/src/js/__tests__/__helpers__/mocha.opts @@ -0,0 +1,5 @@ +--compilers js:babel-core/register +--require src/js/__tests__/__helpers__/tests-dom.js +--bail +--recursive +src/js/__tests__/**/*.js diff --git a/src/js/__tests__/__helpers__/tests-dom.js b/src/js/__tests__/__helpers__/tests-dom.js new file mode 100644 index 000000000..fcb41cd63 --- /dev/null +++ b/src/js/__tests__/__helpers__/tests-dom.js @@ -0,0 +1,16 @@ +import jsdom from 'jsdom'; + +global.document = jsdom.jsdom(''); +global.window = document.defaultView; +global.navigator = {userAgent: 'node.js'}; + +// Mocks for Electron +window.require = function () { + return { + ipcRenderer: { + send: function () { + // Fake sending message to ipcMain + } + }, + }; +}; diff --git a/src/js/__tests__/basic.js b/src/js/__tests__/basic.js new file mode 100644 index 000000000..3c8ad3e27 --- /dev/null +++ b/src/js/__tests__/basic.js @@ -0,0 +1,9 @@ +import { expect } from 'chai'; + +describe('basic.js', function () { + + it('should make a basic test', function () { + expect(2).to.equal(2); + }); + +}); diff --git a/src/js/__tests__/components/all-read.js b/src/js/__tests__/components/all-read.js new file mode 100644 index 000000000..51b4a92e3 --- /dev/null +++ b/src/js/__tests__/components/all-read.js @@ -0,0 +1,33 @@ +import React from 'react'; // eslint-disable-line no-unused-vars +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import sinon from 'sinon'; +import AllRead from '../../components/all-read'; + +function setup() { + const props = {}; + const wrapper = mount(); + + return { + props: props, + wrapper: wrapper, + }; +}; + +describe('components/all-read.js', function () { + + it('should render itself & its children', function () { + + sinon.spy(AllRead.prototype, 'componentDidMount'); + + const { wrapper } = setup(); + + expect(wrapper).to.exist; + expect(AllRead.prototype.componentDidMount.calledOnce).to.be.true; + expect(wrapper.find('h4').text()).to.equal('No new notifications.'); + + AllRead.prototype.componentDidMount.restore(); + + }); + +}); diff --git a/src/js/__tests__/components/search.js b/src/js/__tests__/components/search.js new file mode 100644 index 000000000..ea09c4ed3 --- /dev/null +++ b/src/js/__tests__/components/search.js @@ -0,0 +1,95 @@ +import React from 'react'; // eslint-disable-line no-unused-vars +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import sinon from 'sinon'; +import { SearchBar } from '../../components/search'; + +function setup(props) { + const wrapper = mount(); + + return { + props: props, + wrapper: wrapper, + }; +}; + +describe('components/search.js', function () { + + it('should render itself & its children', function () { + + const props = { + searchNotifications: sinon.spy(), + clearSearch: sinon.spy(), + showSearch: true, + query: '' + }; + + const { wrapper } = setup(props); + + expect(wrapper).to.exist; + expect(wrapper.find('.octicon-x').length).to.equal(0); + + wrapper.find('input').props().onChange({target: {value: 'hello'}}); + expect(wrapper.props().searchNotifications.calledOnce).to.be.true; + + }); + + it('should hide the search bar if not showSearch', function () { + + const props = { + searchNotifications: sinon.spy(), + clearSearch: sinon.spy(), + showSearch: false, + query: '' + }; + + const { wrapper } = setup(props); + + expect(wrapper).to.exist; + + expect(wrapper.find('.container-fluid').length).to.equal(1); + expect(wrapper.find('.search-bar').length).to.equal(0); + + }); + + it('should show the clear icon/button', function () { + + const props = { + searchNotifications: sinon.spy(), + clearSearch: sinon.spy(), + showSearch: true, + query: 'hello' + }; + + const { wrapper } = setup(props); + + expect(wrapper).to.exist; + + wrapper.find('input').props().onChange({target: {value: 'hello'}}); + expect(wrapper.find('.octicon-x').length).to.equal(1); + + }); + + it('should test the componentWillReceiveProps function', function () { + + sinon.spy(SearchBar.prototype, 'componentWillReceiveProps'); + + const props = { + searchNotifications: sinon.spy(), + clearSearch: sinon.spy(), + showSearch: true, + query: 'hello' + }; + + const { wrapper } = setup(props); + + expect(wrapper).to.exist; + + wrapper.setProps({showSearch: false}); + + expect(wrapper.props().clearSearch.calledOnce).to.be.true; + expect(SearchBar.prototype.componentWillReceiveProps.calledOnce).to.be.true; + + }); + +}); diff --git a/src/js/__tests__/components/settings.js b/src/js/__tests__/components/settings.js new file mode 100644 index 000000000..b57f16701 --- /dev/null +++ b/src/js/__tests__/components/settings.js @@ -0,0 +1,62 @@ +import React from 'react'; // eslint-disable-line no-unused-vars +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import sinon from 'sinon'; +import Toggle from 'react-toggle'; +import { SettingsPage } from '../../components/settings'; + +function setup() { + const props = { + updateSetting: sinon.spy(), + settings: { + participating: false, + playSound: true, + showNotifications: true, + markOnClick: false, + openAtStartup: false + } + }; + + const wrapper = mount(); + + return { + props: props, + wrapper: wrapper, + }; +}; + +describe('components/settings.js', function () { + + it('should render itself & its children', function () { + + const { wrapper } = setup(); + + expect(wrapper).to.exist; + expect(wrapper.props().settings.participating).to.be.false; + expect(wrapper.find(Toggle).length).to.equal(5); + expect(wrapper.find('.footer').find('.text-right').text()).to.contain('Gitify - Version'); + + }); + + it('should update a setting', function () { + + const { wrapper } = setup(); + + expect(wrapper).to.exist; + wrapper.find(Toggle).first().props().onChange({target: {checked: true}}); + expect(wrapper.props().updateSetting.calledOnce).to.be.true; + + }); + + it('should check for updates and quit the app', function () { + + const { wrapper } = setup(); + + expect(wrapper).to.exist; + + wrapper.find('.btn-primary').simulate('click'); + wrapper.find('.btn-danger').simulate('click'); + + }); + +}); diff --git a/src/js/__tests__/reducers/auth.js b/src/js/__tests__/reducers/auth.js new file mode 100644 index 000000000..32299e67a --- /dev/null +++ b/src/js/__tests__/reducers/auth.js @@ -0,0 +1,143 @@ +import { expect } from 'chai'; +import reducer from '../../reducers/auth'; +import { + LOGIN_REQUEST, LOGIN_SUCCESS, LOGIN_FAILURE, LOGOUT, CHECK_AUTH +} from '../../actions'; + +describe('reducers/auth.js', () => { + const initialState = { + response: {}, + token: null, + isFetching: false, + failed: false + }; + + it('should return the initial state', () => { + + expect(reducer(undefined, {})).to.eql(initialState); + + }); + + it('should handle LOGIN_REQUEST', () => { + + const action = { + type: LOGIN_REQUEST + }; + + expect(reducer(undefined, action)).to.eql({ + ...initialState, + isFetching: true, + failed: false, + response: {}, + token: null + }); + + }); + + it('should handle LOGIN_SUCCESS', () => { + + const fakeState = { + isFetching: true, + failed: false, + response: {}, + token: null + }; + + expect(reducer(fakeState, {}).token).to.be.null; + + const action = { + type: LOGIN_SUCCESS, + payload: { + access_token: '123HELLOWORLDTOKEN' + } + }; + + expect(reducer(fakeState, action)).to.eql({ + ...initialState, + isFetching: false, + token: action.payload.access_token + }); + + expect(reducer(fakeState, action).token).to.not.be.null; + + }); + + it('should handle LOGIN_FAILURE', () => { + + const fakeState = { + isFetching: true, + failed: false, + response: {}, + token: null + }; + + expect(reducer(fakeState, {}).token).to.be.null; + + const action = { + type: LOGIN_FAILURE, + payload: 'Failed to login.' + }; + + expect(reducer(fakeState, action)).to.eql({ + ...initialState, + isFetching: false, + failed: true, + response: action.payload + }); + + expect(reducer(fakeState, action).token).to.be.null; + + }); + + it('should handle LOGOUT', () => { + + const fakeState = { + isFetching: false, + failed: false, + response: {}, + token: 'LOGGEDINTOKEN' + }; + + expect(reducer(fakeState, {}).token).to.not.be.null; + + const action = { + type: LOGOUT + }; + + expect(reducer(fakeState, action)).to.eql({ + ...initialState, + token: null, + response: null + }); + + expect(reducer(fakeState, action).token).to.be.null; + + }); + + it('should handle CHECK_AUTH', () => { + + const fakeState = { + isFetching: false, + failed: false, + response: {}, + token: null + }; + + const fakeToken = '123HELLOWORLDTOKEN'; + + expect(reducer(fakeState, {}).token).to.be.null; + + const action = { + type: CHECK_AUTH, + token: fakeToken + }; + + expect(reducer(fakeState, action)).to.eql({ + ...initialState, + token: fakeToken + }); + + expect(reducer(fakeState, action).token).to.not.be.null; + + }); +}); diff --git a/src/js/__tests__/reducers/notifications.js b/src/js/__tests__/reducers/notifications.js new file mode 100644 index 000000000..089cc7549 --- /dev/null +++ b/src/js/__tests__/reducers/notifications.js @@ -0,0 +1,144 @@ +import { expect } from 'chai'; +import reducer from '../../reducers/notifications'; +import { + NOTIFICATIONS_REQUEST, NOTIFICATIONS_SUCCESS, NOTIFICATIONS_FAILURE, + MARK_NOTIFICATION_SUCCESS, MARK_REPO_NOTIFICATION_SUCCESS +} from '../../actions'; + +describe('reducers/notifications.js', () => { + const initialState = { + response: [], + isFetching: false, + failed: false + }; + + const notifications = [ + { + id: 1, + repository: { + full_name: 'ekonstantinidis/gitify' + }, + text: 'New Release' + }, + { + id: 2, + repository: { + full_name: 'ekonstantinidis/gitify' + }, + text: 'It\'s Great' + } + ]; + + it('should return the initial state', () => { + + expect(reducer(undefined, {})).to.eql(initialState); + + }); + + it('should handle NOTIFICATIONS_REQUEST', () => { + + const action = { + type: NOTIFICATIONS_REQUEST, + query: 'hello' + }; + + expect(reducer(undefined, action)).to.eql({ + ...initialState, + isFetching: true, + failed: false + }); + + }); + + it('should handle NOTIFICATIONS_SUCCESS', () => { + + expect(reducer(undefined, {})).to.eql(initialState); + + const action = { + type: NOTIFICATIONS_SUCCESS, + payload: notifications + }; + + const currentState = { + ...initialState, + isFetching: true, + failed: false + }; + + expect(reducer(currentState, action)).to.eql({ + ...initialState, + isFetching: false, + response: notifications + }); + + }); + + it('should handle NOTIFICATIONS_FAILURE', () => { + + const response = { + error: 404, + message: 'Oops! Something went wrong.' + }; + + const currentState = { + ...initialState, + isFetching: true, + failed: false + }; + + expect(reducer(currentState, {})).to.eql(currentState); + + const action = { + type: NOTIFICATIONS_FAILURE, + payload: response + }; + + expect(reducer(currentState, action)).to.eql({ + ...initialState, + isFetching: false, + failed: true, + response: response + }); + + }); + + it('should handle MARK_NOTIFICATION_SUCCESS', () => { + + const currentState = { + ...initialState, + response: notifications + }; + + expect(reducer(currentState, {}).response.length).to.equal(2); + + const action = { + type: MARK_NOTIFICATION_SUCCESS, + meta: { + id: 1 + } + }; + + expect(reducer(currentState, action).response.length).to.equal(1); + + }); + + it('should handle MARK_REPO_NOTIFICATION_SUCCESS', () => { + + const currentState = { + ...initialState, + response: notifications + }; + + expect(reducer(currentState, {}).response.length).to.equal(2); + + const action = { + type: MARK_REPO_NOTIFICATION_SUCCESS, + meta: { + repoFullName: 'ekonstantinidis/gitify' + } + }; + + expect(reducer(currentState, action).response.length).to.equal(0); + + }); +}); diff --git a/src/js/__tests__/reducers/searchFilter.js b/src/js/__tests__/reducers/searchFilter.js new file mode 100644 index 000000000..8bf72a381 --- /dev/null +++ b/src/js/__tests__/reducers/searchFilter.js @@ -0,0 +1,48 @@ +import { expect } from 'chai'; +import reducer from '../../reducers/searchFilter'; +import { SEARCH_NOTIFICATIONS, CLEAR_SEARCH } from '../../actions'; + +describe('reducers/searchFilter.js', () => { + const initialState = { + query: '' + }; + + it('should return the initial state', () => { + + expect(reducer(undefined, {})).to.eql(initialState); + + }); + + it('should handle SEARCH_NOTIFICATIONS', () => { + + const action = { + type: SEARCH_NOTIFICATIONS, + query: 'hello' + }; + + expect(reducer(undefined, action)).to.eql({ + ...initialState, + query: 'hello' + }); + + }); + + it('should handle CLEAR_SEARCH', () => { + + const fakeState = { + query: 'hello' + }; + + expect(reducer(fakeState, {})).to.eql(fakeState); + + const action = { + type: CLEAR_SEARCH + }; + + expect(reducer(fakeState, action)).to.eql({ + ...initialState, + query: '' + }); + + }); +}); diff --git a/src/js/__tests__/reducers/settings.js b/src/js/__tests__/reducers/settings.js new file mode 100644 index 000000000..4d143554f --- /dev/null +++ b/src/js/__tests__/reducers/settings.js @@ -0,0 +1,45 @@ +import { expect } from 'chai'; +import reducer from '../../reducers/settings'; +import { UPDATE_SETTING } from '../../actions'; + +describe('reducers/settings.js', () => { + const initialState = { + participating: false, + playSound: true, + showNotifications: true, + markOnClick: false, + openAtStartup: false + }; + + it('should return the initial state', () => { + + expect(reducer(undefined, {})).to.eql(initialState); + + }); + + it('should handle UPDATE_SETTING', () => { + + const actionParticipating = { + type: UPDATE_SETTING, + setting: 'participating', + value: true + }; + + expect(reducer(undefined, actionParticipating)).to.eql({ + ...initialState, + participating: true + }); + + const actionOpenAtStartUp = { + type: UPDATE_SETTING, + setting: 'openAtStartup', + value: true + }; + + expect(reducer(undefined, actionOpenAtStartUp)).to.eql({ + ...initialState, + openAtStartup: true + }); + + }); +}); diff --git a/src/js/components/search.js b/src/js/components/search.js index 45974983c..5076e9ede 100644 --- a/src/js/components/search.js +++ b/src/js/components/search.js @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import { searchNotifications, clearSearch } from '../actions'; -class SearchBar extends React.Component { +export class SearchBar extends React.Component { componentWillReceiveProps(nextProps) { if (nextProps.showSearch === false) { diff --git a/src/js/components/settings.js b/src/js/components/settings.js index 63d378d50..c8d1da92d 100644 --- a/src/js/components/settings.js +++ b/src/js/components/settings.js @@ -6,7 +6,7 @@ const ipcRenderer = window.require('electron').ipcRenderer; import { updateSetting } from '../actions'; -class SettingsPage extends React.Component { +export class SettingsPage extends React.Component { toggleSetting(key, event) { this.props.updateSetting(key, event.target.checked); } diff --git a/src/js/utils/constants.js b/src/js/utils/constants.js index 80f613ac3..e0462ed7f 100644 --- a/src/js/utils/constants.js +++ b/src/js/utils/constants.js @@ -1,4 +1,4 @@ -export default { +let constants = { // GitHub OAuth CLIENT_ID: '3fef4433a29c6ad8f22c', CLIENT_SECRET: '9670de733096c15322183ff17ed0fc8704050379', @@ -16,3 +16,5 @@ export default { 'Yay! Good news.', ] }; + +export default constants;