summaryrefslogtreecommitdiffhomepage
path: root/test/components
diff options
context:
space:
mode:
authorLinus Färnstrand <linus@mullvad.net>2017-12-20 11:32:55 +0100
committerLinus Färnstrand <linus@mullvad.net>2017-12-20 11:34:21 +0100
commit2aae380b0af018bf0187bb31fb0fedf6a457ebf1 (patch)
treea8ad6ee12956d92e6257bea07dedc44063f3017f /test/components
parent7b47ddf735af7f3d6065fb6c3ffea6e9ddfd86cb (diff)
parent8b146934260739ae609791a1fb676d48ceb954c0 (diff)
downloadmullvadvpn-2aae380b0af018bf0187bb31fb0fedf6a457ebf1.tar.xz
mullvadvpn-2aae380b0af018bf0187bb31fb0fedf6a457ebf1.zip
Merge backend and frontend repo master branches
Conflicts: .gitignore .travis.yml README.md
Diffstat (limited to 'test/components')
-rw-r--r--test/components/Accordion.spec.js96
-rw-r--r--test/components/Account.spec.js78
-rw-r--r--test/components/AccountInput.spec.js178
-rw-r--r--test/components/Connect.spec.js178
-rw-r--r--test/components/HeaderBar.spec.js71
-rw-r--r--test/components/Login.spec.js120
-rw-r--r--test/components/SelectLocation.spec.js92
-rw-r--r--test/components/Settings.spec.js164
-rw-r--r--test/components/Support.spec.js127
-rw-r--r--test/components/Switch.spec.js126
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