diff options
| author | Linus Färnstrand <linus@mullvad.net> | 2017-12-20 11:32:55 +0100 |
|---|---|---|
| committer | Linus Färnstrand <linus@mullvad.net> | 2017-12-20 11:34:21 +0100 |
| commit | 2aae380b0af018bf0187bb31fb0fedf6a457ebf1 (patch) | |
| tree | a8ad6ee12956d92e6257bea07dedc44063f3017f /test | |
| parent | 7b47ddf735af7f3d6065fb6c3ffea6e9ddfd86cb (diff) | |
| parent | 8b146934260739ae609791a1fb676d48ceb954c0 (diff) | |
| download | mullvadvpn-2aae380b0af018bf0187bb31fb0fedf6a457ebf1.tar.xz mullvadvpn-2aae380b0af018bf0187bb31fb0fedf6a457ebf1.zip | |
Merge backend and frontend repo master branches
Conflicts:
.gitignore
.travis.yml
README.md
Diffstat (limited to 'test')
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 |
