summaryrefslogtreecommitdiffhomepage
path: root/test
diff options
context:
space:
mode:
authorLinus Färnstrand <linus@mullvad.net>2017-12-20 11:32:55 +0100
committerLinus Färnstrand <linus@mullvad.net>2017-12-20 11:34:21 +0100
commit2aae380b0af018bf0187bb31fb0fedf6a457ebf1 (patch)
treea8ad6ee12956d92e6257bea07dedc44063f3017f /test
parent7b47ddf735af7f3d6065fb6c3ffea6e9ddfd86cb (diff)
parent8b146934260739ae609791a1fb676d48ceb954c0 (diff)
downloadmullvadvpn-2aae380b0af018bf0187bb31fb0fedf6a457ebf1.tar.xz
mullvadvpn-2aae380b0af018bf0187bb31fb0fedf6a457ebf1.zip
Merge backend and frontend repo master branches
Conflicts: .gitignore .travis.yml README.md
Diffstat (limited to 'test')
-rw-r--r--test/auth.spec.js56
-rw-r--r--test/autologin.spec.js124
-rw-r--r--test/components/Accordion.spec.js96
-rw-r--r--test/components/Account.spec.js78
-rw-r--r--test/components/AccountInput.spec.js178
-rw-r--r--test/components/Connect.spec.js178
-rw-r--r--test/components/HeaderBar.spec.js71
-rw-r--r--test/components/Login.spec.js120
-rw-r--r--test/components/SelectLocation.spec.js92
-rw-r--r--test/components/Settings.spec.js164
-rw-r--r--test/components/Support.spec.js127
-rw-r--r--test/components/Switch.spec.js126
-rw-r--r--test/connect.spec.js67
-rw-r--r--test/connection-info.spec.js46
-rw-r--r--test/helpers/IpcChain.js104
-rw-r--r--test/helpers/dom-events.js22
-rw-r--r--test/helpers/ipc-helpers.js105
-rw-r--r--test/ipc.spec.js138
-rw-r--r--test/keyframe-animation.spec.js250
-rw-r--r--test/login.spec.js85
-rw-r--r--test/logout.spec.js66
-rw-r--r--test/mocks/ipc.js98
-rw-r--r--test/mocks/redux.js4
-rw-r--r--test/relay-settings-builder.spec.js151
-rw-r--r--test/setup/enzyme.js4
-rw-r--r--test/setup/main.js4
-rw-r--r--test/transition-rule.spec.js58
27 files changed, 2612 insertions, 0 deletions
diff --git a/test/auth.spec.js b/test/auth.spec.js
new file mode 100644
index 0000000000..c3fd78c83d
--- /dev/null
+++ b/test/auth.spec.js
@@ -0,0 +1,56 @@
+// @flow
+
+import { expect } from 'chai';
+import { setupIpcAndStore, setupBackendAndStore, failFast, checkNextTick } from './helpers/ipc-helpers';
+import { IpcChain } from './helpers/IpcChain';
+import { Backend } from '../app/lib/backend';
+
+describe('authentication', () => {
+
+ it('authenticates before ipc call if unauthenticated', (done) => {
+ const { store, mockIpc } = setupIpcAndStore();
+ const credentials = {
+ sharedSecret: 'foo',
+ connectionString: '',
+ };
+
+
+ const chain = new IpcChain(mockIpc);
+ chain.require('authenticate')
+ .withInputValidation( secret => {
+ expect(secret).to.equal(credentials.sharedSecret);
+ })
+ .done();
+
+ chain.require('connect')
+ .done();
+
+ chain.onSuccessOrFailure(done);
+
+
+ const backend = new Backend(store, credentials, mockIpc);
+ backend.connect();
+ });
+
+ it('reauthenticates on reconnect', (done) => {
+ const { mockIpc, backend } = setupBackendAndStore();
+
+ let authCount = 0;
+ mockIpc.authenticate = () => {
+ authCount++;
+ return Promise.resolve();
+ };
+
+
+ mockIpc.killWebSocket();
+ failFast(() => {
+ expect(authCount).to.equal(0);
+ }, done);
+
+
+ backend.connect();
+ checkNextTick(() => {
+ expect(authCount).to.equal(1);
+ }, done);
+ });
+});
diff --git a/test/autologin.spec.js b/test/autologin.spec.js
new file mode 100644
index 0000000000..5821166eff
--- /dev/null
+++ b/test/autologin.spec.js
@@ -0,0 +1,124 @@
+// @flow
+
+import { expect } from 'chai';
+import { setupBackendAndStore, setupBackendAndMockStore, getLocation } from './helpers/ipc-helpers';
+import { IpcChain } from './helpers/IpcChain';
+
+describe('autologin', () => {
+
+ it('should send get_account then get_account_data if an account is set', (done) => {
+ const { mockIpc, backend } = setupBackendAndStore();
+
+ const randomAccountToken = '12345';
+
+ const chain = new IpcChain(mockIpc);
+ chain.require('getAccount')
+ .withReturnValue(randomAccountToken)
+ .done();
+
+ chain.require('getAccountData')
+ .withInputValidation((num) => {
+ expect(num).to.equal(randomAccountToken);
+ })
+ .done();
+
+ chain.onSuccessOrFailure(done);
+
+ backend.autologin();
+ });
+
+ it('should redirect to the login page if no account is set', () => {
+ const { store, backend, mockIpc } = setupBackendAndMockStore();
+
+ mockIpc.getAccount = () => new Promise((_, reject) => reject('NO_ACCOUNT'));
+
+ return backend.autologin()
+ .then( () => {
+ expect(getLocation(store)).to.equal('/');
+ })
+ .catch( (e) => {
+ if (e !== 'NO_ACCOUNT') {
+ throw e;
+ }
+ });
+ });
+
+ it('should redirect to the login page for non-existing accounts', () => {
+ const { store, backend, mockIpc } = setupBackendAndMockStore();
+
+ mockIpc.getAccount = () => new Promise(r => r('123'));
+ mockIpc.getAccountData = () => new Promise((_, reject) => reject('NO_ACCOUNT'));
+
+ return backend.autologin()
+ .then( () => {
+ expect(getLocation(store)).to.equal('/');
+ })
+ .catch( (e) => {
+ if (e !== 'NO_ACCOUNT') {
+ throw e;
+ }
+ });
+ });
+
+ it('should mark the state as not logged in if no account is set', () => {
+ const { store, backend, mockIpc } = setupBackendAndStore();
+
+ mockIpc.getAccount = () => new Promise(r => r(null));
+
+ return backend.autologin()
+ .catch( () => {}) // ignore errors
+ .then( () => {
+ const state = store.getState().account;
+
+ expect(state.status).to.equal('none');
+ expect(state.accountToken).to.be.null;
+ expect(state.error).to.be.null;
+ });
+ });
+
+ it('should mark the state as not logged in for non-existing accounts', () => {
+ const { store, backend, mockIpc } = setupBackendAndStore();
+
+ mockIpc.getAccount = () => new Promise(r => r('123'));
+ mockIpc.getAccountData = () => new Promise((_, reject) => reject('NO ACCOUNT'));
+
+ return backend.autologin()
+ .catch( () => {}) // ignore errors
+ .then( () => {
+ const state = store.getState().account;
+
+ expect(state.status).to.equal('none');
+ expect(state.error).to.be.null;
+ });
+ });
+
+ it('should put the account data in the state for existing accounts', () => {
+ const { store, backend, mockIpc } = setupBackendAndStore();
+ mockIpc.getAccount = () => new Promise(r => r('123'));
+ mockIpc.getAccountData = () => new Promise(r => r({
+ expiry: '2001-01-01T00:00:00Z',
+ }));
+
+ return backend.autologin()
+ .then( () => {
+ const state = store.getState().account;
+ expect(state.status).to.equal('ok');
+ expect(state.accountToken).to.equal('123');
+ expect(state.expiry).to.equal('2001-01-01T00:00:00Z');
+ });
+ });
+
+ it('should redirect to /connect for existing accounts', () => {
+ const { store, backend, mockIpc } = setupBackendAndMockStore();
+
+ mockIpc.getAccount = () => new Promise(r => r('123'));
+ mockIpc.getAccountData = () => new Promise(r => r({
+ expiry: '2001-01-01T00:00:00Z',
+ }));
+
+ return backend.autologin()
+ .then( () => {
+ expect(getLocation(store)).to.equal('/connect');
+ });
+ });
+});
diff --git a/test/components/Accordion.spec.js b/test/components/Accordion.spec.js
new file mode 100644
index 0000000000..2357e27dfc
--- /dev/null
+++ b/test/components/Accordion.spec.js
@@ -0,0 +1,96 @@
+// @flow
+/* eslint react/no-find-dom-node: off */
+
+import { expect } from 'chai';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import Accordion from '../../app/components/Accordion';
+
+import type { AccordionProps } from '../../app/components/Accordion';
+
+describe('components/Accordion', () => {
+
+ let container: ?HTMLElement;
+
+ function renderIntoDocument(instance: React.Element<AccordionProps>) {
+ if(!container) {
+ container = document.createElement('div');
+ if(!document.documentElement) {
+ throw new Error('document.documentElement cannot be null.');
+ }
+ document.documentElement.appendChild(container);
+ }
+ return ReactDOM.render(instance, container);
+ }
+
+ // unmount container and clean up DOM
+ afterEach(() => {
+ if(container) {
+ ReactDOM.unmountComponentAtNode(container);
+ container = null;
+ }
+ });
+
+ it('should be collapsed upon mount', () => {
+ const component = renderIntoDocument(
+ <Accordion height={ 0 }>
+ <div style={{ height: 100 }}></div>
+ </Accordion>
+ );
+ const domNode = ReactDOM.findDOMNode(component);
+ expect(domNode).to.have.property('clientHeight', 0);
+ });
+
+ it('should be expanded to provided height upon mount', () => {
+ const component = renderIntoDocument(
+ <Accordion height={ 100 } />
+ );
+ const domNode = ReactDOM.findDOMNode(component);
+ expect(domNode).to.have.property('clientHeight', 100);
+ });
+
+ it('should be expanded using layout upon mount', () => {
+ const component = renderIntoDocument(
+ <Accordion height={ 'auto' }>
+ <div style={{ height: 100 }}></div>
+ </Accordion>
+ );
+ const domNode = ReactDOM.findDOMNode(component);
+ expect(domNode).to.have.property('clientHeight', 100);
+ });
+
+ it('should collapse', () => {
+ const component = renderIntoDocument(
+ <Accordion height={ 'auto' }>
+ <div style={{ height: 100 }}></div>
+ </Accordion>
+ );
+
+ renderIntoDocument(
+ <Accordion height={ 0 } transitionStyle="none">
+ <div style={{ height: 100 }}></div>
+ </Accordion>
+ );
+
+ const domNode = ReactDOM.findDOMNode(component);
+ expect(domNode).to.have.property('clientHeight', 0);
+ });
+
+ it('should expand', () => {
+ const component = renderIntoDocument(
+ <Accordion height={ 0 }>
+ <div style={{ height: 100 }}></div>
+ </Accordion>
+ );
+
+ renderIntoDocument(
+ <Accordion height="auto" transitionStyle="none">
+ <div style={{ height: 100 }}></div>
+ </Accordion>
+ );
+
+ const domNode = ReactDOM.findDOMNode(component);
+ expect(domNode).to.have.property('clientHeight', 100);
+ });
+
+}); \ No newline at end of file
diff --git a/test/components/Account.spec.js b/test/components/Account.spec.js
new file mode 100644
index 0000000000..a8b8e3d501
--- /dev/null
+++ b/test/components/Account.spec.js
@@ -0,0 +1,78 @@
+// @flow
+
+import { expect } from 'chai';
+import React from 'react';
+import ReactTestUtils, { Simulate } from 'react-dom/test-utils';
+import Account from '../../app/components/Account';
+
+import type { AccountReduxState } from '../../app/redux/account/reducers';
+import type { AccountProps } from '../../app/components/Account';
+
+describe('components/Account', () => {
+ const state: AccountReduxState = {
+ accountToken: '1234',
+ accountHistory: [],
+ expiry: (new Date('2038-01-01')).toISOString(),
+ status: 'none',
+ error: null
+ };
+
+ const makeProps = (state: AccountReduxState, mergeProps: $Shape<AccountProps>): AccountProps => {
+ const defaultProps: AccountProps = {
+ account: state,
+ onClose: () => {},
+ onLogout: () => {},
+ onBuyMore: () => {}
+ };
+ return Object.assign({}, defaultProps, mergeProps);
+ };
+
+ const render = (props: AccountProps): Account => {
+ return ReactTestUtils.renderIntoDocument(
+ <Account { ...props } />
+ );
+ };
+
+ it('should call close callback', (done) => {
+ const props = makeProps(state, {
+ onClose: () => done()
+ });
+ const domNode = ReactTestUtils.findRenderedDOMComponentWithClass(render(props), 'account__close');
+ Simulate.click(domNode);
+ });
+
+ it('should call logout callback', (done) => {
+ const props = makeProps(state, {
+ onLogout: () => done()
+ });
+ const domNode = ReactTestUtils.findRenderedDOMComponentWithClass(render(props), 'account__logout');
+ Simulate.click(domNode);
+ });
+
+ it('should call "buy more" callback', (done) => {
+ const props = makeProps(state, {
+ onBuyMore: () => done()
+ });
+ const domNode = ReactTestUtils.findRenderedDOMComponentWithClass(render(props), 'account__buymore');
+ Simulate.click(domNode);
+ });
+
+ it('should display "out of time" message when account expired', () => {
+ const expiredState: AccountReduxState = {
+ accountToken: '1234',
+ accountHistory: [],
+ expiry: (new Date('2001-01-01')).toISOString(),
+ status: 'none',
+ error: null
+ };
+ const props = makeProps(expiredState, {});
+ ReactTestUtils.findRenderedDOMComponentWithClass(render(props), 'account__out-of-time');
+ });
+
+ it('should not display "out of time" message when account is active', () => {
+ const props = makeProps(state, {});
+ const domNodes = ReactTestUtils.scryRenderedDOMComponentsWithClass(render(props), 'account__out-of-time');
+ expect(domNodes.length).to.be.equal(0);
+ });
+
+});
diff --git a/test/components/AccountInput.spec.js b/test/components/AccountInput.spec.js
new file mode 100644
index 0000000000..ee73323950
--- /dev/null
+++ b/test/components/AccountInput.spec.js
@@ -0,0 +1,178 @@
+// @flow
+import { expect } from 'chai';
+import { createKeyEvent } from '../helpers/dom-events';
+import React from 'react';
+import ReactTestUtils, { Simulate } from 'react-dom/test-utils';
+import AccountInput from '../../app/components/AccountInput';
+
+import type { AccountInputProps } from '../../app/components/AccountInput';
+
+describe('components/AccountInput', () => {
+ const getInputRef = (component: AccountInput): HTMLInputElement => {
+ return ReactTestUtils.findRenderedDOMComponentWithTag(component, 'input');
+ };
+
+ const render = (mergeProps: $Shape<AccountInputProps>) => {
+ const defaultProps: AccountInputProps = {
+ value: '',
+ onEnter: null,
+ onChange: null
+ };
+ const props = Object.assign({}, defaultProps, mergeProps);
+ return ReactTestUtils.renderIntoDocument(
+ <AccountInput { ...props } />
+ );
+ };
+
+ it('should call onEnter', (done) => {
+ const component = render({
+ onEnter: () => done()
+ });
+ Simulate.keyUp(getInputRef(component), createKeyEvent('Enter'));
+ });
+
+ it('should call onChange', (done) => {
+ const component = render({
+ onChange: (val) => {
+ expect(val).to.be.equal('1');
+ done();
+ }
+ });
+ Simulate.keyDown(getInputRef(component), createKeyEvent('1'));
+ });
+
+ it('should format input properly', () => {
+ const cases = [
+ '1111111111111',
+ '1111 1111 1111',
+ '1111 1111 111',
+ '1111 1111 11',
+ '1111 1111 1',
+ '1111 1111',
+ '1111 111',
+ '1111 11',
+ '1111 1',
+ '1111',
+ '111',
+ '11',
+ '1',
+ ''
+ ];
+
+ for(const value of cases) {
+ const component = render({ value });
+ expect(getInputRef(component).value).to.be.equal(value);
+ }
+ });
+
+ it('should remove last character', (done) => {
+ const component = render({
+ value: '1234',
+ onChange: (val) => {
+ expect(val).to.be.equal('123');
+ done();
+ }
+ });
+ Simulate.keyDown(getInputRef(component), createKeyEvent('Backspace'));
+ });
+
+ it('should remove first character', (done) => {
+ const component = render({
+ value: '1234',
+ onChange: (val) => {
+ expect(val).to.be.equal('234');
+ done();
+ }
+ });
+ component.setState({ selectionRange: [1, 1] }, () => {
+ Simulate.keyDown(getInputRef(component), createKeyEvent('Backspace'));
+ });
+ });
+
+ it('should remove all characters', (done) => {
+ const component = render({
+ value: '12345678',
+ onChange: (val) => {
+ expect(val).to.be.empty;
+ done();
+ }
+ });
+ component.setState({ selectionRange: [0, 8] }, () => {
+ Simulate.keyDown(getInputRef(component), createKeyEvent('Backspace'));
+ });
+ });
+
+ it('should remove selection', (done) => {
+ const component = render({
+ value: '1234 5678 9999',
+ onChange: (val) => {
+ expect(val).to.be.equal('12349999');
+ done();
+ }
+ });
+ component.setState({ selectionRange: [4, 8] }, () => {
+ Simulate.keyDown(getInputRef(component), createKeyEvent('Backspace'));
+ });
+ });
+
+ it('should replace selection', (done) => {
+ const component = render({
+ value: '0000'
+ });
+
+ component.setState({ selectionRange: [1, 3] }, () => {
+ Simulate.keyDown(getInputRef(component), createKeyEvent('1'));
+
+ component.setState({}, () => {
+ expect(component.state.value).to.be.equal('010');
+ expect(component.state.selectionRange).to.deep.equal([2, 2]);
+ done();
+ });
+ });
+ });
+
+ it('should keep selection in the back', (done) => {
+ const component = render({ value: '' });
+
+ for(let i = 0; i < 12; i++) {
+ Simulate.keyDown(getInputRef(component), createKeyEvent('1'));
+ }
+
+ component.setState({}, () => {
+ expect(component.state.value).to.be.equal('111111111111');
+ expect(component.state.selectionRange).to.deep.equal([12, 12]);
+ done();
+ });
+ });
+
+ it('should advance selection on insertion', (done) => {
+ const component = render({
+ value: '0000'
+ });
+ component.setState({ selectionRange: [1, 1]}, () => {
+ Simulate.keyDown(getInputRef(component), createKeyEvent('1'));
+
+ component.setState({}, () => {
+ expect(component.state.value).to.be.equal('01000');
+ expect(component.state.selectionRange).to.deep.equal([2, 2]);
+ done();
+ });
+ });
+ });
+
+ it('should not do anything when nothing to remove', (done) => {
+ const component = render({
+ value: '0000'
+ });
+ component.setState({ selectionRange: [0, 0] }, () => {
+ Simulate.keyDown(getInputRef(component), createKeyEvent('Backspace'));
+
+ component.setState({}, () => {
+ expect(component.state.value).to.be.equal('0000');
+ expect(component.state.selectionRange).to.deep.equal([0, 0]);
+ done();
+ });
+ });
+ });
+
+}); \ No newline at end of file
diff --git a/test/components/Connect.spec.js b/test/components/Connect.spec.js
new file mode 100644
index 0000000000..4fcc3c6f60
--- /dev/null
+++ b/test/components/Connect.spec.js
@@ -0,0 +1,178 @@
+// @flow
+
+import { expect } from 'chai';
+import React from 'react';
+import { mount } from 'enzyme';
+
+import Connect from '../../app/components/Connect';
+import Header from '../../app/components/HeaderBar';
+
+import type { ConnectProps } from '../../app/components/Connect';
+
+describe('components/Connect', () => {
+
+ it('shows unsecured hints when disconnected', () => {
+ const component = renderWithProps({
+ connection: {
+ ...defaultProps.connection,
+ status: 'disconnected',
+ }
+ });
+
+ const header = component.find(Header);
+ const securityMessage = component.find('.connect__status-security--unsecured');
+ const connectButton = component.find('.button .button--positive');
+
+ expect(header.prop('style')).to.equal('error');
+ expect(securityMessage.text().toLowerCase()).to.contain('unsecured');
+ expect(connectButton.text()).to.equal('Secure my connection');
+ });
+
+ it('shows secured hints when connected', () => {
+ const component = renderWithProps({
+ connection: {
+ ...defaultProps.connection,
+ status: 'connected',
+ }
+ });
+
+ const header = component.find(Header);
+ const securityMessage = component.find('.connect__status-security--secure');
+ const disconnectButton = component.find('.button .button--negative-light');
+
+ expect(header.prop('style')).to.equal('success');
+ expect(securityMessage.text().toLowerCase()).to.contain('secure');
+ expect(disconnectButton.text()).to.equal('Disconnect');
+ });
+
+ it('shows the connection location when connecting', () => {
+ const component = renderWithProps({
+ connection: {
+ ...defaultProps.connection,
+ status: 'connecting',
+ country: 'Norway',
+ city: 'Oslo',
+ }
+ });
+ const countryAndCity = component.find('.connect__status-location');
+ const ipAddr = component.find('.connect__status-ipaddress');
+
+ expect(countryAndCity.text()).to.contain('Norway');
+ expect(countryAndCity.text()).not.to.contain('Oslo');
+ expect(ipAddr.text()).to.be.empty;
+ });
+
+ it('shows the connection location when connected', () => {
+ const component = renderWithProps({
+ connection: {
+ ...defaultProps.connection,
+ status: 'connected',
+ country: 'Norway',
+ city: 'Oslo',
+ clientIp: '4.3.2.1',
+ }
+ });
+ const countryAndCity = component.find('.connect__status-location');
+ const ipAddr = component.find('.connect__status-ipaddress');
+
+ expect(countryAndCity.text()).to.contain('Norway');
+ expect(countryAndCity.text()).to.contain('Oslo');
+ expect(ipAddr.text()).to.contain('4.3.2.1');
+ });
+
+ it('shows the connection location when disconnected', () => {
+ const component = renderWithProps({
+ connection: {
+ ...defaultProps.connection,
+ status: 'disconnected',
+ country: 'Norway',
+ city: 'Oslo',
+ clientIp: '4.3.2.1',
+ }
+ });
+ const countryAndCity = component.find('.connect__status-location');
+ const ipAddr = component.find('.connect__status-ipaddress');
+
+ expect(countryAndCity.text()).to.contain('\u2002');
+ expect(countryAndCity.text()).to.not.contain('Oslo');
+ expect(ipAddr.text()).to.contain('\u2003');
+ });
+
+ it('shows the country name in the location switcher', () => {
+ const component = renderWithProps({
+ connection: {
+ ...defaultProps.connection,
+ status: 'disconnected',
+ },
+ settings: {
+ ...defaultProps.settings,
+ relaySettings: {
+ normal: {
+ location: { city: ['se', 'mma'] },
+ protocol: 'any',
+ port: 'any',
+ }
+ },
+ },
+ });
+
+ const locationSwitcher = component.find('.connect__server');
+ expect(locationSwitcher.text()).to.contain('Malmö');
+ });
+
+ it('invokes the onConnect prop', (done) => {
+ const component = renderWithProps({
+ onConnect: () => done(),
+ connection: {
+ ...defaultProps.connection,
+ status: 'disconnected',
+ }
+ });
+ const connectButton = component.find('.button .button--positive');
+
+ connectButton.simulate('click');
+ });
+});
+
+const defaultProps: ConnectProps = {
+ onSettings: () => {},
+ onSelectLocation: () => {},
+ onConnect: () => {},
+ onCopyIP: () => {},
+ onDisconnect: () => {},
+ onExternalLink: () => {},
+ accountExpiry: '',
+ settings: {
+ relaySettings: {
+ normal: {
+ location: 'any',
+ protocol: 'any',
+ port: 'any',
+ }
+ },
+ relayLocations: [{
+ name: 'Sweden',
+ code: 'se',
+ hasActiveRelays: true,
+ cities: [{
+ name: 'Malmö',
+ code: 'mma',
+ hasActiveRelays: true,
+ position: [0, 0],
+ }]
+ }],
+ },
+ connection: {
+ status: 'disconnected',
+ isOnline: true,
+ clientIp: null,
+ location: null,
+ country: null,
+ city: null,
+ },
+};
+
+function renderWithProps(customProps: $Shape<ConnectProps>) {
+ const props = { ...defaultProps, ...customProps };
+ return mount( <Connect { ...props } /> );
+}
diff --git a/test/components/HeaderBar.spec.js b/test/components/HeaderBar.spec.js
new file mode 100644
index 0000000000..e4ebb09429
--- /dev/null
+++ b/test/components/HeaderBar.spec.js
@@ -0,0 +1,71 @@
+// @flow
+
+import { expect } from 'chai';
+import React from 'react';
+import { shallow } from 'enzyme';
+import HeaderBar from '../../app/components/HeaderBar';
+
+require('../setup/enzyme');
+
+describe('components/HeaderBar', () => {
+
+ it('should display headerbar', () => {
+ const component = render({
+ hidden: false,
+ });
+ const hasChildMatching = hasChild(component, 'headerbar__container');
+ expect(hasChildMatching).to.be.true;
+ });
+
+ it('should not display headerbar', () => {
+ const component = render({
+ hidden: true,
+ });
+ const hasChildMatching = hasChild(component, 'headerbar__container');
+ expect(hasChildMatching).to.be.false;
+ });
+
+ it('should display settings button', () => {
+ const component = render({
+ showSettings: true,
+ });
+ const hasChildMatching = hasChild(component, 'headerbar__settings');
+ expect(hasChildMatching).to.be.true;
+ });
+
+ it('should not display settings button', () => {
+ const component = render({
+ showSettings: false,
+ });
+ const hasChildMatching = hasChild(component, 'headerbar__settings');
+ expect(hasChildMatching).to.be.false;
+ });
+
+ it('should call settings callback', (done) => {
+ const component = render({
+ showSettings: true,
+ onSettings: () => done(),
+ });
+ const settingsButton = getComponent(component, 'headerbar__settings');
+ click(settingsButton);
+ });
+
+});
+
+function render(props) {
+ return shallow(
+ <HeaderBar {...props} />
+ );
+}
+
+function getComponent(container, testName) {
+ return container.findWhere( n => n.prop('testName') === testName);
+}
+
+function hasChild(container, testName) {
+ return getComponent(container, testName).length > 0;
+}
+
+function click(component) {
+ component.prop('onPress')();
+}
diff --git a/test/components/Login.spec.js b/test/components/Login.spec.js
new file mode 100644
index 0000000000..f34d9ea405
--- /dev/null
+++ b/test/components/Login.spec.js
@@ -0,0 +1,120 @@
+// @flow
+
+import { expect } from 'chai';
+import React from 'react';
+import { shallow } from 'enzyme';
+import sinon from 'sinon';
+
+import Login from '../../app/components/Login';
+import AccountInput from '../../app/components/AccountInput';
+
+describe('components/Login', () => {
+
+ it('notifies on the first change after failure', () => {
+ let onFirstChange = sinon.spy();
+ const props = {
+ account: Object.assign({}, defaultAccount, {
+ status: 'failed'
+ }),
+ onFirstChangeAfterFailure: onFirstChange,
+ };
+
+ const component = renderWithProps( props );
+ const accountInput = component.find(AccountInput);
+
+ accountInput.simulate('change', 'foo');
+ expect(onFirstChange.calledOnce).to.be.true;
+
+ onFirstChange.reset();
+
+ accountInput.simulate('change', 'bar');
+ expect(onFirstChange.calledOnce).to.be.false;
+ });
+
+ it('does not show the footer when logging in', () => {
+ const component = renderLoggingIn();
+ const footer = component.find('.login-footer');
+ expect(footer.hasClass('login-footer--invisible')).to.be.true;
+ });
+
+ it('shows the footer and account input when not logged in', () => {
+ const component = renderNotLoggedIn();
+ const footer = component.find('.login-footer');
+ expect(footer.hasClass('login-footer--invisible')).to.be.false;
+ expect(component.find(AccountInput).exists()).to.be.true;
+ });
+
+ it('does not show the footer nor account input when logged in', () => {
+ const component = renderLoggedIn();
+ const footer = component.find('.login-footer');
+ expect(footer.hasClass('login-footer--invisible')).to.be.true;
+ expect(component.find('.login-form__fields')).to.have.length(0);
+ });
+
+ it('logs in with the entered account number when clicking the login icon', (done) => {
+ const component = renderNotLoggedIn();
+ component.setProps({
+ account: Object.assign({}, defaultAccount, {
+ accountToken: '12345'
+ }),
+ onLogin: (an) => {
+ try {
+ expect(an).to.equal('12345');
+ done();
+ } catch (e) {
+ done(e);
+ }
+ },
+ });
+
+ component.find('.login-form__account-input-button').simulate('click');
+ });
+});
+
+const defaultAccount = {
+ accountToken: null,
+ accountHistory: [],
+ expiry: null,
+ status: 'none',
+ error: null
+};
+
+const defaultProps = {
+ account: defaultAccount,
+ onLogin: () => {},
+ onSettings: () => {},
+ onChange: () => {},
+ onFirstChangeAfterFailure: () => {},
+ onExternalLink: () => {},
+ onAccountTokenChange: (_accountToken) => {},
+ onRemoveAccountTokenFromHistory: (_accountToken) => {},
+};
+
+function renderLoggedIn() {
+ return renderWithProps({
+ account: Object.assign(defaultAccount, {
+ status: 'ok',
+ }),
+ });
+}
+
+function renderLoggingIn() {
+ return renderWithProps({
+ account: Object.assign(defaultAccount, {
+ status: 'logging in',
+ }),
+ });
+}
+
+function renderNotLoggedIn() {
+ return renderWithProps({
+ account: Object.assign(defaultAccount, {
+ status: 'none',
+ }),
+ });
+}
+
+function renderWithProps(customProps) {
+ const props = Object.assign({}, defaultProps, customProps);
+ return shallow( <Login { ...props } /> );
+}
diff --git a/test/components/SelectLocation.spec.js b/test/components/SelectLocation.spec.js
new file mode 100644
index 0000000000..1abdf515d9
--- /dev/null
+++ b/test/components/SelectLocation.spec.js
@@ -0,0 +1,92 @@
+// @flow
+
+import { expect } from 'chai';
+import React from 'react';
+import ReactTestUtils, { Simulate } from 'react-dom/test-utils';
+import SelectLocation from '../../app/components/SelectLocation';
+
+import type { SettingsReduxState } from '../../app/redux/settings/reducers';
+import type { SelectLocationProps } from '../../app/components/SelectLocation';
+
+describe('components/SelectLocation', () => {
+ const state: SettingsReduxState = {
+ relaySettings: {
+ normal: {
+ location: 'any',
+ protocol: 'any',
+ port: 'any',
+ }
+ },
+ relayLocations: [{
+ name: 'Sweden',
+ code: 'se',
+ hasActiveRelays: true,
+ cities: [{
+ name: 'Malmö',
+ code: 'mma',
+ position: [0, 0],
+ hasActiveRelays: true,
+ }],
+ }],
+ };
+
+ const makeProps = (state: SettingsReduxState, mergeProps: $Shape<SelectLocationProps>): SelectLocationProps => {
+ const defaultProps: SelectLocationProps = {
+ settings: state,
+ onClose: () => {},
+ onSelect: (_server) => {}
+ };
+ return Object.assign({}, defaultProps, mergeProps);
+ };
+
+ const render = (props: SelectLocationProps): SelectLocation => {
+ return ReactTestUtils.renderIntoDocument(
+ <SelectLocation { ...props } />
+ );
+ };
+
+ it('should call close callback', (done) => {
+ const props = makeProps(state, {
+ onClose: () => done()
+ });
+ const domNode = ReactTestUtils.findRenderedDOMComponentWithClass(render(props), 'select-location__close');
+ Simulate.click(domNode);
+ });
+
+ it('should call select callback for country', (done) => {
+ const props = makeProps(state, {
+ onSelect: (location) => {
+ try {
+ expect(location).to.deep.equal({
+ country: 'se'
+ });
+ done();
+ } catch(e) {
+ done(e);
+ }
+ }
+ });
+ const elements = ReactTestUtils.scryRenderedDOMComponentsWithClass(render(props), 'select-location__cell');
+ expect(elements).to.have.length(1);
+ Simulate.click(elements[0]);
+ });
+
+ it('should call select callback for city', (done) => {
+ const props = makeProps(state, {
+ onSelect: (location) => {
+ try {
+ expect(location).to.deep.equal({
+ city: ['se', 'mma']
+ });
+ done();
+ } catch(e) {
+ done(e);
+ }
+ }
+ });
+ const elements = ReactTestUtils.scryRenderedDOMComponentsWithClass(render(props), 'select-location__sub-cell');
+ expect(elements).to.have.length(1);
+ Simulate.click(elements[0]);
+ });
+
+});
diff --git a/test/components/Settings.spec.js b/test/components/Settings.spec.js
new file mode 100644
index 0000000000..cc38c54616
--- /dev/null
+++ b/test/components/Settings.spec.js
@@ -0,0 +1,164 @@
+// @flow
+
+import { expect } from 'chai';
+import React from 'react';
+import ReactTestUtils, { Simulate } from 'react-dom/test-utils';
+import Settings from '../../app/components/Settings';
+
+import type { AccountReduxState } from '../../app/redux/account/reducers';
+import type { SettingsReduxState } from '../../app/redux/settings/reducers';
+import type { SettingsProps } from '../../app/components/Settings';
+
+describe('components/Settings', () => {
+ const loggedOutAccountState: AccountReduxState = {
+ accountToken: null,
+ accountHistory: [],
+ expiry: null,
+ status: 'none',
+ error: null
+ };
+
+ const loggedInAccountState: AccountReduxState = {
+ accountToken: '1234',
+ accountHistory: [],
+ expiry: (new Date('2038-01-01')).toISOString(),
+ status: 'ok',
+ error: null
+ };
+
+ const unpaidAccountState: AccountReduxState = {
+ accountToken: '1234',
+ accountHistory: [],
+ expiry: (new Date('2001-01-01')).toISOString(),
+ status: 'ok',
+ error: null
+ };
+
+ const settingsState: SettingsReduxState = {
+ relaySettings: {
+ normal: {
+ location: 'any',
+ protocol: 'udp',
+ port: 1301,
+ },
+ },
+ relayLocations: [],
+ };
+
+ const makeProps = (anAccountState: AccountReduxState, aSettingsState: SettingsReduxState, mergeProps: $Shape<SettingsProps> = {}): SettingsProps => {
+ const defaultProps: SettingsProps = {
+ account: anAccountState,
+ settings: aSettingsState,
+ onQuit: () => {},
+ onClose: () => {},
+ onViewAccount: () => {},
+ onViewSupport: () => {},
+ onViewAdvancedSettings: () => {},
+ onExternalLink: (_type) => {}
+ };
+ return Object.assign({}, defaultProps, mergeProps);
+ };
+
+ const render = (props: SettingsProps): Settings => {
+ return ReactTestUtils.renderIntoDocument(
+ <Settings { ...props } />
+ );
+ };
+
+ it('should show quit button when logged out', () => {
+ const props = makeProps(loggedOutAccountState, settingsState);
+ ReactTestUtils.findRenderedDOMComponentWithClass(render(props), 'settings__quit');
+ });
+
+ it('should show quit button when logged in', () => {
+ const props = makeProps(loggedInAccountState, settingsState);
+ ReactTestUtils.findRenderedDOMComponentWithClass(render(props), 'settings__quit');
+ });
+
+ it('should show external links when logged out', () => {
+ const props = makeProps(loggedOutAccountState, settingsState);
+ ReactTestUtils.findRenderedDOMComponentWithClass(render(props), 'settings__external');
+ });
+
+ it('should show external links when logged in', () => {
+ const props = makeProps(loggedInAccountState, settingsState);
+ ReactTestUtils.findRenderedDOMComponentWithClass(render(props), 'settings__external');
+ });
+
+ it('should show account section when logged in', () => {
+ const props = makeProps(loggedInAccountState, settingsState);
+ ReactTestUtils.findRenderedDOMComponentWithClass(render(props), 'settings__account');
+ });
+
+ it('should hide account section when logged out', () => {
+ const props = makeProps(loggedOutAccountState, settingsState);
+ const elements = ReactTestUtils.scryRenderedDOMComponentsWithClass(render(props), 'settings__account');
+ expect(elements).to.be.empty;
+ });
+
+ it('should hide account link when not logged in', () => {
+ const props = makeProps(loggedOutAccountState, settingsState);
+ const elements = ReactTestUtils.scryRenderedDOMComponentsWithClass(render(props), 'settings__view-account');
+ expect(elements).to.be.empty;
+ });
+
+ it('should show out-of-time message for unpaid account', () => {
+ const props = makeProps(unpaidAccountState, settingsState);
+ const domNode = ReactTestUtils.findRenderedDOMComponentWithClass(render(props), 'settings__account-paid-until-label');
+ expect(domNode.textContent).to.contain('OUT OF TIME');
+ });
+
+ it('should hide out-of-time message for paid account', () => {
+ const props = makeProps(loggedInAccountState, settingsState);
+ const domNode = ReactTestUtils.findRenderedDOMComponentWithClass(render(props), 'settings__account-paid-until-label');
+ expect(domNode.textContent).to.not.contain('OUT OF TIME');
+ });
+
+ it('should call close callback', (done) => {
+ const props = makeProps(loggedOutAccountState, settingsState, {
+ onClose: () => done()
+ });
+ const domNode = ReactTestUtils.findRenderedDOMComponentWithClass(render(props), 'settings__close');
+ Simulate.click(domNode);
+ });
+
+ it('should call quit callback', (done) => {
+ const props = makeProps(loggedOutAccountState, settingsState, {
+ onQuit: () => done()
+ });
+ const domNode = ReactTestUtils.findRenderedDOMComponentWithClass(render(props), 'settings__quit');
+ Simulate.click(domNode);
+ });
+
+ it('should call account callback', (done) => {
+ const props = makeProps(loggedInAccountState, settingsState, {
+ onViewAccount: () => done()
+ });
+ const domNode = ReactTestUtils.findRenderedDOMComponentWithClass(render(props), 'settings__view-account');
+ Simulate.click(domNode);
+ });
+
+ it('should call support callback', (done) => {
+ const props = makeProps(loggedInAccountState, settingsState, {
+ onViewSupport: () => done()
+ });
+ const domNode = ReactTestUtils.findRenderedDOMComponentWithClass(render(props), 'settings__view-support');
+ Simulate.click(domNode);
+ });
+
+ it('should call external links callback', () => {
+ let collectedExternalLinkTypes: Array<string> = [];
+ const props = makeProps(loggedOutAccountState, settingsState, {
+ onExternalLink: (type) => {
+ collectedExternalLinkTypes.push(type);
+ }
+ });
+ const container = ReactTestUtils.findRenderedDOMComponentWithClass(render(props), 'settings__external');
+ Array.from(container.childNodes)
+ .filter((elm: HTMLElement) => elm.classList.contains('settings__cell'))
+ .forEach((elm) => Simulate.click(elm));
+
+ expect(collectedExternalLinkTypes).to.include.ordered.members(['faq', 'guides']);
+ });
+
+});
diff --git a/test/components/Support.spec.js b/test/components/Support.spec.js
new file mode 100644
index 0000000000..3251b08464
--- /dev/null
+++ b/test/components/Support.spec.js
@@ -0,0 +1,127 @@
+// @flow
+
+import { expect } from 'chai';
+import sinon from 'sinon';
+import React from 'react';
+import ReactTestUtils, { Simulate } from 'react-dom/test-utils';
+import Support from '../../app/components/Support';
+
+import type { SupportProps } from '../../app/components/Support';
+
+describe('components/Support', () => {
+
+ const makeProps = (mergeProps: $Shape<SupportProps> = {}): SupportProps => {
+ const defaultProps: SupportProps = {
+ account: {
+ accountToken: null,
+ accountHistory: [],
+ error: null,
+ expiry: null,
+ status: 'none',
+ },
+ onClose: () => {},
+ onViewLog: (_path) => {},
+ onCollectLog: () => Promise.resolve('/tmp/mullvad_problem_report.log'),
+ onSend: (_report) => {}
+ };
+ return Object.assign({}, defaultProps, mergeProps);
+ };
+
+ const render = (props: SupportProps): Support => {
+ return ReactTestUtils.renderIntoDocument(
+ <Support { ...props } />
+ );
+ };
+
+ it('should call close callback', (done) => {
+ const props = makeProps({
+ onClose: () => done()
+ });
+ const domNode = ReactTestUtils.findRenderedDOMComponentWithClass(render(props), 'support__close');
+ Simulate.click(domNode);
+ });
+
+ it('should call view logs callback', (done) => {
+ const props = makeProps({
+ onViewLog: (_path) => done()
+ });
+ const domNode = ReactTestUtils.findRenderedDOMComponentWithClass(render(props), 'support__form-view-logs');
+ Simulate.click(domNode);
+ });
+
+ it('should call send callback when description filled in', (done) => {
+ const props = makeProps({
+ onSend: (_report) => done()
+ });
+
+ const component = render(props);
+
+ const descriptionField = ReactTestUtils.findRenderedDOMComponentWithClass(component, 'support__form-message');
+ descriptionField.value = 'Lorem Ipsum';
+ Simulate.change(descriptionField);
+
+ const sendButton = ReactTestUtils.findRenderedDOMComponentWithClass(component, 'support__form-send');
+ expect(sendButton.disabled).to.be.false;
+ Simulate.click(sendButton);
+ });
+
+ it('should not call send callback when description is empty', () => {
+ const component = render(makeProps());
+
+ const descriptionField = ReactTestUtils.findRenderedDOMComponentWithClass(component, 'support__form-message');
+ descriptionField.value = '';
+ Simulate.change(descriptionField);
+
+ const sendButton = ReactTestUtils.findRenderedDOMComponentWithClass(component, 'support__form-send');
+ expect(sendButton.disabled).to.be.true;
+ });
+
+ it('should not collect report twice', (done) => {
+ const collectCallback = sinon.spy(() => Promise.resolve('non-falsy'));
+ const props = makeProps({
+ onCollectLog: collectCallback
+ });
+
+ const component = render(props);
+ const viewLogButton = ReactTestUtils.findRenderedDOMComponentWithClass(component, 'support__form-view-logs');
+ Simulate.click(viewLogButton);
+
+ setTimeout(() => {
+ Simulate.click(viewLogButton);
+ });
+
+ setTimeout(() => {
+ try {
+ expect(collectCallback.callCount).to.equal(1);
+ done();
+ } catch (e) {
+ done(e);
+ }
+ });
+ });
+
+ it('should collect report on submission', (done) => {
+ const collectCallback = sinon.spy(() => Promise.resolve(''));
+ const props = makeProps({
+ onCollectLog: collectCallback,
+ onSend: (_report) => {
+ try {
+ expect(collectCallback.calledOnce).to.be.true;
+ done();
+ } catch (e) {
+ done(e);
+ }
+ }
+ });
+
+ const component = render(props);
+
+ const descriptionField = ReactTestUtils.findRenderedDOMComponentWithClass(component, 'support__form-message');
+ descriptionField.value = 'Lorem Ipsum';
+ Simulate.change(descriptionField);
+
+ const sendButton = ReactTestUtils.findRenderedDOMComponentWithClass(component, 'support__form-send');
+ Simulate.click(sendButton);
+ });
+
+});
diff --git a/test/components/Switch.spec.js b/test/components/Switch.spec.js
new file mode 100644
index 0000000000..db7fe8d8c4
--- /dev/null
+++ b/test/components/Switch.spec.js
@@ -0,0 +1,126 @@
+// @flow
+
+import { expect } from 'chai';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import ReactTestUtils, { Simulate } from 'react-dom/test-utils';
+import Switch from '../../app/components/Switch';
+
+describe('components/Switch', () => {
+
+ let container: ?HTMLElement;
+
+ function renderIntoDocument(instance: React.Element<*>): React.Component<*, *, *> {
+ if(container) {
+ throw new Error('Unmount previously rendered component first.');
+ }
+
+ container = document.createElement('div');
+ if(!document.documentElement) {
+ throw new Error('document.documentElement cannot be null.');
+ }
+
+ document.documentElement.appendChild(container);
+
+ return ReactDOM.render(instance, container);
+ }
+
+ // unmount container and clean up DOM
+ afterEach(() => {
+ if(container) {
+ ReactDOM.unmountComponentAtNode(container);
+ container = null;
+ }
+ });
+
+ it('should switch on', (done) => {
+ const onChange = (isOn) => {
+ expect(isOn).to.be.true;
+ done();
+ };
+ const component = renderIntoDocument(
+ <Switch isOn={ false } onChange={ onChange } />
+ );
+ const domNode = ReactTestUtils.findRenderedDOMComponentWithTag(component, 'input');
+
+ Simulate.mouseDown(domNode, { clientX: 100, clientY: 0 });
+ Simulate.mouseUp(domNode, { clientX: 100, clientY: 0 });
+ Simulate.change(domNode, { target: { checked: true } });
+ });
+
+ it('should switch off', (done) => {
+ const onChange = (isOn) => {
+ expect(isOn).to.be.false;
+ done();
+ };
+ const component = renderIntoDocument(
+ <Switch isOn={ true } onChange={ onChange } />
+ );
+ const domNode = ReactTestUtils.findRenderedDOMComponentWithTag(component, 'input');
+
+ Simulate.mouseDown(domNode, { clientX: 100, clientY: 0 });
+ Simulate.mouseUp(domNode, { clientX: 100, clientY: 0 });
+ Simulate.change(domNode, { target: { checked: false } });
+ });
+
+ it('should handle left to right swipe', (done) => {
+ const onChange = (isOn) => {
+ expect(isOn).to.be.true;
+ done();
+ };
+ const component = renderIntoDocument(
+ <Switch isOn={ false } onChange={ onChange } />
+ );
+ const domNode = ReactTestUtils.findRenderedDOMComponentWithTag(component, 'input');
+
+ Simulate.mouseDown(domNode, { clientX: 100, clientY: 0 });
+
+ // Switch listens to events on document
+ document.dispatchEvent(new MouseEvent('mousemove', { clientX: 150, clientY: 0 }));
+ document.dispatchEvent(new MouseEvent('mouseup', { clientX: 150, clientY: 0 }));
+ });
+
+ it('should handle right to left swipe', (done) => {
+ const onChange = (isOn) => {
+ expect(isOn).to.be.false;
+ done();
+ };
+ const component = renderIntoDocument(
+ <Switch isOn={ true } onChange={ onChange } />
+ );
+ const domNode = ReactTestUtils.findRenderedDOMComponentWithTag(component, 'input');
+
+ Simulate.mouseDown(domNode, { clientX: 150, clientY: 0 });
+
+ // Switch listens to events on document
+ document.dispatchEvent(new MouseEvent('mousemove', { clientX: 100, clientY: 0 }));
+ document.dispatchEvent(new MouseEvent('mouseup', { clientX: 100, clientY: 0 }));
+ });
+
+ it('should timeout when user holds knob for too long without moving', (done) => {
+ const onChange = () => {
+ throw new Error('onChange should not be called on timeout.');
+ };
+
+ const component = renderIntoDocument(
+ <Switch isOn={ false } onChange={ onChange } />
+ );
+ const domNode = ReactTestUtils.findRenderedDOMComponentWithTag(component, 'input');
+
+ Simulate.mouseDown(domNode, { clientX: 100, clientY: 0 });
+
+ setTimeout(() => {
+ // Switch listens to events on document
+ document.dispatchEvent(new MouseEvent('mouseup', { clientX: 100, clientY: 0 }));
+
+ try {
+ // should not trigger onChange()
+ Simulate.change(domNode);
+ done();
+ } catch(e) {
+ done(e);
+ }
+ }, 1000);
+ });
+
+}); \ No newline at end of file
diff --git a/test/connect.spec.js b/test/connect.spec.js
new file mode 100644
index 0000000000..056e1bfe15
--- /dev/null
+++ b/test/connect.spec.js
@@ -0,0 +1,67 @@
+// @flow
+
+import { expect } from 'chai';
+import connectionActions from '../app/redux/connection/actions';
+import { setupBackendAndStore, checkNextTick } from './helpers/ipc-helpers';
+
+describe('connect', () => {
+
+ it('should set the connection state to \'disconnected\' on failed attempts', (done) => {
+ const { store, mockIpc, backend } = setupBackendAndStore();
+
+ mockIpc.connect = () => new Promise((_, reject) => reject('Some error'));
+
+ store.dispatch(connectionActions.connected());
+
+
+ expect(store.getState().connection.status).not.to.equal('disconnected');
+
+ store.dispatch(connectionActions.connect(backend));
+
+
+ checkNextTick(() => {
+ expect(store.getState().connection.status).to.equal('disconnected');
+ }, done);
+ });
+
+ it('should update the state with the server address', () => {
+ const { store, backend } = setupBackendAndStore();
+
+ return backend.connect()
+ .then( () => {
+ const state = store.getState().connection;
+ expect(state.status).to.equal('connecting');
+ });
+ });
+
+ it('should correctly deduce \'connected\' from backend states', (done) => {
+ const { store, mockIpc } = setupBackendAndStore();
+
+ checkNextTick( () => {
+ expect(store.getState().connection.status).not.to.equal('connected');
+ mockIpc.sendNewState({ state: 'secured', target_state: 'secured' });
+ expect(store.getState().connection.status).to.equal('connected');
+ }, done);
+ });
+
+ it('should correctly deduce \'connecting\' from backend states', (done) => {
+ const { store, mockIpc } = setupBackendAndStore();
+
+ checkNextTick( () => {
+ expect(store.getState().connection.status).not.to.equal('connecting');
+ mockIpc.sendNewState({ state: 'unsecured', target_state: 'secured' });
+ expect(store.getState().connection.status).to.equal('connecting');
+ }, done);
+ });
+
+ it('should correctly deduce \'disconnected\' from backend states', (done) => {
+ const { store, mockIpc } = setupBackendAndStore();
+ store.dispatch(connectionActions.connected());
+
+ checkNextTick( () => {
+ expect(store.getState().connection.status).not.to.equal('disconnected');
+ mockIpc.sendNewState({ state: 'unsecured', target_state: 'unsecured' });
+ expect(store.getState().connection.status).to.equal('disconnected');
+ }, done);
+ });
+}); \ No newline at end of file
diff --git a/test/connection-info.spec.js b/test/connection-info.spec.js
new file mode 100644
index 0000000000..76a97a35f7
--- /dev/null
+++ b/test/connection-info.spec.js
@@ -0,0 +1,46 @@
+// @flow
+
+import { expect } from 'chai';
+import { createMemoryHistory } from 'history';
+import configureStore from '../app/redux/store';
+import connectionActions from '../app/redux/connection/actions';
+
+describe('The connection state', () => {
+
+ it('should contain the latest IP', () => {
+ const store = createStore();
+
+ store.dispatch(connectionActions.newPublicIp('1.2.3.4'));
+ store.dispatch(connectionActions.newPublicIp('5.6.7.8'));
+
+ expect(store.getState().connection.clientIp).to.equal('5.6.7.8');
+ });
+
+ it('should contain the latest location', () => {
+ const store = createStore();
+
+ const firstLoc = {
+ location: [1, 2],
+ city: 'a',
+ country: 'b',
+ };
+ const secondLoc = {
+ location: [3, 4],
+ city: 'c',
+ country: 'd',
+ };
+
+ store.dispatch(connectionActions.newLocation(firstLoc));
+ store.dispatch(connectionActions.newLocation(secondLoc));
+
+ const { location, city, country } = store.getState().connection;
+ expect(location).to.equal(secondLoc.location);
+ expect(city).to.equal(secondLoc.city);
+ expect(country).to.equal(secondLoc.country);
+ });
+});
+
+function createStore() {
+ const memoryHistory = createMemoryHistory();
+ return configureStore(null, memoryHistory);
+}
diff --git a/test/helpers/IpcChain.js b/test/helpers/IpcChain.js
new file mode 100644
index 0000000000..a62626a6a6
--- /dev/null
+++ b/test/helpers/IpcChain.js
@@ -0,0 +1,104 @@
+// @flow
+
+import { expect } from 'chai';
+import { check, failFast } from './ipc-helpers';
+
+export class IpcChain {
+ _expectedCalls: Array<string>;
+ _recordedCalls: Array<string>;
+ _mockIpc: {};
+ _done: (*) => void;
+ _aborted: boolean;
+
+ constructor(mockIpc: {}) {
+ this._expectedCalls = [];
+ this._recordedCalls = [];
+ this._mockIpc = mockIpc;
+ this._aborted = false;
+ }
+
+ require(ipcCall: string): StepBuilder {
+ this._expectedCalls.push(ipcCall);
+ return new StepBuilder(ipcCall, this._addStep.bind(this));
+ }
+
+ _addStep(step: StepBuilder) {
+ const me = this;
+ this._mockIpc[step.ipcCall] = function() {
+ return new Promise(r => me._stepPromiseCallback(step, r, arguments));
+ };
+ }
+
+ _stepPromiseCallback(step, resolve, args) {
+ if (this._aborted) {
+ return;
+ }
+
+ this._registerCall(step.ipcCall);
+
+ if (step.inputValidation) {
+ const failedInputValidation = failFast(() => {
+ step.inputValidation(...args);
+ }, this._done);
+
+ if (failedInputValidation) {
+ this._abort();
+ return;
+ }
+ }
+
+ if (this._isLastCall()) {
+ this._ensureChainCalledCorrectly();
+ }
+
+ resolve(step.returnValue);
+ }
+
+ _abort() {
+ this._aborted = true;
+ }
+
+ _isLastCall(): boolean {
+ return this._recordedCalls.length === this._expectedCalls.length;
+ }
+
+ _ensureChainCalledCorrectly() {
+ check(() => {
+ expect(this._expectedCalls).to.deep.equal(this._recordedCalls);
+ }, this._done);
+ }
+
+ _registerCall(ipcCall: string) {
+ this._recordedCalls.push(ipcCall);
+ }
+
+ onSuccessOrFailure(done: (*) => void) {
+ this._done = done;
+ }
+}
+
+class StepBuilder {
+ ipcCall: string;
+ inputValidation: () => void;
+ returnValue: *;
+ _cb: (StepBuilder) => void;
+
+ constructor(ipcCall: string, cb: (StepBuilder)=> void) {
+ this.ipcCall = ipcCall;
+ this._cb = cb;
+ }
+
+ withInputValidation(iv: () => void): StepBuilder {
+ this.inputValidation = iv;
+ return this;
+ }
+
+ withReturnValue(rv: *): StepBuilder {
+ this.returnValue = rv;
+ return this;
+ }
+
+ done() {
+ this._cb(this);
+ }
+}
diff --git a/test/helpers/dom-events.js b/test/helpers/dom-events.js
new file mode 100644
index 0000000000..9a02ad167c
--- /dev/null
+++ b/test/helpers/dom-events.js
@@ -0,0 +1,22 @@
+// @flow
+const keycodes = {
+ '1': { which: 49, keyCode: 49 },
+ '2': { which: 50, keyCode: 50 },
+ '3': { which: 51, keyCode: 51 },
+ '4': { which: 52, keyCode: 52 },
+ '5': { which: 53, keyCode: 53 },
+ '6': { which: 54, keyCode: 54 },
+ '7': { which: 55, keyCode: 55 },
+ '8': { which: 56, keyCode: 56 },
+ '9': { which: 57, keyCode: 57 },
+ '0': { which: 48, keyCode: 48 },
+ Tab: { which: 9, keyCode: 9 },
+ Enter: { which: 13, keyCode: 13 },
+ Backspace: { which: 8, keyCode: 8 }
+};
+
+export type Keycode = $Keys<typeof keycodes>;
+
+export function createKeyEvent(key: Keycode): Object {
+ return Object.assign({}, { key }, keycodes[key]);
+}
diff --git a/test/helpers/ipc-helpers.js b/test/helpers/ipc-helpers.js
new file mode 100644
index 0000000000..2a2dd1e6b8
--- /dev/null
+++ b/test/helpers/ipc-helpers.js
@@ -0,0 +1,105 @@
+// @flow
+
+import { Backend } from '../../app/lib/backend';
+import { newMockIpc } from '../mocks/ipc';
+import configureStore from '../../app/redux/store';
+import { createMemoryHistory } from 'history';
+import { mockStore } from '../mocks/redux';
+
+type DoneCallback = (?mixed) => void;
+type Check = () => void;
+
+export function setupIpcAndStore() {
+ const memoryHistory = createMemoryHistory();
+ const store = configureStore(null, memoryHistory);
+
+ const mockIpc = newMockIpc();
+
+ return { store, mockIpc };
+}
+
+export function setupBackendAndStore() {
+
+ const { store, mockIpc } = setupIpcAndStore();
+
+ const credentials = {
+ sharedSecret: '',
+ connectionString: '',
+ };
+ const backend = new Backend(store, credentials, mockIpc);
+
+ return { store, mockIpc, backend };
+}
+
+export function setupBackendAndMockStore() {
+ const store = mockStore(_initialState());
+ const mockIpc = newMockIpc();
+ const credentials = {
+ sharedSecret: '',
+ connectionString: '',
+ };
+ const backend = new Backend(store, credentials, mockIpc);
+ return { store, mockIpc, backend };
+}
+
+function _initialState() {
+ const { store } = setupIpcAndStore();
+ return store.getState();
+}
+
+// chai and async aren't the best of friends. To allow us
+// to get the assertion error in the output of failed async
+// tests we need to do this try-catch thing.
+export function check(fn: Check, done: DoneCallback) {
+ try {
+ fn();
+ done();
+ } catch (e) {
+ done(e);
+ }
+}
+
+// Sometimes with redux we cannot know when all reducers have
+// finished running. This function puts the check at the end
+// of the execution queue, hopefully resulting in the check being
+// run after the reducers are finished
+export function checkNextTick(fn: Check, done: DoneCallback) {
+ setTimeout(() => {
+ check(fn, done);
+ }, 1);
+}
+
+
+// In async tests where we want to test a chain of IPC messages
+// we can only invoke `done` for the last message. This function
+// is for the intermediate messages.
+export function failFast(fn: Check, done: DoneCallback): boolean {
+ try {
+ fn();
+ return false;
+ } catch(e) {
+ done(e);
+ return true;
+ }
+}
+export function failFastNextTick(fn: Check, done: DoneCallback) {
+ setTimeout(() => {
+ failFast(fn, done);
+ }, 1);
+}
+
+type MockStore = {
+ getActions: () => Array<{type: string, payload: Object}>,
+}
+// Parses the action log to find out which URL we most recently navigated to
+// Note that this cannot be done with the real redux store, but rather must be
+// done with the mock store.
+export function getLocation(store: MockStore): ?string {
+ const navigations = store.getActions().filter(action => action.type === '@@router/CALL_HISTORY_METHOD');
+ if (navigations.length === 0) {
+ return null;
+ }
+
+ return navigations[navigations.length - 1].payload.args[0];
+}
+
diff --git a/test/ipc.spec.js b/test/ipc.spec.js
new file mode 100644
index 0000000000..6e02ab94b1
--- /dev/null
+++ b/test/ipc.spec.js
@@ -0,0 +1,138 @@
+// @flow
+
+import Ipc from '../app/lib/jsonrpc-ws-ipc.js';
+import jsonrpc from 'jsonrpc-lite';
+import { expect } from 'chai';
+import assert from 'assert';
+import type { JsonRpcMessage } from '../app/lib/jsonrpc-ws-ipc.js';
+
+describe('The IPC server', () => {
+
+ it('should send as soon as the websocket connects', () => {
+ const { ws, ipc } = setupIpc();
+ ws.close();
+
+ let sent = false;
+ const p = ipc.send('hello')
+ .then(() => {
+ expect(sent).to.be.true;
+ });
+
+ ws.on('hello', (msg) => {
+ sent = true;
+
+ ws.replyOk(msg.id);
+ });
+ ws.acceptConnection();
+
+ return p;
+ });
+
+ it('should reject failed jsonrpc requests', () => {
+ const { ws, ipc } = setupIpc();
+ ws.on('WHAT_IS_THIS', (msg) => {
+ ws.replyFail(msg.id, 'Method not found', -32601);
+ });
+
+ return ipc.send('WHAT_IS_THIS')
+ .catch((e) => {
+ expect(e.code).to.equal(-32601);
+ expect(e.message).to.contain('Method not found');
+ });
+ });
+
+ it('should route reply to correct promise', () => {
+ const { ws, ipc } = setupIpc();
+
+ ws.on('a message', (msg) => ws.replyOk(msg.id, 'a reply'));
+
+ const decoy = ipc.send('a decoy', [], 1)
+ .then(() => assert(false, 'Should not be called'))
+ .catch(e => {
+ if (e.name !== 'TimeOutError') {
+ throw e;
+ }
+ });
+ const message = ipc.send('a message', [], 1)
+ .then((reply) => expect(reply).to.equal('a reply'));
+
+ return Promise.all([message, decoy]);
+ });
+
+ it('should timeout if no response is returned', () => {
+ const { ipc } = setupIpc();
+
+ return ipc.send('a message', [], 1)
+ .catch((e) => {
+ expect(e.name).to.equal('TimeOutError');
+ expect(e.message).to.contain('timed out');
+ });
+ });
+
+ it('should route notifications', (done) => {
+ const { ws, ipc } = setupIpc();
+
+ const eventListener = (event) => {
+ try {
+ expect(event).to.equal('an event!');
+ done();
+ } catch (ex) {
+ done(ex);
+ }
+ };
+
+ ws.on('event_subscribe', (msg) => ws.replyOk(msg.id, 1));
+ ipc.on('event', eventListener)
+ .then(() => {
+ ws.reply(jsonrpc.notification('event', {subscription:1, result: 'an event!'}));
+ })
+ .catch((e) => done(e));
+ });
+});
+
+function mockWebsocket() {
+ const ws : any = {
+ listeners: {},
+ readyState: 1,
+ };
+
+ ws.on = (event, listener) => ws.listeners[event] = listener;
+ ws.send = (data) => {
+ const listener = ws.listeners[data.method];
+ if (listener) {
+ listener(data);
+ }
+ };
+
+ ws.factory = () => ws;
+
+ ws.acceptConnection = () => {
+ ws.readyState = 1;
+ ws.onopen();
+ };
+ ws.close = () => {
+ ws.readyState = 3;
+ ws.onclose();
+ };
+
+ ws.reply = (msg: JsonRpcMessage) => {
+ ws.onmessage({data: JSON.stringify(msg)});
+ };
+ ws.replyOk = (id: string, msg) => {
+ ws.reply(jsonrpc.success(id, msg || ''));
+ };
+ ws.replyFail = (id: string, msg: string, code: number) => {
+ ws.reply(jsonrpc.error(id, new jsonrpc.JsonRpcError(msg, code)));
+ };
+
+ return ws;
+}
+
+function setupIpc() {
+ const ws = mockWebsocket();
+ return {
+ ws: ws,
+ ipc: new Ipc('1.2.3.4', ws.factory),
+ };
+}
+
diff --git a/test/keyframe-animation.spec.js b/test/keyframe-animation.spec.js
new file mode 100644
index 0000000000..539efb858c
--- /dev/null
+++ b/test/keyframe-animation.spec.js
@@ -0,0 +1,250 @@
+import { expect } from 'chai';
+import KeyframeAnimation from '../app/lib/keyframe-animation';
+import { nativeImage } from 'electron';
+
+describe('lib/keyframe-animation', function() {
+ this.timeout(1000);
+
+ const newAnimation = () => {
+ const images = [1, 2, 3, 4, 5].map(() => nativeImage.createEmpty());
+ const animation = new KeyframeAnimation(images);
+ animation.speed = 1;
+ return animation;
+ };
+
+ it('should play sequence', (done) => {
+ let seq = [];
+ const animation = newAnimation();
+ animation.onFrame = () => seq.push(animation._currentFrame);
+ animation.onFinish = () => {
+ expect(seq).to.be.deep.equal([0, 1, 2, 3, 4]);
+ expect(animation._currentFrame).to.be.equal(4);
+ done();
+ };
+
+ animation.play();
+ });
+
+ it('should play one frame', (done) => {
+ let seq = [];
+ const animation = newAnimation();
+ animation.onFrame = () => seq.push(animation._currentFrame);
+ animation.onFinish = () => {
+ expect(seq).to.be.deep.equal([3]);
+ expect(animation._currentFrame).to.be.equal(3);
+ done();
+ };
+
+ animation.play({ startFrame: 3, endFrame: 3 });
+ });
+
+ it('should play sequence with custom frames', (done) => {
+ let seq = [];
+ const animation = newAnimation();
+ animation.onFrame = () => seq.push(animation._currentFrame);
+ animation.onFinish = () => {
+ expect(seq).to.be.deep.equal([2, 3, 4]);
+ expect(animation._currentFrame).to.be.equal(4);
+ done();
+ };
+
+ animation.play({
+ startFrame: 2,
+ endFrame: 4
+ });
+ });
+
+ it('should play sequence with custom frames in reverse', (done) => {
+ let seq = [];
+ const animation = newAnimation();
+ animation.onFrame = () => seq.push(animation._currentFrame);
+ animation.onFinish = () => {
+ expect(seq).to.be.deep.equal([4, 3, 2]);
+ expect(animation._currentFrame).to.be.equal(2);
+ done();
+ };
+
+ animation.reverse = true;
+ animation.play({
+ startFrame: 4,
+ endFrame: 2
+ });
+ });
+
+ it('should begin from current state starting below range', (done) => {
+ let seq = [];
+ const animation = newAnimation();
+ animation.onFrame = () => seq.push(animation._currentFrame);
+ animation.onFinish = () => {
+ expect(seq).to.be.deep.equal([0, 1, 2, 3, 4]);
+ expect(animation._currentFrame).to.be.equal(4);
+ done();
+ };
+
+ animation._currentFrame = 0;
+ animation._isFirstRun = false;
+
+ animation.play({
+ beginFromCurrentState: true,
+ startFrame: 3,
+ endFrame: 4
+ });
+ });
+
+ it('should begin from current state starting below range reverse', (done) => {
+ let seq = [];
+ const animation = newAnimation();
+ animation.onFrame = () => seq.push(animation._currentFrame);
+ animation.onFinish = () => {
+ expect(seq).to.be.deep.equal([0, 1, 2, 3]);
+ expect(animation._currentFrame).to.be.equal(3);
+ done();
+ };
+
+ animation._currentFrame = 0;
+ animation._isFirstRun = false;
+ animation.reverse = true;
+
+ animation.play({
+ beginFromCurrentState: true,
+ startFrame: 3,
+ endFrame: 4
+ });
+ });
+
+ it('should begin from current state starting above range', (done) => {
+ let seq = [];
+ const animation = newAnimation();
+ animation.onFrame = () => seq.push(animation._currentFrame);
+ animation.onFinish = () => {
+ expect(seq).to.be.deep.equal([4, 3, 2]);
+ expect(animation._currentFrame).to.be.equal(2);
+ done();
+ };
+
+ animation._currentFrame = 4;
+ animation._isFirstRun = false;
+
+ animation.play({
+ beginFromCurrentState: true,
+ startFrame: 1,
+ endFrame: 2
+ });
+ });
+
+ it('should begin from current state starting above range reverse', (done) => {
+ let seq = [];
+ const animation = newAnimation();
+ animation.onFrame = () => seq.push(animation._currentFrame);
+ animation.onFinish = () => {
+ expect(seq).to.be.deep.equal([4, 3, 2, 1]);
+ expect(animation._currentFrame).to.be.equal(1);
+ done();
+ };
+
+ animation._currentFrame = 4;
+ animation._isFirstRun = false;
+ animation.reverse = true;
+
+ animation.play({
+ beginFromCurrentState: true,
+ startFrame: 1,
+ endFrame: 3
+ });
+ });
+
+ it('should play sequence in reverse', (done) => {
+ let seq = [];
+ const animation = newAnimation();
+ animation.onFrame = () => seq.push(animation._currentFrame);
+ animation.onFinish = () => {
+ expect(seq).to.be.deep.equal([4, 3, 2, 1, 0]);
+ expect(animation._currentFrame).to.be.equal(0);
+ done();
+ };
+
+ animation.reverse = true;
+ animation.play();
+ });
+
+ it('should play sequence on repeat', (done) => {
+ let seq = [];
+ const animation = newAnimation();
+ const expectedFrames = [0, 1, 2, 3, 4, 0, 1, 2, 3, 4];
+
+ animation.onFrame = () => {
+ if(seq.length === expectedFrames.length) {
+ animation.stop();
+ expect(seq).to.be.deep.equal(expectedFrames);
+ done();
+ } else {
+ seq.push(animation._currentFrame);
+ }
+ };
+
+ animation.repeat = true;
+ animation.play();
+ });
+
+ it('should play sequence on repeat in reverse', (done) => {
+ let seq = [];
+ const animation = newAnimation();
+ const expectedFrames = [4, 3, 2, 1, 0, 4, 3, 2, 1, 0];
+
+ animation.onFrame = () => {
+ if(seq.length === expectedFrames.length) {
+ animation.stop();
+ expect(seq).to.be.deep.equal(expectedFrames);
+ done();
+ } else {
+ seq.push(animation._currentFrame);
+ }
+ };
+
+ animation.repeat = true;
+ animation.reverse = true;
+ animation.play();
+ });
+
+ it('should alternate sequence', (done) => {
+ let seq = [];
+ const animation = newAnimation();
+ const expectedFrames = [0, 1, 2, 3, 4, 3, 2, 1, 0];
+
+ animation.onFrame = () => {
+ if(seq.length === expectedFrames.length) {
+ animation.stop();
+ expect(seq).to.be.deep.equal(expectedFrames);
+ done();
+ } else {
+ seq.push(animation._currentFrame);
+ }
+ };
+
+ animation.repeat = true;
+ animation.alternate = true;
+ animation.play();
+ });
+
+ it('should alternate reverse sequence', (done) => {
+ let seq = [];
+ const animation = newAnimation();
+ const expectedFrames = [4, 3, 2, 1, 0, 1, 2, 3, 4];
+
+ animation.onFrame = () => {
+ if(seq.length === expectedFrames.length) {
+ animation.stop();
+ expect(seq).to.be.deep.equal(expectedFrames);
+ done();
+ } else {
+ seq.push(animation._currentFrame);
+ }
+ };
+
+ animation.repeat = true;
+ animation.reverse = true;
+ animation.alternate = true;
+ animation.play();
+ });
+
+}); \ No newline at end of file
diff --git a/test/login.spec.js b/test/login.spec.js
new file mode 100644
index 0000000000..1efe89ae38
--- /dev/null
+++ b/test/login.spec.js
@@ -0,0 +1,85 @@
+// @flow
+
+import { expect } from 'chai';
+import { setupBackendAndStore, setupBackendAndMockStore, checkNextTick, getLocation, failFast, check } from './helpers/ipc-helpers';
+import { IpcChain } from './helpers/IpcChain';
+import accountActions from '../app/redux/account/actions';
+
+describe('Logging in', () => {
+
+ it('should validate the account number and then set it in the backend', (done) => {
+ const { store, mockIpc, backend } = setupBackendAndStore();
+
+ const chain = new IpcChain(mockIpc);
+ chain.require('getAccountData')
+ .withInputValidation((an) => {
+ expect(an).to.equal('123');
+ })
+ .done();
+
+ chain.require('setAccount')
+ .withInputValidation((an) => {
+ expect(an).to.equal('123');
+ })
+ .done();
+
+ chain.onSuccessOrFailure(done);
+
+ store.dispatch(accountActions.login(backend, '123'));
+ });
+
+ it('should put the account data in the state', () => {
+ const { store, backend, mockIpc } = setupBackendAndStore();
+ mockIpc.getAccountData = () => new Promise(r => r({
+ expiry: '2001-01-01T00:00:00Z',
+ }));
+
+ return backend.login('123')
+ .then( () => {
+ const state = store.getState().account;
+ expect(state.status).to.equal('ok');
+ expect(state.accountToken).to.equal('123');
+ expect(state.expiry).to.equal('2001-01-01T00:00:00Z');
+ });
+ });
+
+ it('should indicate failure for non-existing accounts', (done) => {
+ const { store, mockIpc, backend } = setupBackendAndStore();
+
+ mockIpc.getAccountData = (_num) => new Promise((_, reject) => {
+ reject('NO SUCH ACCOUNT');
+ });
+
+
+ store.dispatch(accountActions.login(backend, '123'));
+
+ checkNextTick(() => {
+ const state = store.getState().account;
+ expect(state.status).to.equal('failed');
+ expect(state.error).to.not.be.null;
+ }, done);
+ });
+
+ it('should redirect to /connect after 1s after successful login', (done) => {
+ const { store, backend } = setupBackendAndMockStore();
+
+ store.dispatch(accountActions.login(backend, '123'));
+
+ setTimeout(() => {
+
+ failFast(() => {
+ expect(getLocation(store)).not.to.equal('/connect');
+ }, done);
+
+ }, 100);
+
+
+ setTimeout(() => {
+
+ check(() => {
+ expect(getLocation(store)).to.equal('/connect');
+ }, done);
+
+ }, 1100);
+ });
+});
diff --git a/test/logout.spec.js b/test/logout.spec.js
new file mode 100644
index 0000000000..0e295aef9b
--- /dev/null
+++ b/test/logout.spec.js
@@ -0,0 +1,66 @@
+// @flow
+
+import { expect } from 'chai';
+import { setupBackendAndStore, setupBackendAndMockStore, getLocation, checkNextTick, failFastNextTick } from './helpers/ipc-helpers';
+import { IpcChain } from './helpers/IpcChain';
+import accountActions from '../app/redux/account/actions';
+
+describe('logging out', () => {
+
+ it('should set the account to null and then disconnect', (done) => {
+ const { mockIpc, backend } = setupBackendAndStore();
+
+ const chain = new IpcChain(mockIpc);
+ chain.require('setAccount')
+ .withInputValidation((num) => {
+ expect(num).to.be.null;
+ })
+ .done();
+ chain.require('disconnect')
+ .done();
+ chain.onSuccessOrFailure(done);
+
+ backend.logout();
+ });
+
+
+ it('should remove the account number from the store', (done) => {
+
+ const { store, backend, mockIpc } = setupBackendAndStore();
+ mockIpc.getAccountData = () => new Promise(r => r({
+ expiry: '2001-01-01T00:00:00.000Z',
+ }));
+ const action: any = accountActions.login(backend, '123');
+ store.dispatch(action);
+
+ const expectedLogoutState = {
+ status: 'none',
+ accountToken: null,
+ expiry: null,
+ error: null,
+ };
+
+ failFastNextTick(() => {
+ let state = store.getState().account;
+ expect(state).not.to.include(expectedLogoutState);
+
+ backend.logout();
+
+ checkNextTick(() => {
+ state = store.getState().account;
+ expect(state).to.include(expectedLogoutState);
+ }, done);
+ }, done);
+ });
+
+
+ it('should redirect to / on logout', (done) => {
+ const { store, backend } = setupBackendAndMockStore();
+
+ backend.logout();
+
+ checkNextTick(() => {
+ expect(getLocation(store)).to.equal('/');
+ }, done);
+ });
+});
diff --git a/test/mocks/ipc.js b/test/mocks/ipc.js
new file mode 100644
index 0000000000..ede4bd37b7
--- /dev/null
+++ b/test/mocks/ipc.js
@@ -0,0 +1,98 @@
+// @flow
+import type { IpcFacade, BackendState } from '../../app/lib/ipc-facade';
+
+interface MockIpc {
+ sendNewState: (BackendState) => void;
+ killWebSocket: () => void;
+ -getAccountData: *;
+ -connect: *;
+ -getAccount: *;
+ -authenticate: *;
+}
+
+export function newMockIpc() {
+
+ const stateListeners = [];
+ const connectionCloseListeners = [];
+
+ const mockIpc: IpcFacade & MockIpc = {
+
+ setConnectionString: (_str: string) => {},
+
+ getAccountData: (accountToken) => Promise.resolve({
+ accountToken: accountToken,
+ expiry: '',
+ }),
+
+ getAccount: () => Promise.resolve('1111'),
+
+ setAccount: () => Promise.resolve(),
+
+ updateRelaySettings: () => Promise.resolve(),
+
+ getRelaySettings: () => Promise.resolve({
+ custom_tunnel_endpoint: {
+ host: 'www.example.com',
+ tunnel: {
+ openvpn: {
+ port: 1301,
+ protocol: 'udp',
+ }
+ }
+ },
+ }),
+
+ getRelayLocations: () => Promise.resolve({
+ countries: [],
+ }),
+
+ connect: () => Promise.resolve(),
+
+ disconnect: () => Promise.resolve(),
+
+ shutdown: () => Promise.resolve(),
+
+ getPublicIp: () => Promise.resolve('1.2.3.4'),
+
+ getLocation: () => Promise.resolve({
+ country: '',
+ country_code: '',
+ city: '',
+ city_code: '',
+ position: [0, 0],
+ }),
+
+ getState: () => Promise.resolve({
+ state: 'unsecured',
+ target_state:'unsecured',
+ }),
+
+ registerStateListener: (listener: (BackendState) => void) => {
+ stateListeners.push(listener);
+ },
+
+ sendNewState: (state: BackendState) => {
+ for(const l of stateListeners) {
+ l(state);
+ }
+ },
+
+ setCloseConnectionHandler: (listener: () => void) => {
+ connectionCloseListeners.push(listener);
+ },
+
+ authenticate: (_secret: string) => Promise.resolve(),
+
+ getAccountHistory: () => Promise.resolve([]),
+
+ removeAccountFromHistory: (_accountToken) => Promise.resolve(),
+
+ killWebSocket: () => {
+ for(const l of connectionCloseListeners) {
+ l();
+ }
+ }
+ };
+
+ return mockIpc;
+}
diff --git a/test/mocks/redux.js b/test/mocks/redux.js
new file mode 100644
index 0000000000..a652009645
--- /dev/null
+++ b/test/mocks/redux.js
@@ -0,0 +1,4 @@
+import configureMockStore from 'redux-mock-store';
+import thunk from 'redux-thunk';
+
+export const mockStore = configureMockStore([ thunk ]);
diff --git a/test/relay-settings-builder.spec.js b/test/relay-settings-builder.spec.js
new file mode 100644
index 0000000000..3f27d2df23
--- /dev/null
+++ b/test/relay-settings-builder.spec.js
@@ -0,0 +1,151 @@
+// @flow
+
+import { expect } from 'chai';
+import RelaySettingsBuilder from '../app/lib/relay-settings-builder';
+
+describe('Relay settings builder', () => {
+
+ it('should set location to any', () => {
+ expect(
+ RelaySettingsBuilder.normal()
+ .location
+ .any()
+ .build()
+ ).to.deep.equal({
+ normal: {
+ location: 'any'
+ }
+ });
+ });
+
+ it('should bound location to city', () => {
+ expect(
+ RelaySettingsBuilder.normal()
+ .location.city('se', 'mma').build()
+ ).to.deep.equal({
+ normal: {
+ location: {
+ only: {
+ city: ['se', 'mma']
+ }
+ }
+ }
+ });
+ });
+
+ it('should bound location to country', () => {
+ expect(
+ RelaySettingsBuilder.normal()
+ .location.country('se').build()
+ ).to.deep.equal({
+ normal: {
+ location: {
+ only: { country: 'se' }
+ }
+ }
+ });
+ });
+
+ it('should set openvpn settings to any', () => {
+ expect(
+ RelaySettingsBuilder.normal()
+ .tunnel.openvpn(openvpn => {
+ openvpn.port.any()
+ .protocol.any();
+ })
+ .build()
+ ).to.deep.equal({
+ normal: {
+ tunnel: {
+ only: {
+ openvpn: {
+ port: 'any',
+ protocol: 'any'
+ }
+ }
+ }
+ }
+ });
+ });
+
+ it('should set openvpn settings to exact values', () => {
+ expect(
+ RelaySettingsBuilder.normal()
+ .tunnel.openvpn(openvpn => {
+ openvpn.port.exact(80)
+ .protocol.exact('tcp');
+ })
+ .build()
+ ).to.deep.equal({
+ normal: {
+ tunnel: {
+ only: {
+ openvpn: {
+ port: { only: 80 },
+ protocol: { only: 'tcp' }
+ }
+ }
+ }
+ }
+ });
+ });
+
+ it('should set location from raw RelayLocation', () => {
+ expect(
+ RelaySettingsBuilder.normal()
+ .location.fromRaw('any')
+ .build()
+ ).to.deep.equal({
+ normal: {
+ location: 'any'
+ }
+ });
+
+ expect(
+ RelaySettingsBuilder.normal()
+ .location.fromRaw({ country: 'se'})
+ .build()
+ ).to.deep.equal({
+ normal: {
+ location: {
+ only: { country: 'se' }
+ }
+ }
+ });
+
+ expect(
+ RelaySettingsBuilder.normal()
+ .location.fromRaw({ city: ['se', 'mma']})
+ .build()
+ ).to.deep.equal({
+ normal: {
+ location: {
+ only: { city: ['se', 'mma'] }
+ }
+ }
+ });
+ });
+
+ it('should set custom endpoint settings', () => {
+ expect(
+ RelaySettingsBuilder.custom()
+ .host('se2.mullvad.net')
+ .tunnel.openvpn((openvpn) => {
+ openvpn.port(80)
+ .protocol('tcp');
+ })
+ .build()
+ ).to.deep.equal({
+ custom_tunnel_endpoint: {
+ host: 'se2.mullvad.net',
+ tunnel: {
+ openvpn: {
+ port: 80,
+ protocol: 'tcp'
+ }
+ }
+ }
+ });
+ });
+
+}); \ No newline at end of file
diff --git a/test/setup/enzyme.js b/test/setup/enzyme.js
new file mode 100644
index 0000000000..135148a7c4
--- /dev/null
+++ b/test/setup/enzyme.js
@@ -0,0 +1,4 @@
+const Enzyme = require('enzyme');
+const Adapter = require('enzyme-adapter-react-16');
+
+Enzyme.configure({ adapter: new Adapter() });
diff --git a/test/setup/main.js b/test/setup/main.js
new file mode 100644
index 0000000000..dd458a30b6
--- /dev/null
+++ b/test/setup/main.js
@@ -0,0 +1,4 @@
+const log = require('electron-log');
+
+log.transports.console.level = false;
+log.transports.file.level = false;
diff --git a/test/transition-rule.spec.js b/test/transition-rule.spec.js
new file mode 100644
index 0000000000..647517ca5d
--- /dev/null
+++ b/test/transition-rule.spec.js
@@ -0,0 +1,58 @@
+// @flow
+import { expect } from 'chai';
+import TransitionRule from '../app/lib/transition-rule';
+
+describe('TransitionRule', () => {
+ const testTransition = {
+ forward: { name: 'forward', duration: 0.25 },
+ backward: { name: 'backward', duration: 0.25 }
+ };
+
+ it('should match wildcard rule', () => {
+ const rule = new TransitionRule(null, '/route', testTransition);
+
+ expect(rule.match(null, '/route')).to.deep.equal({
+ direction: 'forward',
+ descriptor: { name: 'forward', duration: 0.25 }
+ });
+
+ expect(rule.match('/somewhere', '/route')).to.deep.equal({
+ direction: 'forward',
+ descriptor: { name: 'forward', duration: 0.25 }
+ });
+ });
+
+ it('should match wildcard rule reversion', () => {
+ const rule = new TransitionRule(null, '/route', testTransition);
+
+ expect(rule.match('/route', '/other')).to.deep.equal({
+ direction: 'backward',
+ descriptor: { name: 'backward', duration: 0.25 }
+ });
+ });
+
+ it('should match exact rule', () => {
+ const rule = new TransitionRule('/route1', '/route2', testTransition);
+
+ expect(rule.match('/other', '/route1')).to.be.null;
+ expect(rule.match('/other', '/route2')).to.be.null;
+
+ expect(rule.match('/route1', '/route2')).to.deep.equal({
+ direction: 'forward',
+ descriptor: { name: 'forward', duration: 0.25 }
+ });
+ });
+
+ it('should match exact rule reversion', () => {
+ const rule = new TransitionRule('/route1', '/route2', testTransition);
+
+ expect(rule.match('/route1', '/other')).to.be.null;
+ expect(rule.match('/route2', '/other')).to.be.null;
+
+ expect(rule.match('/route2', '/route1')).to.deep.equal({
+ direction: 'backward',
+ descriptor: { name: 'backward', duration: 0.25 }
+ });
+ });
+
+}); \ No newline at end of file