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;