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/components | |
| 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/components')
| -rw-r--r-- | test/components/Accordion.spec.js | 96 | ||||
| -rw-r--r-- | test/components/Account.spec.js | 78 | ||||
| -rw-r--r-- | test/components/AccountInput.spec.js | 178 | ||||
| -rw-r--r-- | test/components/Connect.spec.js | 178 | ||||
| -rw-r--r-- | test/components/HeaderBar.spec.js | 71 | ||||
| -rw-r--r-- | test/components/Login.spec.js | 120 | ||||
| -rw-r--r-- | test/components/SelectLocation.spec.js | 92 | ||||
| -rw-r--r-- | test/components/Settings.spec.js | 164 | ||||
| -rw-r--r-- | test/components/Support.spec.js | 127 | ||||
| -rw-r--r-- | test/components/Switch.spec.js | 126 |
10 files changed, 1230 insertions, 0 deletions
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 |
