diff --git a/.babelrc b/.babelrc new file mode 100644 index 00000000..d4e5f8d4 --- /dev/null +++ b/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["es2015", "react"] +} diff --git a/.eslintrc b/.eslintrc index 7ed608d3..379472c5 100644 --- a/.eslintrc +++ b/.eslintrc @@ -25,11 +25,15 @@ "browser": true, "node": true, "commonjs": true, - "jquery": true + "jquery": true, + "jest": true }, "extends": "eslint:recommended", "ecmaFeatures": { "jsx": true, + "modules": true, + "arrowFunctions": true, + "blockBindings": true, "experimentalObjectRestSpread": true }, "plugins": [ diff --git a/.travis.yml b/.travis.yml index 6d560114..d428abed 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,7 +11,7 @@ addons: firefox: "42.0" before_install: - - pip install coveralls flake8 coverage beautifulsoup4 py-dateutil selenium + - pip install coveralls flake8 coverage beautifulsoup4 py-dateutil pytz selenium before_script: - python3 -m flake8 . @@ -20,17 +20,17 @@ before_script: - nvm install stable - npm install npm -g - npm install + - node_modules/gulp/bin/gulp.js dev-build - ./tests/python/selenium_webapp.py &>/dev/null & - sleep 2 - - node_modules/gulp/bin/gulp.js dev-build script: -# - npm test + - npm test - xvfb-run --server-args="-screen 0, 1280x1280x16" tests/python/coverage_run.sh after_success: - coveralls -# - npm coveralls + - npm coveralls notifications: email: diff --git a/Dockerfile b/Dockerfile index d1bf024e..320f278b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM python:3.4 WORKDIR /dokomo -RUN apt-get update && apt-get install npm nodejs -y +RUN apt-get update && apt-get install npm nodejs postgresql-client -y ADD package.json /tmp/package.json RUN cd /tmp && npm install && npm install lodash --save-dev RUN cp -a /tmp/node_modules /dokomo/ diff --git a/README.md b/README.md index c12fe43d..a2d980db 100644 --- a/README.md +++ b/README.md @@ -1,90 +1,65 @@ -# About +# Dokomo Forms -Dokomo Forms is a self-hosted data collection and analysis platform, and is the successor to [Formhub](https://formhub.org/). +Dokomo Forms is a free and open source data collection and analysis platform. [![Build Status](https://travis-ci.org/SEL-Columbia/dokomoforms.svg?branch=master)](https://travis-ci.org/SEL-Columbia/dokomoforms) -[![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/SEL-Columbia/dokomoforms?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) - [![Coverage Status](https://coveralls.io/repos/SEL-Columbia/dokomoforms/badge.svg?branch=master)](https://coveralls.io/r/SEL-Columbia/dokomoforms?branch=master) - -[![Sauce Test Status](https://saucelabs.com/browser-matrix/dokomo_sauce_matrix.svg)](https://saucelabs.com/u/dokomo_sauce_matrix) - +[![Documentation Status](https://readthedocs.org/projects/dokomoforms/badge/?version=master)](https://readthedocs.org/projects/dokomoforms/?badge=latest) +[![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/SEL-Columbia/dokomoforms?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![Dependency Status](https://gemnasium.com/SEL-Columbia/dokomoforms.svg)](https://gemnasium.com/SEL-Columbia/dokomoforms) +[![Sauce Test Status](https://saucelabs.com/browser-matrix/dokomo_sauce_matrix.svg)](https://saucelabs.com/u/dokomo_sauce_matrix) -[![Documentation Status](https://readthedocs.org/projects/dokomoforms/badge/?version=latest)](https://readthedocs.org/projects/dokomoforms/?badge=latest) - -# Staging - -1. Organization owns instance, and all users belong to the organization. (TODO) - -2. Filesystem-level encryption. (TODO) - -3. i18n - -4. Focus on questions rather than surveys. (TODO) - -5. You can specify configuration options in `local_config.py` or as command line flags to [webapp.py](webapp.py). The available options are defined in [dokomoforms/options.py](dokomoforms/options.py) - -6. [webapp.py](webapp.py) now sets up the tables for you (no more `manage_db.py`). If you want `$ manage_db.py -d`, run - - `$ ./webapp.py --kill=True` - - You can also specify the schema you want like `$ ./webappy.py --schema=whatever` - -7. New way to run tests (after `$ pip install tox`): - - `$ tox` - - Or, if you want the coverage report as well, - - `$ tox -e cover` - - The tests only touch the `doko_test` schema (which they create/destroy for you). +## About the Project -# Using Docker for Local Dev Environment and Deployment +Several solutions exist to handle offline mobile data collection. While this type of technology is increasingly valuable to organizations working in the developing world, the available solutions are cumbersome to use, often requiring a confusing mélange of individual software components and advanced technical skills to setup and manage. -[Docker](https://en.wikipedia.org/wiki/Docker_(software)) is a container management software that aims at component separation and deployment automation. Please refer to [the Docker API](https://docs.docker.com/) for a fuller introduction. +**Dokomo strives to simplify the process by integrating the elements of a data collection effort into a unified system, from creation of mobile-ready surveys to quick analysis and visualization of the collected data.** -## Using Docker Manually (Docker knowledge required) +## Features -There is a [Dockerfile](Dockerfile) in the root directory to build the Docker image of the Dokomo Forms webapp component building on top a Python 3 image. To build the webapp image, run +#### Mobile-Web Technology -> $ docker build -t selcolumbia/dokomoforms . +Instead of relying on platform-specific apps, Dokomo's surveys are conducted using an offline-capable mobile web app. This makes for an easier workflow for administrators and enumerators — surveys can be distributed and accessed via a normal web link, and can be conducted on (almost) any device that has a web browser. -However, Dokomo Forms as a service needs other components such as the database in order to work. We have referenced `mdillon/postgis` as the image, since we are using PostgreSQL with the PostGIS extension. You may also substitute `mdillon/postgis` with any image includes PostGIS. A manual way to run Dokomo Forms as a service would involve starting the `postgis` container and linking it to the Dokomo Forms image we have just built, such as: +![alt Dokomo Forms Admin - Manage](https://i.imgur.com/saW5zcB.jpg) -> $ docker run -d -p 8888:8888 --link postgis:db selcolumbia/dokomoforms +#### Survey Monitoring -## Using Docker for Local Development +As an adminstrator of a surveying effort, it's important to know where, when, and by whom data is being submitted. Dokomo Forms lets administrators quickly see the current progress of an effort, providing a quick list and map of the latest submissions and a graph showing submissions/day for the recent past. -`docker-compose` is the program that automates Docker container building, running, and linking as described above. It uses the [docker-compose.yml](docker-compose.yml) file which is provided in the root directory. +![alt Dokomo Forms Admin - Manage](https://i.imgur.com/6z7UJt2.jpg) -To start the service locally, run: +#### Submission Data Quick Views -> $ docker-compose up +Administrators can quickly view data from individual submissions and get some basic statistics and aggregations from each question on a survey. -Docker will download the necessary images, then build and link them. This step takes 3-5 minutes for the first build. Once the command has finished, you can visit [http://localhost:8888](http://localhost:8888) and start using Dokomo Forms. +![alt Dokomo Forms Admin - Data](https://i.imgur.com/hwYRf8e.jpg) -## Using Docker for Automated Deployment +#### Revisit Integration -`docker-machine` is the program that automates the deployment process. It can hook into many VPS providers such as [AWS](http://aws.amazon.com/), [Rackspace](http://www.rackspace.com/) and [DigitalOcean](https://www.digitalocean.com/). +Dokomo Forms integrates with [Revisit](http://revisit.global), a global facility registry API built here at the Sustainable Engineering Lab. Leveraging Revisit, multiple surveys conducted at the same facility can be easily linked, allowing changes in data points at survey locations to be tracked over time. -Here is an example using DigitalOcean: +## Under Development -1. Obtain a token from DigitalOcean. Click on "Generate New Token" from the API page as indicated below. +Dokomo Forms is under active development, with some pretty nifty features on the horizon. - ![doapi](http://i.imgur.com/0SrmqX7.jpg) +#### Survey Creation GUI -2. Create a droplet with the token you have just acquired +Soon survey administrators will be able to quickly create surveys though a web-based creation tool, built directly into Dokomo Forms. - > $ docker-machine create -d digitalocean --digitalocean-access-token YOUR_ACCESS_TOKEN dokomoforms +#### Better Survey Administration -3. Make your local Docker environment aware of this new machine +- Publish surveys directly from the administration panel to enumerators' mobile devices. +- Send updates and communications to enumerators - > $ eval $(docker-machine env dokomoforms) +#### Data Visualization -4. Run `docker-compose` with the new environment +- View collected data on map +- See quick statistics and aggregations on a per-question basis - > $ docker-compose up -d +## Guides and Documentation -Now you have an instance of Dokomo Forms running on your DigitalOcean droplet! +- [User Guide](https://github.com/SEL-Columbia/dokomoforms/wiki/User-Guide) +- [Local Development Environment Setup](https://github.com/SEL-Columbia/dokomoforms/wiki/Local-Development-Environment) +- [Deployment](https://github.com/SEL-Columbia/dokomoforms/wiki/Deployment) +- [REST API Documentation](https://github.com/SEL-Columbia/dokomoforms/wiki/REST-API-v0.2.0) diff --git a/config.py b/config.py index 88d3d7a4..591a71d4 100755 --- a/config.py +++ b/config.py @@ -29,9 +29,10 @@ db_database = 'doko' db_user = 'postgres' -organization = 'unconfigured organization' -https = True +organization = 'unconfigured organization' +admin_email = 'admin@example.com' +admin_name = 'admin' try: from local_config import * # NOQA diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index d0f265bb..1c73a2c4 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -1,6 +1,6 @@ webapp-dev: build: . - command: bash -c "head -c 24 /dev/urandom > cookie_secret && python webapp.py" + command: bash -c "./docker-wait-for-postgres.sh db-dev && head -c 24 /dev/urandom > cookie_secret && python webapp.py" volumes: - ./:/dokomo links: @@ -10,5 +10,4 @@ webapp-dev: db-dev: image: "mdillon/postgis:9.4" environment: - POSTGRES_PASSWORD: 'password' POSTGRES_DB: 'doko' diff --git a/docker-compose.yml b/docker-compose.yml index de4ef25b..b9f23b1d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,22 +1,22 @@ nginx: - image: "nginx:1.9.5" + image: "nginx:1.9.8" links: - "webapp:webapp" ports: - "80:80" + - "443:443" volumes: - ./nginx.conf:/etc/nginx/nginx.conf + - /etc/letsencrypt:/etc/letsencrypt + - /tmp:/tmp webapp: - image: "selcolumbia/dokomoforms" - command: bash -c "head -c 24 /dev/urandom > cookie_secret && python webapp.py" + image: "selcolumbia/dokomoforms:0.2.2" + command: bash -c "./docker-wait-for-postgres.sh db && head -c 24 /dev/urandom > cookie_secret && python webapp.py" links: - "db:db" - ports: - - "8888:8888" volumes: - ./local_config.py:/dokomo/local_config.py db: image: "mdillon/postgis:9.4" environment: - POSTGRES_PASSWORD: 'password' POSTGRES_DB: 'doko' diff --git a/docker-wait-for-postgres.sh b/docker-wait-for-postgres.sh new file mode 100755 index 00000000..b2c7c907 --- /dev/null +++ b/docker-wait-for-postgres.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env sh +until psql --host=$1 --username=postgres -w &>/dev/null +do + echo "Waiting for PostgreSQL..." + sleep 1 +done diff --git a/dokomoforms/handlers/api/v0/base.py b/dokomoforms/handlers/api/v0/base.py index cd508cd7..6ed2da14 100644 --- a/dokomoforms/handlers/api/v0/base.py +++ b/dokomoforms/handlers/api/v0/base.py @@ -283,6 +283,7 @@ def list(self, where=None): Given a model class, build up the ORM query based on query params and return the query result. """ + self.session.flush() model_cls = self.resource_type query = self.session.query(model_cls, count().over()) diff --git a/dokomoforms/handlers/auth.py b/dokomoforms/handlers/auth.py index 6dd123a8..398fef3e 100644 --- a/dokomoforms/handlers/auth.py +++ b/dokomoforms/handlers/auth.py @@ -17,7 +17,7 @@ from dokomoforms.options import options from dokomoforms.handlers.util import BaseHandler, authenticated_admin -from dokomoforms.models import User, Email +from dokomoforms.models import User, Administrator, Email class Login(BaseHandler): @@ -79,14 +79,21 @@ def post(self): .one() ) except NoResultFound: - _ = self.locale.translate - raise tornado.web.HTTPError( - 422, - reason=_( - 'There is no account associated with the e-mail' - ' address {}'.format(data['email']) - ), - ) + if data['email'] != options.admin_email: + _ = self.locale.translate + raise tornado.web.HTTPError( + 422, + reason=_( + 'There is no account associated with the e-mail' + ' address {}'.format(data['email']) + ), + ) + with self.session.begin(): + user = Administrator( + name=options.admin_name, + emails=[Email(address=options.admin_email)] + ) + self.session.add(user) cookie_options = { 'httponly': True, } diff --git a/dokomoforms/handlers/debug.py b/dokomoforms/handlers/debug.py index 7fc8fced..51b05f70 100644 --- a/dokomoforms/handlers/debug.py +++ b/dokomoforms/handlers/debug.py @@ -125,7 +125,7 @@ def get(self): if not revisit_online: raise tornado.web.HTTPError(502) if slow_mode: # pragma: no cover - sleep(2) + sleep(2.5) self.write(compressed_facilities) self.set_header('Content-Type', 'application/json') diff --git a/dokomoforms/handlers/demo.py b/dokomoforms/handlers/demo.py index 2e482bb6..d5cc680e 100644 --- a/dokomoforms/handlers/demo.py +++ b/dokomoforms/handlers/demo.py @@ -3,6 +3,7 @@ from sqlalchemy.orm.exc import NoResultFound +from dokomoforms.options import options import dokomoforms.models as models from dokomoforms.models import Administrator, Email from dokomoforms.handlers.util import BaseHandler @@ -193,9 +194,10 @@ def get(self): except NoResultFound: user = _create_demo_user(self.session) cookie_options = { - 'expires_days': None, 'httponly': True, } + if options.https: + cookie_options['secure'] = True self.set_secure_cookie('user', user.id, **cookie_options) self.redirect('/') diff --git a/dokomoforms/handlers/util.py b/dokomoforms/handlers/util.py index e52ffca0..b821fd07 100644 --- a/dokomoforms/handlers/util.py +++ b/dokomoforms/handlers/util.py @@ -113,7 +113,8 @@ def set_default_headers(self): "img-src 'self' *.tile.openstreetmap.org data: blob:;" "object-src 'self' blob:;" "media-src 'self' blob: mediastream:;" - "connect-src 'self' blob: *.revisit.global localhost:3000;" + "connect-src 'self' blob: revisit.global *.revisit.global" + " localhost:3000;" "default-src 'self';" ) diff --git a/dokomoforms/options.py b/dokomoforms/options.py index fe8b1366..ae0bfcc2 100644 --- a/dokomoforms/options.py +++ b/dokomoforms/options.py @@ -24,9 +24,11 @@ define('debug', default=False, help='whether to enable debug mode', type=bool) https_help = 'whether the application accepts https traffic' -define('https', help=https_help, type=bool) +define('https', default=True, help=https_help, type=bool) define('organization', help='the name of your organization') +define('admin_email', help='the e-mail address of the main administrator') +define('admin_name', help='the user name of the main administrator') persona_help = ( 'the URL for login verification. Do not change this without a good reason.' @@ -34,7 +36,7 @@ persona_url = 'https://verifier.login.persona.org/verify' define('persona_verification_url', default=persona_url, help=persona_help) -revisit_url = 'http://revisit.global/api/v0/facilities.json' +revisit_url = 'https://revisit.global/api/v0/facilities.json' revisit_help = ( 'the URL for facility data. Do not change this without a good reason.' ) diff --git a/dokomoforms/static/src/admin/js/account-overview.js b/dokomoforms/static/src/admin/js/account-overview.js index 4b1a0745..cd297f67 100644 --- a/dokomoforms/static/src/admin/js/account-overview.js +++ b/dokomoforms/static/src/admin/js/account-overview.js @@ -58,7 +58,7 @@ var AccountOverview = (function() { var submissions = data.submissions, $table = $('#recent-list table tbody'); submissions.forEach(function(sub) { - sub.submission_time = moment(sub.submission_time).format('MMM d, YYYY [at] HH:mm'); + sub.submission_time = moment(sub.submission_time).format('MMM D, YYYY [at] HH:mm'); sub.survey = { id: sub.survey_id, default_language: sub.survey_default_language @@ -70,7 +70,8 @@ var AccountOverview = (function() { function loadRecentSubmissions() { var limit = 5; - return $.getJSON('/api/v0/submissions?order_by=save_time:DESC&limit=' + limit + + return $.getJSON('/api/v0/submissions?user_id=' + window.CURRENT_USER_ID + + '&order_by=save_time:DESC&limit=' + limit + '&fields=id,submission_time,submitter_name,survey_title,survey_id,survey_default_language,answers'); } diff --git a/dokomoforms/static/src/admin/less/variables.less b/dokomoforms/static/src/admin/less/variables.less index 58373b7e..cce117ef 100755 --- a/dokomoforms/static/src/admin/less/variables.less +++ b/dokomoforms/static/src/admin/less/variables.less @@ -863,5 +863,7 @@ @page-header-border-color: @gray-lighter; //** Width of horizontal description list titles @dl-horizontal-offset: @component-offset-horizontal; +//** Point at which .dl-horizontal becomes horizontal +@dl-horizontal-breakpoint: @grid-float-breakpoint; //** Horizontal line color. @hr-border: @gray-lighter; diff --git a/dokomoforms/static/src/survey/js/__tests__/Application-tests.js b/dokomoforms/static/src/survey/js/__tests__/Application-tests.js new file mode 100644 index 00000000..f8e15b8d --- /dev/null +++ b/dokomoforms/static/src/survey/js/__tests__/Application-tests.js @@ -0,0 +1,22 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// jest.autoMockOff(); + +// a noop function useful for passing into components that require it. +var noop = () => {}; + +describe('Application', () => { + var Application; + + beforeEach(function() { + jest.dontMock('../Application.js'); + Application = require('../Application'); + }); + + it('does nothing', () => { + + }); + +}); diff --git a/dokomoforms/static/src/survey/js/api/__tests__/FacilityAPI-tests.js b/dokomoforms/static/src/survey/js/api/__tests__/FacilityAPI-tests.js new file mode 100644 index 00000000..56e48149 --- /dev/null +++ b/dokomoforms/static/src/survey/js/api/__tests__/FacilityAPI-tests.js @@ -0,0 +1,22 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// jest.autoMockOff(); + +// a noop function useful for passing into components that require it. +var noop = () => {}; + +describe('FacilityAPI', () => { + var FacilityAPI; + + beforeEach(function() { + jest.dontMock('../FacilityAPI.js'); + FacilityAPI = require('../FacilityAPI'); + }); + + it('does nothing', () => { + + }); + +}); diff --git a/dokomoforms/static/src/survey/js/api/__tests__/PhotoAPI-tests.js b/dokomoforms/static/src/survey/js/api/__tests__/PhotoAPI-tests.js new file mode 100644 index 00000000..78863da8 --- /dev/null +++ b/dokomoforms/static/src/survey/js/api/__tests__/PhotoAPI-tests.js @@ -0,0 +1,22 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// jest.autoMockOff(); + +// a noop function useful for passing into components that require it. +var noop = () => {}; + +describe('PhotoAPI', () => { + var PhotoAPI; + + beforeEach(function() { + jest.dontMock('../PhotoAPI.js'); + PhotoAPI = require('../PhotoAPI'); + }); + + it('does nothing', () => { + + }); + +}); diff --git a/dokomoforms/static/src/survey/js/components/Question.js b/dokomoforms/static/src/survey/js/components/Question.js index 287ffc41..52b7d7af 100644 --- a/dokomoforms/static/src/survey/js/components/Question.js +++ b/dokomoforms/static/src/survey/js/components/Question.js @@ -20,14 +20,14 @@ module.exports = React.createClass({ var answers = survey[this.props.question.id] || []; var length = answers.length === 0 ? 1 : answers.length; - return { - questionCount: length, - } + return { + questionCount: length + }; }, /* * Hack to force react to update child components - * Gets called by parent element through 'refs' when state of something changed + * Gets called by parent element through 'refs' when state of something changed * (usually localStorage) */ update: function() { @@ -35,7 +35,7 @@ module.exports = React.createClass({ var answers = survey[this.props.question.id] || []; var length = answers.length === 0 ? 1 : answers.length; this.setState({ - questionCount: length, + questionCount: length }); }, @@ -47,13 +47,12 @@ module.exports = React.createClass({ var answers = survey[this.props.question.id] || []; var length = answers.length; - console.log("Length:", length, "Count", this.state.questionCount); - if (answers[length] && answers[length].response_type - || length > 0 && length == this.state.questionCount) { + console.log('Length:', length, 'Count', this.state.questionCount); + if (answers[length] && answers[length].response_type || length > 0 && length === this.state.questionCount) { this.setState({ questionCount: this.state.questionCount + 1 - }) + }); } }, @@ -61,14 +60,13 @@ module.exports = React.createClass({ * Remove input and update localStorage */ removeInput: function(index) { - console.log("Remove", index); + console.log('Remove', index); if (!(this.state.questionCount > 1)) return; var survey = JSON.parse(localStorage[this.props.surveyID] || '{}'); var answers = survey[this.props.question.id] || []; - var length = answers.length === 0 ? 1 : answers.length; answers.splice(index, 1); survey[this.props.question.id] = answers; @@ -77,25 +75,24 @@ module.exports = React.createClass({ this.setState({ questionCount: this.state.questionCount - 1 - }) + }); //this.forceUpdate(); }, /* * Record new response into localStorage, response has been validated - * if this callback is fired + * if this callback is fired */ onInput: function(value, index) { - console.log("Hey", index, value); + console.log('Hey', index, value); var survey = JSON.parse(localStorage[this.props.surveyID] || '{}'); var answers = survey[this.props.question.id] || []; - var length = answers.length === 0 ? 1 : answers.length; //XXX Null value implies failed validation answers[index] = { - 'response': value, + 'response': value, 'response_type': 'answer' }; @@ -113,43 +110,44 @@ module.exports = React.createClass({ * @index: The location in the answer array in localStorage to search */ getAnswer: function(index) { - console.log("In:", index); + console.log('In:', index); var survey = JSON.parse(localStorage[this.props.surveyID] || '{}'); var answers = survey[this.props.question.id] || []; - var length = answers.length === 0 ? 1 : answers.length; console.log(answers, index); return answers[index] && answers[index].response || null; }, render: function() { - var children = Array.apply(null, {length: this.state.questionCount}) + var children = Array.apply(null, { + length: this.state.questionCount + }); var self = this; return ( - + {children.map(function(child, idx) { return ( - 1} /> - ) + ); })} {this.props.question.allow_multiple ? - : null + : null } - ) + ); } }); diff --git a/dokomoforms/static/src/survey/js/components/__tests__/Facility-tests.js b/dokomoforms/static/src/survey/js/components/__tests__/Facility-tests.js new file mode 100644 index 00000000..1d2f7af5 --- /dev/null +++ b/dokomoforms/static/src/survey/js/components/__tests__/Facility-tests.js @@ -0,0 +1,22 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// jest.autoMockOff(); + +// a noop function useful for passing into components that require it. +var noop = () => {}; + +describe('Facility', () => { + var Facility; + + beforeEach(function() { + jest.dontMock('../Facility.js'); + Facility = require('../Facility'); + }); + + it('does nothing', () => { + + }); + +}); diff --git a/dokomoforms/static/src/survey/js/components/__tests__/Footer-tests.js b/dokomoforms/static/src/survey/js/components/__tests__/Footer-tests.js new file mode 100644 index 00000000..65e668c9 --- /dev/null +++ b/dokomoforms/static/src/survey/js/components/__tests__/Footer-tests.js @@ -0,0 +1,22 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// jest.autoMockOff(); + +// a noop function useful for passing into components that require it. +var noop = () => {}; + +describe('Footer', () => { + var Footer; + + beforeEach(function() { + jest.dontMock('../Footer.js'); + Footer = require('../Footer'); + }); + + it('does nothing', () => { + + }); + +}); diff --git a/dokomoforms/static/src/survey/js/components/__tests__/Header-tests.js b/dokomoforms/static/src/survey/js/components/__tests__/Header-tests.js new file mode 100644 index 00000000..55333087 --- /dev/null +++ b/dokomoforms/static/src/survey/js/components/__tests__/Header-tests.js @@ -0,0 +1,22 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// jest.autoMockOff(); + +// a noop function useful for passing into components that require it. +var noop = () => {}; + +describe('Header', () => { + var Header; + + beforeEach(function() { + jest.dontMock('../Header.js'); + Header = require('../Header'); + }); + + it('does nothing', () => { + + }); + +}); diff --git a/dokomoforms/static/src/survey/js/components/__tests__/Loading-tests.js b/dokomoforms/static/src/survey/js/components/__tests__/Loading-tests.js new file mode 100644 index 00000000..02b191b1 --- /dev/null +++ b/dokomoforms/static/src/survey/js/components/__tests__/Loading-tests.js @@ -0,0 +1,22 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// jest.autoMockOff(); + +// a noop function useful for passing into components that require it. +var noop = () => {}; + +describe('Loading', () => { + var Loading; + + beforeEach(function() { + jest.dontMock('../Loading.js'); + Loading = require('../Loading'); + }); + + it('does nothing', () => { + + }); + +}); diff --git a/dokomoforms/static/src/survey/js/components/__tests__/Location-tests.js b/dokomoforms/static/src/survey/js/components/__tests__/Location-tests.js new file mode 100644 index 00000000..c4bed0ec --- /dev/null +++ b/dokomoforms/static/src/survey/js/components/__tests__/Location-tests.js @@ -0,0 +1,22 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// jest.autoMockOff(); + +// a noop function useful for passing into components that require it. +var noop = () => {}; + +describe('Location', () => { + var Location; + + beforeEach(function() { + jest.dontMock('../Location.js'); + Location = require('../Location'); + }); + + it('does nothing', () => { + + }); + +}); diff --git a/dokomoforms/static/src/survey/js/components/__tests__/MultipleChoice-tests.js b/dokomoforms/static/src/survey/js/components/__tests__/MultipleChoice-tests.js new file mode 100644 index 00000000..3d24373b --- /dev/null +++ b/dokomoforms/static/src/survey/js/components/__tests__/MultipleChoice-tests.js @@ -0,0 +1,22 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// jest.autoMockOff(); + +// a noop function useful for passing into components that require it. +var noop = () => {}; + +describe('MultipleChoice', () => { + var MultipleChoice; + + beforeEach(function() { + jest.dontMock('../MultipleChoice.js'); + MultipleChoice = require('../MultipleChoice'); + }); + + it('does nothing', () => { + + }); + +}); diff --git a/dokomoforms/static/src/survey/js/components/__tests__/Note-tests.js b/dokomoforms/static/src/survey/js/components/__tests__/Note-tests.js new file mode 100644 index 00000000..47382d7b --- /dev/null +++ b/dokomoforms/static/src/survey/js/components/__tests__/Note-tests.js @@ -0,0 +1,22 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// jest.autoMockOff(); + +// a noop function useful for passing into components that require it. +var noop = () => {}; + +describe('Note', () => { + var Note; + + beforeEach(function() { + jest.dontMock('../Note.js'); + Note = require('../Note'); + }); + + it('does nothing', () => { + + }); + +}); diff --git a/dokomoforms/static/src/survey/js/components/__tests__/Photo-tests.js b/dokomoforms/static/src/survey/js/components/__tests__/Photo-tests.js new file mode 100644 index 00000000..f8d11f26 --- /dev/null +++ b/dokomoforms/static/src/survey/js/components/__tests__/Photo-tests.js @@ -0,0 +1,22 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// jest.autoMockOff(); + +// a noop function useful for passing into components that require it. +var noop = () => {}; + +describe('Photo', () => { + var Photo; + + beforeEach(function() { + jest.dontMock('../Photo.js'); + Photo = require('../Photo'); + }); + + it('does nothing', () => { + + }); + +}); diff --git a/dokomoforms/static/src/survey/js/components/__tests__/Question-tests.js b/dokomoforms/static/src/survey/js/components/__tests__/Question-tests.js new file mode 100644 index 00000000..db1bc3e6 --- /dev/null +++ b/dokomoforms/static/src/survey/js/components/__tests__/Question-tests.js @@ -0,0 +1,22 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// jest.autoMockOff(); + +// a noop function useful for passing into components that require it. +var noop = () => {}; + +describe('Question', () => { + var Question; + + beforeEach(function() { + jest.dontMock('../Question.js'); + Question = require('../Question'); + }); + + it('does nothing', () => { + + }); + +}); diff --git a/dokomoforms/static/src/survey/js/components/__tests__/Splash-tests.js b/dokomoforms/static/src/survey/js/components/__tests__/Splash-tests.js new file mode 100644 index 00000000..4deab344 --- /dev/null +++ b/dokomoforms/static/src/survey/js/components/__tests__/Splash-tests.js @@ -0,0 +1,22 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// jest.autoMockOff(); + +// a noop function useful for passing into components that require it. +var noop = () => {}; + +describe('Splash', () => { + var Splash; + + beforeEach(function() { + jest.dontMock('../Splash.js'); + Splash = require('../Splash'); + }); + + it('does nothing', () => { + + }); + +}); diff --git a/dokomoforms/static/src/survey/js/components/__tests__/Submit-tests.js b/dokomoforms/static/src/survey/js/components/__tests__/Submit-tests.js new file mode 100644 index 00000000..d2cd8d32 --- /dev/null +++ b/dokomoforms/static/src/survey/js/components/__tests__/Submit-tests.js @@ -0,0 +1,22 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// jest.autoMockOff(); + +// a noop function useful for passing into components that require it. +var noop = () => {}; + +describe('Submit', () => { + var Submit; + + beforeEach(function() { + jest.dontMock('../Submit.js'); + Submit = require('../Submit'); + }); + + it('does nothing', () => { + + }); + +}); diff --git a/dokomoforms/static/src/survey/js/components/baseComponents/BigButton.js b/dokomoforms/static/src/survey/js/components/baseComponents/BigButton.js index 6ca9a2d5..33152afa 100644 --- a/dokomoforms/static/src/survey/js/components/baseComponents/BigButton.js +++ b/dokomoforms/static/src/survey/js/components/baseComponents/BigButton.js @@ -18,12 +18,12 @@ module.exports = React.createClass({ } return ( -
- -
- ); +
+ +
+ ); } }); diff --git a/dokomoforms/static/src/survey/js/components/baseComponents/Card.js b/dokomoforms/static/src/survey/js/components/baseComponents/Card.js index e44e108e..93d8a5a6 100644 --- a/dokomoforms/static/src/survey/js/components/baseComponents/Card.js +++ b/dokomoforms/static/src/survey/js/components/baseComponents/Card.js @@ -21,7 +21,7 @@ module.exports = React.createClass({
{this.props.messages.map(function(msg, idx) { return ( - {msg}
+ {msg}
); })}
diff --git a/dokomoforms/static/src/survey/js/components/baseComponents/DontKnow.js b/dokomoforms/static/src/survey/js/components/baseComponents/DontKnow.js index 99f75425..c259be89 100644 --- a/dokomoforms/static/src/survey/js/components/baseComponents/DontKnow.js +++ b/dokomoforms/static/src/survey/js/components/baseComponents/DontKnow.js @@ -9,17 +9,17 @@ var React = require('react'); module.exports = React.createClass({ render: function() { return ( -
- - -
- ); +
+ + +
+ ); } }); diff --git a/dokomoforms/static/src/survey/js/components/baseComponents/FacilityRadios.js b/dokomoforms/static/src/survey/js/components/baseComponents/FacilityRadios.js index 17f667e9..5bf3adb3 100644 --- a/dokomoforms/static/src/survey/js/components/baseComponents/FacilityRadios.js +++ b/dokomoforms/static/src/survey/js/components/baseComponents/FacilityRadios.js @@ -38,11 +38,10 @@ module.exports = React.createClass({ e.target.checked = checked; window.etarget = e.target; - //e.stopPropagation(); - //e.cancelBubble = true; - if (this.props.selectFunction) + if (this.props.selectFunction) { this.props.selectFunction(selected); + } this.setState({ selected: selected diff --git a/dokomoforms/static/src/survey/js/components/baseComponents/LittleButton.js b/dokomoforms/static/src/survey/js/components/baseComponents/LittleButton.js index f4a83078..dd9885fd 100644 --- a/dokomoforms/static/src/survey/js/components/baseComponents/LittleButton.js +++ b/dokomoforms/static/src/survey/js/components/baseComponents/LittleButton.js @@ -8,6 +8,7 @@ var React = require('react'); * @buttonFunction: What to do on click events * @text: Text of the button * @icon: Icon if any to show before button text + * @disabled: Whether or not the button should be disabled */ module.exports = React.createClass({ render: function() { @@ -15,16 +16,16 @@ module.exports = React.createClass({ var classes = 'btn '; classes += this.props.extraClasses || ''; return ( -
- -
- ); + {this.props.icon ? : null } + {this.props.text} + + + ); } }); diff --git a/dokomoforms/static/src/survey/js/components/baseComponents/Menu.js b/dokomoforms/static/src/survey/js/components/baseComponents/Menu.js index 2a68f068..f1b088ad 100644 --- a/dokomoforms/static/src/survey/js/components/baseComponents/Menu.js +++ b/dokomoforms/static/src/survey/js/components/baseComponents/Menu.js @@ -138,9 +138,6 @@ module.exports = React.createClass({ ); } - console.log('loggedIn: ', this.props.loggedIn); - console.log('hasFacilities: ', this.props.hasFacilities); - if (navigator.onLine) { if (this.props.hasFacilities) { reloadFacilities = ( diff --git a/dokomoforms/static/src/survey/js/components/baseComponents/PhotoField.js b/dokomoforms/static/src/survey/js/components/baseComponents/PhotoField.js index c365181c..05c9f54c 100644 --- a/dokomoforms/static/src/survey/js/components/baseComponents/PhotoField.js +++ b/dokomoforms/static/src/survey/js/components/baseComponents/PhotoField.js @@ -2,8 +2,9 @@ var React = require('react'), PhotoPreview = require('./PhotoPreview'); /* - * ResponseField component - * Main input field component, handles validation + * PhotoField component + * + * Displays a photo thumbnail. * * props: * @onInput: What to do on valid input @@ -19,26 +20,13 @@ module.exports = React.createClass({ }; }, - /* - * Validate the answer based on props.type - * - * @answer: The response to be validated - * - * TODO: implement photo validation, if necessary... - */ - validate: function(answer) { - return true; - }, - showPreview: function() { - console.log('showPreview'); this.setState({ showPreview: true }); }, hidePreview: function() { - console.log('hidePreview'); this.setState({ showPreview: false }); @@ -49,16 +37,6 @@ module.exports = React.createClass({ this.props.buttonFunction(this.props.index); }, - /* - * Handle change event, validates on every change - * fires props.onInput on validation success - * - * @event: Change event - */ - // onChange: function(event) { - - // }, - render: function() { var preview; if (this.state.showPreview) { diff --git a/dokomoforms/static/src/survey/js/components/baseComponents/PhotoPreview.js b/dokomoforms/static/src/survey/js/components/baseComponents/PhotoPreview.js index 294a1922..62075960 100644 --- a/dokomoforms/static/src/survey/js/components/baseComponents/PhotoPreview.js +++ b/dokomoforms/static/src/survey/js/components/baseComponents/PhotoPreview.js @@ -1,39 +1,17 @@ var React = require('react'); /* - * ResponseField component - * Main input field component, handles validation + * PhotoPreview component + * + * Displays a preview of the Photo * * props: - * @onInput: What to do on valid input - * @index: What index value to send on valid input (i.e position in array of fields) - * @showRetake: Show the 'retake' button - * @buttonFunction: What to do on 'X' click event, index value is bound to this function - * @initValue: Initial value for the input field + * @onClose: What to do when the close button is pressed + * @onDelete: What to do when the delete button is pressed + * @url: The URL of the photo to display */ module.exports = React.createClass({ - /* - * Validate the answer based on props.type - * - * @answer: The response to be validated - * - * TODO: implement photo validation, if necessary... - */ - validate: function(answer) { - return true; - }, - - /* - * Handle change event, validates on every change - * fires props.onInput on validation success - * - * @event: Change event - */ - // onChange: function(event) { - - // }, - render: function() { var divStyle = { backgroundImage: 'url(' + this.props.url + ')' diff --git a/dokomoforms/static/src/survey/js/components/baseComponents/ResponseField.js b/dokomoforms/static/src/survey/js/components/baseComponents/ResponseField.js index a38e09de..f57bcd0f 100644 --- a/dokomoforms/static/src/survey/js/components/baseComponents/ResponseField.js +++ b/dokomoforms/static/src/survey/js/components/baseComponents/ResponseField.js @@ -1,4 +1,5 @@ -var React = require('react'); +var React = require('react'), + moment = require('moment'); /* * ResponseField component @@ -16,14 +17,13 @@ var React = require('react'); */ module.exports = React.createClass({ getInitialState: function() { - return { - }; + return {}; }, // Determine the input field type based on props.type getResponseType: function() { var type = this.props.type; - switch(type) { + switch (type) { case 'integer': case 'decimal': return 'number'; @@ -43,7 +43,7 @@ module.exports = React.createClass({ // Determine the input field step based on props.type getResponseStep: function() { var type = this.props.type; - switch(type) { + switch (type) { case 'decimal': return 'any'; case 'timestamp': @@ -61,9 +61,8 @@ module.exports = React.createClass({ validate: function(answer) { var type = this.props.type; var logic = this.props.logic; - console.log('Enforcing: ', logic); var val = null; - switch(type) { + switch (type) { case 'integer': val = parseInt(answer); if (isNaN(val)) { @@ -109,10 +108,11 @@ module.exports = React.createClass({ var month = ('0' + (resp.getMonth() + 1)).slice(-2); var year = resp.getFullYear(); val = answer; //XXX Keep format? - if(isNaN(year) || isNaN(month) || isNaN(day)) { + if (isNaN(year) || isNaN(month) || isNaN(day)) { val = null; } + if (logic && logic.min && !isNaN((new Date(logic.min)).getDate())) { if (resp < new Date(logic.min)) { val = null; @@ -127,12 +127,15 @@ module.exports = React.createClass({ break; case 'timestamp': + //TODO: enforce min/max + val = moment(answer).toDate(); + console.log('val: ', val); + break; case 'time': - //TODO: enforce default: - if (answer) { - val = answer; - } + if (answer) { + val = answer; + } } return val; @@ -161,24 +164,24 @@ module.exports = React.createClass({ render: function() { return ( -
- + + {this.props.showMinus ? + - {this.props.showMinus ? - - - : null} - -
- ); + className='icon icon-close question__minus'> +
+ : null} + + + ); } }); diff --git a/dokomoforms/static/src/survey/js/components/baseComponents/Title.js b/dokomoforms/static/src/survey/js/components/baseComponents/Title.js index daa80894..a3a0af6d 100644 --- a/dokomoforms/static/src/survey/js/components/baseComponents/Title.js +++ b/dokomoforms/static/src/survey/js/components/baseComponents/Title.js @@ -9,11 +9,11 @@ var React = require('react'); */ module.exports = React.createClass({ render: function() { - return ( -
-

{this.props.title}

-

{this.props.message}

-
- ) + return ( +
+

{this.props.title}

+

{this.props.message}

+
+ ); } }); diff --git a/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/BigButton-tests.js b/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/BigButton-tests.js new file mode 100644 index 00000000..d8026141 --- /dev/null +++ b/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/BigButton-tests.js @@ -0,0 +1,56 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// a noop function useful for passing into components that require it. +var noop = () => {}; + +describe('BigButton', () => { + var BigButton; + + beforeEach(function() { + jest.dontMock('../BigButton.js'); + BigButton = require('../BigButton'); + }); + + it('renders displaying its text property', () => { + // Render a BigButton in the document + var button = TestUtils.renderIntoDocument( + + ); + + // Get the rendered element + var buttonNode = ReactDOM.findDOMNode(button); + + // Verify that its text matches Press Me! + expect(buttonNode.textContent).toEqual('Press Me!'); + }); + + it('calls the buttonFunction property when pressed', () => { + var callback = jest.genMockFunction(); + + // Render a BigButton in the document + var button = TestUtils.renderIntoDocument( + + ); + + // Simulate click on button + TestUtils.Simulate.click( + TestUtils.findRenderedDOMComponentWithTag(button, 'button') + ); + + // Verify that the callback was called once. + expect(callback.mock.calls.length).toEqual(1); + }); + + it('should add a class associated with a type property', () => { + // Render a BigButton in the document + var button = TestUtils.renderIntoDocument( + + ); + + // makes sure the type property has added the class + TestUtils.findRenderedDOMComponentWithClass(button, 'btn-testing'); + }); + +}); diff --git a/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/Card-tests.js b/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/Card-tests.js new file mode 100644 index 00000000..d0c85145 --- /dev/null +++ b/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/Card-tests.js @@ -0,0 +1,50 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// a noop function useful for passing into components that require it. +var noop = () => {}; + +describe('Card', () => { + var Card; + + beforeEach(function() { + jest.dontMock('../Card.js'); + Card = require('../Card'); + }); + + it('should render a sinlge message', () => { + var message = ['hello']; + + var card = TestUtils.renderIntoDocument( + + ); + + // makes sure there is a single span tag (i.e. message) + TestUtils.findRenderedDOMComponentWithTag(card, 'span'); + }); + + it('should render multiple messages', () => { + var messages = ['hello', 'hi', 'hola']; + + var card = TestUtils.renderIntoDocument( + + ); + + // makes sure there is a single span tag (i.e. message) + var spans = TestUtils.scryRenderedDOMComponentsWithTag(card, 'span'); + + expect(spans.length).toEqual(3); + }); + + it('should add a class associated with a type property', () => { + var message = ['hello']; + + var card = TestUtils.renderIntoDocument( + + ); + + // makes sure there the type prop has added the class + TestUtils.findRenderedDOMComponentWithClass(card, 'message-secondary'); + }); +}); diff --git a/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/DontKnow-tests.js b/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/DontKnow-tests.js new file mode 100644 index 00000000..5356daeb --- /dev/null +++ b/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/DontKnow-tests.js @@ -0,0 +1,56 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// a noop function useful for passing into components that require it. +var noop = () => {}; + +describe('DontKnow', () => { + var DontKnow; + + beforeEach(function() { + jest.dontMock('../DontKnow.js'); + DontKnow = require('../DontKnow'); + }); + + it('calls the checkBoxFunction property when pressed', () => { + var callback = jest.genMockFunction(); + + // Render a DontKnow in the document + var dontKnow = TestUtils.renderIntoDocument( + + ); + + // Simulate click on button + TestUtils.Simulate.click( + TestUtils.findRenderedDOMComponentWithTag(dontKnow, 'input') + ); + + // Verify that the callback was called once. + expect(callback.mock.calls.length).toEqual(1); + }); + + it('is not checked by default', () => { + // Render a DontKnow in the document + var dontKnow = TestUtils.renderIntoDocument( + + ); + + // Get the rendered element + var dontKnowNode = TestUtils.findRenderedDOMComponentWithTag(dontKnow, 'input'); + + expect(dontKnowNode.defaultChecked).toEqual(false); + }); + + it('is checked by default when checked property is present', () => { + // Render a DontKnow in the document + var dontKnow = TestUtils.renderIntoDocument( + + ); + + // Get the rendered element + var dontKnowNode = TestUtils.findRenderedDOMComponentWithTag(dontKnow, 'input'); + + expect(dontKnowNode.defaultChecked).toEqual(true); + }); +}); diff --git a/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/FacilityRadios-tests.js b/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/FacilityRadios-tests.js new file mode 100644 index 00000000..257911cc --- /dev/null +++ b/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/FacilityRadios-tests.js @@ -0,0 +1,192 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// a noop function useful for passing into components that require it. +var noop = () => {}, + facilities = [{ + 'name': 'water point', + 'coordinates': [6.456968, 12.91716], + '_version': 0, + 'properties': { + 'sector': 'water', + 'type': 'type unavailable', + 'grid_power': null, + 'improved_water_supply': null, + 'improved_sanitation': null, + 'visits': 0, + 'photoUrls': [] + }, + 'identifiers': [], + 'updatedAt': '2014-12-04T21:54:37.177Z', + 'createdAt': '2014-12-04T21:54:37.177Z', + 'active': true, + 'href': 'http://staging.revisit.global/api/v0/facilities/5480d81d2ecfcf69084425f3.json', + 'uuid': '5480d81d2ecfcf69084425f3' + }, { + 'name': 'water point', + 'coordinates': [6.459725, 12.91686], + '_version': 0, + 'properties': { + 'sector': 'water', + 'type': 'type unavailable', + 'grid_power': null, + 'improved_water_supply': null, + 'improved_sanitation': null, + 'visits': 0, + 'photoUrls': [] + }, + 'identifiers': [], + 'updatedAt': '2014-12-04T21:54:37.177Z', + 'createdAt': '2014-12-04T21:54:37.177Z', + 'active': true, + 'href': 'http://staging.revisit.global/api/v0/facilities/5480d81d2ecfcf69084425b0.json', + 'uuid': '5480d81d2ecfcf69084425b0', + 'distance': 100 + }, { + 'name': 'water point', + 'coordinates': [6.454339, 12.92059], + '_version': 0, + 'properties': { + 'sector': 'water', + 'type': 'type unavailable', + 'grid_power': null, + 'improved_water_supply': null, + 'improved_sanitation': null, + 'visits': 0, + 'photoUrls': [] + }, + 'identifiers': [], + 'updatedAt': '2014-12-04T21:54:37.177Z', + 'createdAt': '2014-12-04T21:54:37.177Z', + 'active': true, + 'href': 'http://staging.revisit.global/api/v0/facilities/5480d81d2ecfcf69084425ff.json', + 'uuid': '5480d81d2ecfcf69084425ff' + }, { + 'name': 'water point', + 'coordinates': [6.456571, 12.92105], + '_version': 0, + 'properties': { + 'sector': 'water', + 'type': 'type unavailable', + 'grid_power': null, + 'improved_water_supply': null, + 'improved_sanitation': null, + 'visits': 0, + 'photoUrls': [] + }, + 'identifiers': [], + 'updatedAt': '2014-12-04T21:54:37.177Z', + 'createdAt': '2014-12-04T21:54:37.177Z', + 'active': true, + 'href': 'http://staging.revisit.global/api/v0/facilities/5480d81d2ecfcf690844261a.json', + 'uuid': '5480d81d2ecfcf690844261a' + }, { + 'name': 'water point', + 'coordinates': [6.458883, 12.92059], + '_version': 0, + 'properties': { + 'sector': 'water', + 'type': 'type unavailable', + 'grid_power': null, + 'improved_water_supply': null, + 'improved_sanitation': null, + 'visits': 0, + 'photoUrls': [] + }, + 'identifiers': [], + 'updatedAt': '2014-12-04T21:54:37.177Z', + 'createdAt': '2014-12-04T21:54:37.177Z', + 'active': true, + 'href': 'http://staging.revisit.global/api/v0/facilities/5480d81d2ecfcf6908442625.json', + 'uuid': '5480d81d2ecfcf6908442625' + }, { + 'name': 'water point', + 'coordinates': [6.459097, 12.92074], + '_version': 0, + 'properties': { + 'sector': 'water', + 'type': 'type unavailable', + 'grid_power': null, + 'improved_water_supply': null, + 'improved_sanitation': null, + 'visits': 0, + 'photoUrls': [] + }, + 'identifiers': [], + 'updatedAt': '2014-12-04T21:54:37.177Z', + 'createdAt': '2014-12-04T21:54:37.177Z', + 'active': true, + 'href': 'http://staging.revisit.global/api/v0/facilities/5480d81d2ecfcf6908442607.json', + 'uuid': '5480d81d2ecfcf6908442607' + }]; + +describe('FacilityRadios', () => { + var FacilityRadios, callback; + + beforeEach(function() { + jest.dontMock('../FacilityRadios.js'); + FacilityRadios = require('../FacilityRadios'); + callback = jest.genMockFunction(); + }); + + it('renders no facilities message if no facilities passed', () => { + var FacilityRadiosInstance = TestUtils.renderIntoDocument( + + ); + + var message = TestUtils.findRenderedDOMComponentWithClass(FacilityRadiosInstance, 'content-padded'); + + expect(message.textContent).toEqual('No nearby facilities located.'); + }); + + it('renders facilities list', () => { + var len = facilities.length; + + var FacilityRadiosInstance = TestUtils.renderIntoDocument( + + ); + + var facs = TestUtils.scryRenderedDOMComponentsWithClass(FacilityRadiosInstance, 'question__radio'); + + expect(facs.length).toEqual(len); + }); + + it('calls selectFunction prop on facility click', () => { + var FacilityRadiosInstance = TestUtils.renderIntoDocument( + + ); + + var facs = TestUtils.scryRenderedDOMComponentsWithTag(FacilityRadiosInstance, 'input'); + + + TestUtils.Simulate.click(facs[0]); + + expect(callback).toBeCalled(); + }); + + it('selects a facility when facility radio clicked', () => { + var FacilityRadiosInstance = TestUtils.renderIntoDocument( + + ); + + var facs = TestUtils.scryRenderedDOMComponentsWithTag(FacilityRadiosInstance, 'input'); + + expect(facs[0].getAttribute('checked')).toBeNull(); + + TestUtils.Simulate.click(facs[0], { + target: facs[0] + }); + + facs = TestUtils.scryRenderedDOMComponentsWithTag(FacilityRadiosInstance, 'input'); + + expect(facs[0].getAttribute('checked')).toBeDefined(); + + TestUtils.Simulate.click(facs[0], { + target: facs[0] + }); + + expect(facs[0].getAttribute('checked')).toBeNull(); + }); + +}); diff --git a/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/LittleButton-tests.js b/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/LittleButton-tests.js new file mode 100644 index 00000000..de4193d9 --- /dev/null +++ b/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/LittleButton-tests.js @@ -0,0 +1,73 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// a noop function useful for passing into components that require it. +var noop = () => {}; + +describe('LittleButton', () => { + var LittleButton; + + beforeEach(function() { + jest.dontMock('../LittleButton.js'); + LittleButton = require('../LittleButton'); + }); + + it('renders displaying its text property', () => { + // Render a LittleButton in the document + var button = TestUtils.renderIntoDocument( + + ); + + // Get the rendered element + var buttonNode = ReactDOM.findDOMNode(button); + + // Verify that its text matches Press Me! + expect(buttonNode.textContent).toEqual('Press Me!'); + }); + + it('calls the buttonFunction property when pressed', () => { + var callback = jest.genMockFunction(); + + // Render a LittleButton in the document + var button = TestUtils.renderIntoDocument( + + ); + + // Simulate click on button + TestUtils.Simulate.click( + TestUtils.findRenderedDOMComponentWithTag(button, 'button') + ); + + // Verify that the callback was called once. + expect(callback.mock.calls.length).toEqual(1); + }); + + it('is disabled when disabled property is set', () => { + var callback = jest.genMockFunction(); + + // Render a LittleButton in the document + var button = TestUtils.renderIntoDocument( + + ); + + // Simulate click on button + TestUtils.Simulate.click( + TestUtils.findRenderedDOMComponentWithTag(button, 'button') + ); + + // Verify that the callback was called once. + expect(callback.mock.calls.length).toEqual(0); + }); + + it('includes an icon if an icon property is set', () => { + // Render a LittleButton in the document + var button = TestUtils.renderIntoDocument( + + ); + + // makes sure there is a span tag (i.e. icon) rendered within the button + TestUtils.findRenderedDOMComponentWithTag(button, 'span'); + }); + +}); diff --git a/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/Menu-tests.js b/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/Menu-tests.js new file mode 100644 index 00000000..dc14e31e --- /dev/null +++ b/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/Menu-tests.js @@ -0,0 +1,42 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// jest.autoMockOff(); + +// a noop function useful for passing into components that require it. +var noop = () => {}; + +describe('Menu', () => { + var Menu; + + beforeEach(function() { + jest.dontMock('../Menu.js'); + Menu = require('../Menu'); + }); + + it('does nothing', () => { + + }); + + // it('renders log out/in and revisit reload only when online', () => { + + // }); + + // it('renders log out when user is logged in', () => { + + // }); + + // it('renders log in when user is not logged in', () => { + + // }); + + // it('calls logout function when logout is pressed', () => { + + // }); + + // it('calls login function when login is pressed', () => { + + // }); + +}); diff --git a/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/Message-tests.js b/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/Message-tests.js new file mode 100644 index 00000000..299bf401 --- /dev/null +++ b/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/Message-tests.js @@ -0,0 +1,39 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// a noop function useful for passing into components that require it. +var noop = () => {}; + +describe('Message', () => { + var Message; + + beforeEach(function() { + jest.dontMock('../Message.js'); + Message = require('../Message'); + }); + + it('should render a sinlge message', () => { + var message = TestUtils.renderIntoDocument( + + ); + + // Get the rendered element + var messageNode = ReactDOM.findDOMNode(message); + + // Verify that its text matches Press Me! + expect(messageNode.textContent).toEqual('Hello'); + }); + + + it('should add classes associated with the classes property', () => { + var classes = 'testing'; + + var message = TestUtils.renderIntoDocument( + + ); + + // makes sure there the type prop has added the class + TestUtils.findRenderedDOMComponentWithClass(message, 'testing'); + }); +}); diff --git a/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/PhotoField-tests.js b/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/PhotoField-tests.js new file mode 100644 index 00000000..9095c4a6 --- /dev/null +++ b/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/PhotoField-tests.js @@ -0,0 +1,112 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// a noop function useful for passing into components that require it. +var noop = () => {}; + +describe('PhotoField', () => { + var PhotoField, PhotoPreview; + + beforeEach(function() { + jest.dontMock('../PhotoField.js'); + PhotoField = require('../PhotoField'); + jest.dontMock('../PhotoPreview.js'); + PhotoPreview = require('../PhotoPreview'); + }); + + it('calls showPreview on thumbnail click', () => { + + // Hackity hack hack hack + // https://github.com/facebook/jest/issues/207 + PhotoField.prototype.__reactAutoBindMap.showPreview = jest.genMockFunction(); + + var Photo = TestUtils.renderIntoDocument( + + ); + + TestUtils.Simulate.click( + TestUtils.findRenderedDOMComponentWithClass(Photo, 'photo_container') + ); + + expect(PhotoField.prototype.__reactAutoBindMap.showPreview).toBeCalled(); + }); + + it('renders image tag when initValue passed', () => { + + var Photo = TestUtils.renderIntoDocument( + + ); + + TestUtils.findRenderedDOMComponentWithTag(Photo, 'img'); + + }); + + it('renders PhotoPreview on thumbnail click', () => { + + var Photo = TestUtils.renderIntoDocument( + + ); + + TestUtils.Simulate.click( + TestUtils.findRenderedDOMComponentWithClass(Photo, 'photo_container') + ); + + TestUtils.findRenderedComponentWithType(Photo, PhotoPreview); + }); + + + + it('hides PhotoPreview on thumbnail click', () => { + + // render photo field instance + var Photo = TestUtils.renderIntoDocument( + + ); + + // click thumbnail + TestUtils.Simulate.click( + TestUtils.findRenderedDOMComponentWithClass(Photo, 'photo_container') + ); + + // get reference to rendered PhotoPreview instance + var PhotoPreviewInstance = TestUtils.findRenderedComponentWithType(Photo, PhotoPreview); + + // click close button on preview + TestUtils.Simulate.click( + TestUtils.findRenderedDOMComponentWithClass(PhotoPreviewInstance, 'btn-photo-close') + ); + + // check that there are no longer any rendered PhotoPreview instances + var PhotoPreviewInstances = TestUtils.scryRenderedComponentsWithType(Photo, PhotoPreview); + + expect(PhotoPreviewInstances.length).toEqual(0); + }); + + it('calls onDelete when delete button pressed in preview', () => { + + var callback = jest.genMockFunction(); + + // render photo field instance + var Photo = TestUtils.renderIntoDocument( + + ); + + // click thumbnail + TestUtils.Simulate.click( + TestUtils.findRenderedDOMComponentWithClass(Photo, 'photo_container') + ); + + // get reference to rendered PhotoPreview instance + var PhotoPreviewInstance = TestUtils.findRenderedComponentWithType(Photo, PhotoPreview); + + // click close button on preview + TestUtils.Simulate.click( + TestUtils.findRenderedDOMComponentWithClass(PhotoPreviewInstance, 'btn-photo-delete') + ); + + // check that the delete method was called (which in turn calls buttonFunction prop) + expect(callback).toBeCalled(); + }); + +}); diff --git a/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/PhotoPreview-tests.js b/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/PhotoPreview-tests.js new file mode 100644 index 00000000..881cc5b3 --- /dev/null +++ b/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/PhotoPreview-tests.js @@ -0,0 +1,49 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// a noop function useful for passing into components that require it. +var noop = () => {}; + +describe('PhotoPreview', () => { + var PhotoPreview; + + beforeEach(function() { + jest.dontMock('../PhotoPreview.js'); + PhotoPreview = require('../PhotoPreview'); + }); + + it('calls onClose property when close button is pressed', () => { + var callback = jest.genMockFunction(); + + // Render a DontKnow in the document + var preview = TestUtils.renderIntoDocument( + + ); + + // Simulate click on button + TestUtils.Simulate.click( + TestUtils.findRenderedDOMComponentWithClass(preview, 'btn-photo-close') + ); + + // Verify that the callback was called once. + expect(callback.mock.calls.length).toEqual(1); + }); + + it('calls onDelete property when close button is pressed', () => { + var callback = jest.genMockFunction(); + + // Render a DontKnow in the document + var preview = TestUtils.renderIntoDocument( + + ); + + // Simulate click on button + TestUtils.Simulate.click( + TestUtils.findRenderedDOMComponentWithClass(preview, 'btn-photo-delete') + ); + + // Verify that the callback was called once. + expect(callback.mock.calls.length).toEqual(1); + }); +}); diff --git a/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/ResponseField-tests.js b/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/ResponseField-tests.js new file mode 100644 index 00000000..bb135bde --- /dev/null +++ b/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/ResponseField-tests.js @@ -0,0 +1,451 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// a noop function useful for passing into components that require it. +var noop = () => {}; + +describe('ResponseField', () => { + var ResponseField, callback, setCustomValidity; + + beforeEach(function() { + jest.dontMock('../ResponseField.js'); + ResponseField = require('../ResponseField'); + callback = jest.genMockFunction(); + setCustomValidity = jest.genMockFunction(); + }); + + it('renders a number input if prop type is integer', () => { + + + var ResponseFieldInstance = TestUtils.renderIntoDocument( + + ); + + var input = TestUtils.findRenderedDOMComponentWithTag(ResponseFieldInstance, 'input'); + + expect(input.getAttribute('type')).toEqual('number'); + + // simulate input + TestUtils.Simulate.change( + input, + { + target: { + setCustomValidity: setCustomValidity, + value: 50 + } + } + ); + + expect(setCustomValidity).toBeCalled(); + }); + + it('renders a number input if prop type is decimal', () => { + + + var ResponseFieldInstance = TestUtils.renderIntoDocument( + + ); + + var input = TestUtils.findRenderedDOMComponentWithTag(ResponseFieldInstance, 'input'); + + expect(input.getAttribute('type')).toEqual('number'); + + TestUtils.Simulate.change( + input, + { + target: { + setCustomValidity: setCustomValidity, + value: 50 + } + } + ); + + expect(setCustomValidity).toBeCalled(); + }); + + it('renders a datetime-local input if prop type is timestamp', () => { + var ResponseFieldInstance = TestUtils.renderIntoDocument( + + ); + + var input = TestUtils.findRenderedDOMComponentWithTag(ResponseFieldInstance, 'input'); + + expect(input.getAttribute('type')).toEqual('datetime-local'); + + TestUtils.Simulate.change( + input, + { + target: { + setCustomValidity: setCustomValidity, + value: 1238592847 + } + } + ); + + expect(setCustomValidity).toBeCalled(); + }); + + it('renders a time input if prop type is time', () => { + var ResponseFieldInstance = TestUtils.renderIntoDocument( + + ); + + var input = TestUtils.findRenderedDOMComponentWithTag(ResponseFieldInstance, 'input'); + + TestUtils.Simulate.change( + input, + { + target: { + setCustomValidity: setCustomValidity, + value: 1238592847 + } + } + ); + + expect(input.getAttribute('type')).toEqual('time'); + }); + + it('renders a date input if prop type is date', () => { + var ResponseFieldInstance = TestUtils.renderIntoDocument( + + ); + + var input = TestUtils.findRenderedDOMComponentWithTag(ResponseFieldInstance, 'input'); + + expect(input.getAttribute('type')).toEqual('date'); + + TestUtils.Simulate.change( + input, + { + target: { + setCustomValidity: setCustomValidity, + value: '2013-01-01T00:00:00.000Z' + } + } + ); + + expect(setCustomValidity).toBeCalled(); + }); + + it('renders an email input if prop type is email', () => { + var ResponseFieldInstance = TestUtils.renderIntoDocument( + + ); + + var input = TestUtils.findRenderedDOMComponentWithTag(ResponseFieldInstance, 'input'); + + expect(input.getAttribute('type')).toEqual('email'); + }); + + it('renders a text input if prop type is not specified', () => { + var ResponseFieldInstance = TestUtils.renderIntoDocument( + + ); + + var input = TestUtils.findRenderedDOMComponentWithTag(ResponseFieldInstance, 'input'); + + expect(input.getAttribute('type')).toEqual('text'); + + TestUtils.Simulate.change( + input, + { + target: { + setCustomValidity: setCustomValidity, + value: 'onetwothree' + } + } + ); + + expect(setCustomValidity).toBeCalled(); + }); + + it('renders a minus if prop showMinus is true', () => { + var ResponseFieldInstance = TestUtils.renderIntoDocument( + + ); + + // check that minus is rendered + TestUtils.findRenderedDOMComponentWithClass(ResponseFieldInstance, 'question__minus'); + }); + + it('calls buttonFunction when minus is clicked', () => { + var ResponseFieldInstance = TestUtils.renderIntoDocument( + + ); + + // click on minus + TestUtils.Simulate.click( + TestUtils.findRenderedDOMComponentWithClass(ResponseFieldInstance, 'question__minus') + ); + + // check that the delete method was called (which in turn calls buttonFunction prop) + expect(callback).toBeCalled(); + }); + + it('calls onInput prop via onChange when input changes', () => { + var ResponseFieldInstance = TestUtils.renderIntoDocument( + + ); + + var input = TestUtils.findRenderedDOMComponentWithTag(ResponseFieldInstance, 'input'); + + // simulate changing input + TestUtils.Simulate.change( + input, + { + target: { + setCustomValidity: noop, + value: 'a' + } + } + ); + + // check that the delete method was called (which in turn calls buttonFunction prop) + expect(callback).toBeCalled(); + }); + + it('validates non-numeric values in integer field', () => { + + + var ResponseFieldInstance = TestUtils.renderIntoDocument( + + ); + + var input = TestUtils.findRenderedDOMComponentWithTag(ResponseFieldInstance, 'input'); + + // simulate changing input + TestUtils.Simulate.change( + input, + { + target: { + setCustomValidity: setCustomValidity, + value: 'a' + } + } + ); + + // called once with empty string to clear, then again with invalid message + expect(setCustomValidity.mock.calls[0][0]).toEqual(''); + expect(setCustomValidity.mock.calls[1][0]).toEqual('Invalid field.'); + }); + + it('validates min and max values in integer field', () => { + + var logic = { + min: 10, + max: 20 + }; + + var ResponseFieldInstance = TestUtils.renderIntoDocument( + + ); + + var input = TestUtils.findRenderedDOMComponentWithTag(ResponseFieldInstance, 'input'); + + // simulate correct input + TestUtils.Simulate.change( + input, + { + target: { + setCustomValidity: setCustomValidity, + value: 15 + } + } + ); + + // should be only called once if validation passes + expect(setCustomValidity.mock.calls.length).toEqual(1); + + // simulate bad min input + TestUtils.Simulate.change( + input, + { + target: { + setCustomValidity: setCustomValidity, + value: 5 + } + } + ); + + // should be called two more times when validation passes + expect(setCustomValidity.mock.calls.length).toEqual(3); + + // simulate bad max input + TestUtils.Simulate.change( + input, + { + target: { + setCustomValidity: setCustomValidity, + value: 25 + } + } + ); + + // should be called two more times when validation passes + expect(setCustomValidity.mock.calls.length).toEqual(5); + }); + + it('validates non-numeric values in decimal field', () => { + + + var ResponseFieldInstance = TestUtils.renderIntoDocument( + + ); + + var input = TestUtils.findRenderedDOMComponentWithTag(ResponseFieldInstance, 'input'); + + // simulate changing input + TestUtils.Simulate.change( + input, + { + target: { + setCustomValidity: setCustomValidity, + value: 'a' + } + } + ); + + // called once with empty string to clear, then again with invalid message + expect(setCustomValidity.mock.calls[0][0]).toEqual(''); + expect(setCustomValidity.mock.calls[1][0]).toEqual('Invalid field.'); + }); + + it('validates min and max values in decimal field', () => { + + var logic = { + min: 10, + max: 20 + }; + + var ResponseFieldInstance = TestUtils.renderIntoDocument( + + ); + + var input = TestUtils.findRenderedDOMComponentWithTag(ResponseFieldInstance, 'input'); + + // simulate correct input + TestUtils.Simulate.change( + input, + { + target: { + setCustomValidity: setCustomValidity, + value: 15 + } + } + ); + + // should be only called once if validation passes + expect(setCustomValidity.mock.calls.length).toEqual(1); + + // simulate bad min input + TestUtils.Simulate.change( + input, + { + target: { + setCustomValidity: setCustomValidity, + value: 5 + } + } + ); + + // should be called two more times when validation passes + expect(setCustomValidity.mock.calls.length).toEqual(3); + + // simulate bad max input + TestUtils.Simulate.change( + input, + { + target: { + setCustomValidity: setCustomValidity, + value: 25 + } + } + ); + + // should be called two more times when validation passes + expect(setCustomValidity.mock.calls.length).toEqual(5); + }); + + it('validates non-parseable date values in date field', () => { + + + var ResponseFieldInstance = TestUtils.renderIntoDocument( + + ); + + var input = TestUtils.findRenderedDOMComponentWithTag(ResponseFieldInstance, 'input'); + + // simulate changing input + TestUtils.Simulate.change( + input, + { + target: { + setCustomValidity: setCustomValidity, + value: 'gibberish' + } + } + ); + + // called once with empty string to clear, then again with invalid message + expect(setCustomValidity.mock.calls[0][0]).toEqual(''); + expect(setCustomValidity.mock.calls[1][0]).toEqual('Invalid field.'); + }); + + it('validates min and max values in date field', () => { + + var logic = { + min: '2014-01-01', + max: '2015-01-01' + }; + + var ResponseFieldInstance = TestUtils.renderIntoDocument( + + ); + + var input = TestUtils.findRenderedDOMComponentWithTag(ResponseFieldInstance, 'input'); + + // simulate correct input + TestUtils.Simulate.change( + input, + { + target: { + setCustomValidity: setCustomValidity, + value: '2014-06-01' + } + } + ); + + // should be only called once if validation passes + expect(setCustomValidity.mock.calls.length).toEqual(1); + + // simulate bad min input + TestUtils.Simulate.change( + input, + { + target: { + setCustomValidity: setCustomValidity, + value: '2013-01-01' + } + } + ); + + // should be called two more times when validation doesn't pass + expect(setCustomValidity.mock.calls.length).toEqual(3); + + // simulate bad max input + TestUtils.Simulate.change( + input, + { + target: { + setCustomValidity: setCustomValidity, + value: '2016-01-01' + } + } + ); + + // should be called two more times when validation doesn't pass + expect(setCustomValidity.mock.calls.length).toEqual(5); + }); + +}); diff --git a/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/ResponseFields-tests.js b/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/ResponseFields-tests.js new file mode 100644 index 00000000..922d19ae --- /dev/null +++ b/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/ResponseFields-tests.js @@ -0,0 +1,27 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// a noop function useful for passing into components that require it. +var noop = () => {}; + +describe('ResponseFields', () => { + var ResponseFields, ResponseField; + + beforeEach(function() { + jest.dontMock('../ResponseFields.js'); + jest.dontMock('../ResponseField.js'); + ResponseFields = require('../ResponseFields'); + ResponseField = require('../ResponseField'); + }); + + it('renders multiple ResponseField components based on childCount prop', () => { + var ResponseFieldsInstance = TestUtils.renderIntoDocument( + + ); + + var ResponseFieldInstances = TestUtils.scryRenderedComponentsWithType(ResponseFieldsInstance, ResponseField); + + expect(ResponseFieldInstances.length).toEqual(5); + }); +}); diff --git a/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/Select-tests.js b/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/Select-tests.js new file mode 100644 index 00000000..5102d64b --- /dev/null +++ b/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/Select-tests.js @@ -0,0 +1,139 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// a noop function useful for passing into components that require it. +var noop = () => {}; + +describe('Select', () => { + var Select, ResponseField, callback; + + beforeEach(function() { + jest.dontMock('../Select.js'); + jest.dontMock('../ResponseField.js'); + Select = require('../Select'); + ResponseField = require('../ResponseField'); + callback = jest.genMockFunction(); + }); + + it('renders a single select dropdown', () => { + var choices = [{ + value: 0, + text: 'Zero' + }]; + + var SelectInstance = TestUtils.renderIntoDocument( + + ); + + var select = TestUtils.findRenderedDOMComponentWithTag(SelectInstance, 'select'); + + expect(select.getAttribute('multiple')).toBeDefined(); + }); + + it('renders other option', () => { + var choices = [{ + value: 0, + text: 'Zero' + }]; + + var SelectInstance = TestUtils.renderIntoDocument( + + ); + + var options = TestUtils.scryRenderedDOMComponentsWithTag(SelectInstance, 'option'); + var select = TestUtils.findRenderedDOMComponentWithTag(SelectInstance, 'select'); + + TestUtils.Simulate.change( + select, + { + target: { + selectedOptions: [ + options[2] + ] + } + } + ); + + // make sure the response field is rendered + var ResponseFieldInstance = TestUtils.findRenderedComponentWithType(SelectInstance, ResponseField); + }); + + it('calls onSelect prop when changed', () => { + var choices = [{ + value: 0, + text: 'Zero' + }]; + + var SelectInstance = TestUtils.renderIntoDocument( + + ); + + var select = TestUtils.findRenderedDOMComponentWithTag(SelectInstance, 'select'); + + expect(select.value).toEqual('0'); + }); +}); diff --git a/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/Title-tests.js b/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/Title-tests.js new file mode 100644 index 00000000..75e0bdb9 --- /dev/null +++ b/dokomoforms/static/src/survey/js/components/baseComponents/__tests__/Title-tests.js @@ -0,0 +1,28 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// a noop function useful for passing into components that require it. +var noop = () => {}; + +describe('Title', () => { + var Title; + + beforeEach(function() { + jest.dontMock('../Title.js'); + Title = require('../Title'); + }); + + it('should render a title with a message', () => { + var title = TestUtils.renderIntoDocument( + + ); + + var titleNode = TestUtils.findRenderedDOMComponentWithTag(title, 'h3'); + var messageNode = TestUtils.findRenderedDOMComponentWithTag(title, 'p'); + + // Verify that its text matches Press Me! + expect(titleNode.textContent).toEqual('Hello'); + expect(messageNode.textContent).toEqual('Hola'); + }); +}); diff --git a/dokomoforms/static/src/survey/js/services/__tests__/auth-tests.js b/dokomoforms/static/src/survey/js/services/__tests__/auth-tests.js new file mode 100644 index 00000000..055be0af --- /dev/null +++ b/dokomoforms/static/src/survey/js/services/__tests__/auth-tests.js @@ -0,0 +1,22 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// jest.autoMockOff(); + +// a noop function useful for passing into components that require it. +var noop = () => {}; + +describe('auth', () => { + var auth; + + beforeEach(function() { + jest.dontMock('../auth.js'); + auth = require('../auth'); + }); + + it('does nothing', () => { + + }); + +}); diff --git a/dokomoforms/static/src/survey/js/services/__tests__/location-tests.js b/dokomoforms/static/src/survey/js/services/__tests__/location-tests.js new file mode 100644 index 00000000..bb4685a5 --- /dev/null +++ b/dokomoforms/static/src/survey/js/services/__tests__/location-tests.js @@ -0,0 +1,22 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// jest.autoMockOff(); + +// a noop function useful for passing into components that require it. +var noop = () => {}; + +describe('location', () => { + var location; + + beforeEach(function() { + jest.dontMock('../location.js'); + location = require('../location'); + }); + + it('does nothing', () => { + + }); + +}); diff --git a/dokomoforms/static/src/survey/js/services/__tests__/utils-tests.js b/dokomoforms/static/src/survey/js/services/__tests__/utils-tests.js new file mode 100644 index 00000000..adde87a4 --- /dev/null +++ b/dokomoforms/static/src/survey/js/services/__tests__/utils-tests.js @@ -0,0 +1,22 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; + +// jest.autoMockOff(); + +// a noop function useful for passing into components that require it. +var noop = () => {}; + +describe('utils', () => { + var utils; + + beforeEach(function() { + jest.dontMock('../utils.js'); + utils = require('../utils'); + }); + + it('does nothing', () => { + + }); + +}); diff --git a/gulpfile.js b/gulpfile.js index 576d4326..9f38491e 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -53,6 +53,8 @@ var path = { // SURVEY_CSS_BUILD: survey_dist_path + '/css/survey/*.css', SURVEY_JS_VENDOR_SRC: [ + node_modules_path + '/es5-shim/es5-shim.js', + node_modules_path + '/es5-shim/es5-sham.js', node_modules_path + '/jquery/dist/jquery.js', node_modules_path + '/bootstrap/dist/js/bootstrap.js', node_modules_path + '/lodash-compat/index.js', diff --git a/installer.sh b/installer.sh new file mode 100755 index 00000000..4a33e879 --- /dev/null +++ b/installer.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env sh +# Dokomo Forms installer for version 0.2.2 +set -e + +# Do you have docker installed? +if ! command -v docker > /dev/null; then + printf "You need to install docker\n" + exit 1 +fi + +# Do you have sed installed? +if ! command -v sed > /dev/null; then + printf "You need to install sed\n" + exit 1 +fi + +# Do you have openssl installed? +if ! command -v openssl > /dev/null; then + printf "You need to install openssl\n" + exit 1 +fi + +# Do you have curl installed? +if command -v curl > /dev/null; then + CURL=curl +else + CURL="docker run tutum/curl curl" +fi + +# Do you have docker-compose installed? +if command -v docker-compose > /dev/null; then + DOCKER_COMPOSE=docker-compose +else + DOCKER_COMPOSE=./docker-compose + if ! [ -f ./docker-compose ]; then + printf "========================================\n" + printf " Installing docker-compose in this \n" + printf " directory \n" + printf "========================================\n" + $CURL -o docker-compose -L https://github.com/docker/compose/releases/download/1.5.2/run.sh + chmod +x docker-compose + ./docker-compose -v + fi +fi + +# This installer needs root access for various reasons... +# Copy the logic from the letsencrypt-auto script +# https://github.com/letsencrypt/letsencrypt/blob/8c6e242b13ac818c0a94e3dceee81ab4b3816a12/letsencrypt-auto#L34-L68 +if test "`id -u`" -ne "0" ; then + if command -v sudo 1>/dev/null 2>&1; then + SUDO=sudo + else + echo \"sudo\" is not available, will use \"su\" for installation steps... + su_sudo() { + args="" + while [ $# -ne 0 ]; do + args="$args'$(printf "%s" "$1" | sed -e "s/'/'\"'\"'/g")' " + shift + done + su root -c "$args" + } + SUDO=su_sudo + fi +else + SUDO= +fi + +# Ask for domain(s) +printf "========================================\n" +printf " Please enter your domain name(s) (space\n" +printf " separated) \n" +printf " \n" +printf " Hint: for www include both \n" +printf " >>> www.your.domain your.domain \n" +printf " \n" +printf " For a subdomain just give \n" +printf " >>> subdomain.your.domain \n" +printf "========================================\n" +printf "Domain(s):\n>>> " +read DOMAINS +LETSENCRYPT_DIR=$(echo $DOMAINS | cut -d' ' -f1) +DOMAIN_ARGS=$(echo $DOMAINS | sed -r s/\([^\ ]+\)/-d\ \\1/g) + +# Run letsencrypt +printf "========================================\n" +printf " Installing SSL certificate. Make sure \n" +printf " you have set up the DNS records for \n" +printf " your domain to point to this machine. \n" +printf "========================================\n" +$SUDO docker run -it --rm -p 443:443 -p 80:80 --name letsencrypt \ + -v "/etc/letsencrypt:/etc/letsencrypt:Z" \ + -v "/var/lib/letsencrypt:/var/lib/letsencrypt:Z" \ + -v "/var/log:/var/log:Z" \ + quay.io/letsencrypt/letsencrypt:latest auth $DOMAIN_ARGS + +# Run openssl dhparam +printf "========================================\n" +printf " Generating Diffie-Hellman parameters \n" +printf " using OpenSSL (2048 bit prime) \n" +printf "========================================\n" +$SUDO openssl dhparam -out /etc/letsencrypt/live/$LETSENCRYPT_DIR/dhparam.pem 2048 + +# Download the configuration files +printf "========================================\n" +printf " Downloading configuration files \n" +printf "========================================\n" +$CURL -O https://raw.githubusercontent.com/SEL-Columbia/dokomoforms/v0.2.2/docker-compose.yml +$CURL -O https://raw.githubusercontent.com/SEL-Columbia/dokomoforms/v0.2.2/nginx.conf + +# Edit the configuration files +printf "========================================\n" +printf " Generating final configuration \n" +printf "========================================\n" + +touch local_config.py + +sed -i s/www.example.com/$LETSENCRYPT_DIR/g nginx.conf + +printf "What is the name of your organization? \n" +printf "This will be displayed as part of the \n" +printf "title of the website. \n" +printf "Organization name:\n>>> " +read ORGANIZATION +printf "organization = '$ORGANIZATION'\n" >> local_config.py + +printf "\n" +printf "Please enter an e-mail address for the \n" +printf "administrator. This will be the only \n" +printf "account that can log in at first. \n" +printf "Administrator e-mail address:\n>>> " +read ADMIN_EMAIL +printf "admin_email = '$ADMIN_EMAIL'\n" >> local_config.py +DEFAULT_NAME=$(echo $ADMIN_EMAIL | cut -d'@' -f1) + +printf "\n" +printf "Please enter a user name for the \n" +printf "administrator. Leave this field blank to\n" +printf "use this user name: \n" +printf "$DEFAULT_NAME\n" +printf "Administrator user name:\n>>> " +read ADMIN_NAME +printf "admin_name = '${ADMIN_NAME:-$DEFAULT_NAME}'\n" >> local_config.py + +# Let's Encrypt auto-renew (for now this is a cron job). +printf "========================================\n" +printf " Adding monthly cron job to renew SSL \n" +printf " certificate. \n" +printf "========================================\n" +CRON_CMD="mkdir -p /tmp/letsencrypt-auto && docker run -it --rm --name letsencrypt -v /etc/letsencrypt:/etc/letsencrypt:Z -v /var/lib/letsencrypt:/var/lib/letsencrypt:Z -v /tmp/letsencrypt-auto:/tmp/letsencrypt-auto:Z -v /var/log/letsencrypt:/var/log/letsencrypt:Z quay.io/letsencrypt/letsencrypt --renew certonly -a webroot -w /tmp/letsencrypt-auto $DOMAIN_ARGS && docker restart $USER_nginx_1" +CRON_JOB="0 0 1 * * $CRON_CMD" +( crontab -l | fgrep -i -v "$CRON_CMD" ; echo "$CRON_JOB" ) | crontab - + +# Bring up Dokomo Forms +printf "========================================\n" +printf " Starting Dokomo Forms. \n" +printf " \n" +printf " You can view the status of the \n" +printf " containers by running: \n" +printf " $DOCKER_COMPOSE ps\n" +printf "========================================\n" +if [ -f /etc/redhat-release ] ; then + chcon -Rt svirt_sandbox_file_t . +fi +$DOCKER_COMPOSE up -d diff --git a/local_config.py.example b/local_config.py.example index d87ad0dc..6776c4ec 100644 --- a/local_config.py.example +++ b/local_config.py.example @@ -14,6 +14,23 @@ import os ####################################### +# admin_email = "Initial administrator's e-mail address" + +# Default: 'admin@example.com' +# Set admin_email to the e-mail address of the administrator of this +# installation of Dokomo Forms. This will be the only account that can log in +# at first. + +####################################### + +# admin_name = "Initial administrator's user name" + +# Default: 'admin' +# Set admin_name to the user name of the administrator of this +# installation of Dokomo Forms. + +####################################### + # demo = True # Default: False @@ -62,6 +79,11 @@ import os # Default: True # Set https = False to put the webapp into HTTP mode (can not serve HTTPS # traffic). +# +# WARNING: +# Some of the features of the application will only work if served over HTTPS. +# Do not set https = False unless you want to develop the application without +# bothering with a dummy self-signed certificate. ####################################### @@ -76,7 +98,7 @@ import os # revisit_url = 'some URL' -# Default: 'http://revisit.global/api/v0/facilities.json' +# Default: 'https://revisit.global/api/v0/facilities.json' # Set revisit_url to tell the application which Revisit server to use as a # registry for facility data. By default it will use the official # revisit.global server. You may wish to use staging.revisit.global instead diff --git a/nginx.conf b/nginx.conf index fd79954a..d8b0cbc8 100644 --- a/nginx.conf +++ b/nginx.conf @@ -8,6 +8,33 @@ http { server { listen 80; listen [::]:80; + server_name www.example.com; + rewrite ^/(.*) https://$server_name/$1 permanent; + } + server { + server_name www.example.com; + listen 443 ssl http2; + listen [::]:443 ssl http2; + + ssl_dhparam /etc/letsencrypt/live/www.example.com/dhparam.pem; + ssl_certificate /etc/letsencrypt/live/www.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/www.example.com/privkey.pem; + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:50m; + ssl_session_tickets off; + ssl_protocols TLSv1.1 TLSv1.2; + ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!3DES:!MD5:!PSK'; + ssl_prefer_server_ciphers on; + add_header Strict-Transport-Security max-age=15768000; + ssl_stapling on; + ssl_stapling_verify on; + ssl_trusted_certificate /etc/letsencrypt/live/www.example.com/chain.pem; + + location '/.well-known/acme-challenge' { + default_type "text/plain"; + root /tmp/letsencrypt-auto; + } + location / { proxy_pass_header Server; proxy_set_header Host $http_host; diff --git a/package.json b/package.json index d9424dab..85d162e8 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "backbone": "^1.2.3", "bootstrap": "^3.3.5", "datatables": "https://github.com/DataTables/DataTables/archive/1.10.9.tar.gz", + "es5-shim": "^4.3.1", "highcharts-release": "^4.1.8", "jquery": "^2.1.4", "leaflet": "^0.7.5", @@ -22,9 +23,14 @@ "pouchdb-upsert": "^1.1.1", "ratchet": "https://github.com/twbs/ratchet/archive/v2.0.2.tar.gz", "react": "^0.14.2", + "react-dom": "~0.14.2", "screenfull": "^2.0.0" }, "devDependencies": { + "babel-core": "^6.1.20", + "babel-jest": "^6.0.1", + "babel-preset-es2015": "^6.1.18", + "babel-preset-react": "^6.1.18", "babelify": "^7.2.0", "browserify": "^12.0.1", "browserify-shim": "^3.8.7", @@ -53,6 +59,7 @@ "mocha": "^2.2.4", "mocha-istanbul": "^0.2.0", "node-underscorify": "0.0.14", + "react-addons-test-utils": "~0.14.2", "reactify": "^1.1.1", "should": "^7.1.1", "supertest": "^1.1.0", @@ -78,36 +85,45 @@ "screenfull": "global:screenfull" }, "jest": { - "testDirectoryName": "tests/js", + "scriptPreprocessor": "<rootDir>/node_modules/babel-jest", + "testFileExtensions": [ + "js" + ], + "moduleFileExtensions": [ + "js" + ], + "unmockedModulePathPatterns": [ + "<rootDir>/node_modules/" + ], "collectCoverage": true, "collectCoverageOnlyFrom": { - "dokomoforms/static/Application.js": true, - "dokomoforms/static/FacilityAPI.js": true, - "dokomoforms/static/PhotoAPI.js": true, - "dokomoforms/static/persona.js": true, - "dokomoforms/static/visualizations.js": true, - "dokomoforms/static/components/Facility.js": true, - "dokomoforms/static/components/Footer.js": true, - "dokomoforms/static/components/Header.js": true, - "dokomoforms/static/components/Location.js": true, - "dokomoforms/static/components/MultipleChoice.js": true, - "dokomoforms/static/components/Note.js": true, - "dokomoforms/static/components/Photo.js": true, - "dokomoforms/static/components/Question.js": true, - "dokomoforms/static/components/Splash.js": true, - "dokomoforms/static/components/Submit.js": true, - "dokomoforms/static/components/baseComponents/BigButton.js": true, - "dokomoforms/static/components/baseComponents/Card.js": true, - "dokomoforms/static/components/baseComponents/DontKnow.js": true, - "dokomoforms/static/components/baseComponents/FacilityRadios.js": true, - "dokomoforms/static/components/baseComponents/LittleButton.js": true, - "dokomoforms/static/components/baseComponents/Menu.js": true, - "dokomoforms/static/components/baseComponents/Message.js": true, - "dokomoforms/static/components/baseComponents/PhotoField.js": true, - "dokomoforms/static/components/baseComponents/ResponseField.js": true, - "dokomoforms/static/components/baseComponents/ResponseFields.js": true, - "dokomoforms/static/components/baseComponents/Select.js": true, - "dokomoforms/static/components/baseComponents/Title.js": true + "dokomoforms/static/src/survey/js/Application.js": true, + "dokomoforms/static/src/survey/js/api/FacilityAPI.js": true, + "dokomoforms/static/src/survey/js/api/PhotoAPI.js": true, + "dokomoforms/static/src/common/js/persona.js": true, + "dokomoforms/static/src/survey/js/components/Facility.js": true, + "dokomoforms/static/src/survey/js/components/Footer.js": true, + "dokomoforms/static/src/survey/js/components/Header.js": true, + "dokomoforms/static/src/survey/js/components/Location.js": true, + "dokomoforms/static/src/survey/js/components/MultipleChoice.js": true, + "dokomoforms/static/src/survey/js/components/Note.js": true, + "dokomoforms/static/src/survey/js/components/Photo.js": true, + "dokomoforms/static/src/survey/js/components/Question.js": true, + "dokomoforms/static/src/survey/js/components/Splash.js": true, + "dokomoforms/static/src/survey/js/components/Submit.js": true, + "dokomoforms/static/src/survey/js/components/baseComponents/BigButton.js": true, + "dokomoforms/static/src/survey/js/components/baseComponents/LittleButton.js": true, + "dokomoforms/static/src/survey/js/components/baseComponents/Card.js": true, + "dokomoforms/static/src/survey/js/components/baseComponents/Message.js": true, + "dokomoforms/static/src/survey/js/components/baseComponents/DontKnow.js": true, + "dokomoforms/static/src/survey/js/components/baseComponents/Menu.js": true, + "dokomoforms/static/src/survey/js/components/baseComponents/FacilityRadios.js": true, + "dokomoforms/static/src/survey/js/components/baseComponents/PhotoField.js": true, + "dokomoforms/static/src/survey/js/components/baseComponents/PhotoPreview.js": true, + "dokomoforms/static/src/survey/js/components/baseComponents/ResponseField.js": true, + "dokomoforms/static/src/survey/js/components/baseComponents/ResponseFields.js": true, + "dokomoforms/static/src/survey/js/components/baseComponents/Select.js": true, + "dokomoforms/static/src/survey/js/components/baseComponents/Title.js": true } }, "scripts": { @@ -126,7 +142,7 @@ "Data", "Collection" ], - "author": "Abdi Dahir Viktor Roytman Jon Wohl", + "author": "Abdi Dahir, Viktor Roytman, & Jonathan Wohl", "license": "ISC", "bugs": { "url": "https://github.com/SEL-Columbia/dokomoforms/issues" diff --git a/tests/js/README.md b/tests/js/README.md new file mode 100644 index 00000000..d349ed51 --- /dev/null +++ b/tests/js/README.md @@ -0,0 +1,15 @@ +# JavaScript Tests + +### Unit Tests + +The frontend javascript is set up for unit testing using the [Jest](http://facebook.github.io/jest/) testing framework. At the time of this writing, only a small portion of the javascript code base was adequately unit tested, but each React component has a stub test file which can be populated with tests. + +As per Jest's recommended best practice, tests for components are located in an **__tests__** directory which sits along side the components themselves. + +#### Running the Unit Tests + +Assuming the npm dependencies have been installed, run `$ npm test`. + +### Functional Tests + +Frontend functionality is covered using Selenium tests. These tests can be found in **/tests/python/test_selenium.py**. For more info about running these tests, see [the wiki](https://github.com/SEL-Columbia/dokomoforms/wiki/Local-Development-Environment#running-tests). diff --git a/tests/js/example-test.js b/tests/js/example-test.js deleted file mode 100644 index 46234faf..00000000 --- a/tests/js/example-test.js +++ /dev/null @@ -1,6 +0,0 @@ -//TODO: deleteme! -describe('a', function() { - it('is', function() { - expect(3).toBe(4); - }); -}); diff --git a/tests/python/test_selenium.py b/tests/python/test_selenium.py index 65eba54c..440c5c8b 100644 --- a/tests/python/test_selenium.py +++ b/tests/python/test_selenium.py @@ -18,19 +18,22 @@ from bs4 import BeautifulSoup import dateutil.parser -from dateutil.tz import tzlocal +from dateutil.tz import tzoffset from passlib.hash import bcrypt_sha256 +import pytz + from selenium import webdriver from selenium.common.exceptions import ( - TimeoutException, ElementNotVisibleException + TimeoutException, ElementNotVisibleException, WebDriverException ) from selenium.webdriver import ActionChains from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys from selenium.webdriver.support.wait import WebDriverWait from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.support.select import Select import tests.python.util from tests.python.util import setUpModule, tearDownModule @@ -116,6 +119,27 @@ def start_remote_webdriver(self): finally: signal.alarm(0) + @classmethod + def setUpClass(cls): + if not SAUCE_CONNECT: + cls.browser = 'firefox' + cls.platform = 'Linux' + return + cls.username = os.environ.get('SAUCE_USERNAME', SAUCE_USERNAME) + cls.access_key = os.environ.get('SAUCE_ACCESS_KEY', SAUCE_ACCESS_KEY) + cls.browser_config = os.environ.get('BROWSER', DEFAULT_BROWSER) + values = (cls.username, cls.access_key, cls.browser_config) + if any(v is None for v in values): + cls.fail( + cls, + 'You have specified SAUCE_CONNECT=true but you have not' + ' specified SAUCE_USERNAME, SAUCE_ACCESS_KEY,' + ' and DEFAULT_BROWSER' + ) + configs = cls.browser_config.split(':') + cls.browser, cls.version, cls.platform, *cls.other = configs + super().setUpClass() + def setUp(self): try: urlopen(base) @@ -136,29 +160,15 @@ def setUp(self): if not SAUCE_CONNECT: self.drv = webdriver.Firefox(firefox_profile=f_profile) - self.browser = 'firefox' - self.platform = 'Linux' return - self.username = os.environ.get('SAUCE_USERNAME', SAUCE_USERNAME) - self.access_key = os.environ.get('SAUCE_ACCESS_KEY', SAUCE_ACCESS_KEY) - browser_config = os.environ.get('BROWSER', DEFAULT_BROWSER) - values = (self.username, self.access_key, browser_config) - if any(v is None for v in values): - self.fail( - 'You have specified SAUCE_CONNECT=true but you have not' - ' specified SAUCE_USERNAME, SAUCE_ACCESS_KEY,' - ' and DEFAULT_BROWSER' - ) - configs = browser_config.split(':') - self.browser, self.version, self.platform, *other = configs caps = { 'browserName': self.browser, 'platform': self.platform, 'idleTimeout': 1000, # maximum } if self.browser in {'android', 'iPhone'}: - caps['deviceName'] = other[0] + caps['deviceName'] = self.other[0] caps['device-orientation'] = 'portrait' if self.version: caps['version'] = self.version @@ -169,13 +179,13 @@ def setUp(self): caps['tags'] = [os.environ['TRAVIS_PYTHON_VERSION'], 'CI'] caps['name'] = ' -- '.join(( os.environ['TRAVIS_BUILD_NUMBER'], - browser_config, + self.browser_config, '{}.{}'.format(self.__class__.__name__, self._testMethodName) )) else: caps['name'] = ' -- '.join(( 'Manual run', - browser_config, + self.browser_config, '{}.{}'.format(self.__class__.__name__, self._testMethodName) )) hub_url = '{}:{}@localhost:4445'.format(self.username, self.access_key) @@ -208,6 +218,8 @@ def setUp(self): time.sleep(10) if self.browser != 'android': self.drv.set_page_load_timeout(180) + if self.browser not in {'android', 'iPhone'}: + self.drv.set_window_size(1280, 1280) self.drv.set_script_timeout(180) def _set_sauce_status(self): @@ -300,6 +312,9 @@ def toggle_online(self, browser=True, revisit=True): urlopen('http://localhost:9999/debug/toggle_facilities') self.sleep(1) + def input_field(self): + return self.drv.find_element_by_tag_name('input') + @property def control_key(self): is_osx = self.platform.startswith('OS X') @@ -313,6 +328,16 @@ def enter_date(self, element, year, month, day): self.sleep() element.send_keys(year) self.sleep() + elif self.browser == 'android': + self.drv.execute_script( + 'arguments[0].value = "{}-{}-{}"'.format(year, month, day), + element + ) + self.drv.execute_script( + "var event = new Event('input', {bubbles: true}); " + "arguments[0].dispatchEvent(event);", + element + ) else: element.send_keys('/'.join((year, month, day))) @@ -324,27 +349,135 @@ def enter_time(self, element, hour, minute, am_pm): self.sleep() element.send_keys(am_pm) self.sleep() + elif self.browser == 'android': + if hour == '12' and am_pm == 'AM': + adjusted_hour = '0' + elif hour != '12' and am_pm == 'PM': + adjusted_hour = str(int(hour) + 12) + else: + adjusted_hour = hour + + h = adjusted_hour + adjusted_hour = '0' + h if len(h) == 1 else h + adjusted_minute = '0' + minute if len(minute) == 1 else minute + + self.drv.execute_script( + 'arguments[0].value = "{}:{}"'.format( + adjusted_hour, adjusted_minute + ), + element + ) + self.drv.execute_script( + "var event = new Event('input', {bubbles: true}); " + "arguments[0].dispatchEvent(event);", + element + ) else: element.send_keys('{}:{} {}'.format(hour, minute, am_pm)) - def enter_timestamp(self, element, year, month, day, hour, minute, am_pm): + def enter_timestamp(self, element, timestamp): + modified_time_str = self.drv.execute_script( + 'return moment("{}").format();'.format(timestamp) + ) + modified_time = dateutil.parser.parse(modified_time_str) + unmodified_time = ( + dateutil.parser.parse(timestamp).replace(tzinfo=pytz.utc) + ) + utc_offset = (modified_time - unmodified_time).seconds + utc_offset_time = ( + dateutil.parser. + parse(timestamp). + replace(tzinfo=tzoffset(None, utc_offset)) + ) + utc_time = utc_offset_time.astimezone(pytz.utc) + + if self.browser == 'android': + self.drv.execute_script( + 'arguments[0].value = "{}"'.format( + utc_time.isoformat()[:-6] + ), + element + ) + self.sleep() + self.drv.execute_script( + "var event = new Event('input', {bubbles: true}); " + "arguments[0].dispatchEvent(event);", + element + ) + self.sleep() + self.click(element) + if 'NATIVE_APP' in self.drv.window_handles: + self.drv.switch_to.window('NATIVE_APP') + buttons = self.drv.find_elements_by_tag_name('Button') + if buttons: + old_android = self.version < StrictVersion('5.0') + cancel = 0 if old_android else 1 + self.click(buttons[cancel]) + self.drv.switch_to.window('WEBVIEW_0') + self.drv.execute_script( + "var event = new Event('input', {bubbles: true}); " + "arguments[0].dispatchEvent(event);", + self.input_field() + ) + self.sleep() + return if self.browser == 'chrome': raise unittest.SkipTest('Selenium + Chrome + timestamp == 😢') - self.enter_date(element, year, month, day) + self.enter_date( + element, + utc_time.strftime('%Y'), + utc_time.strftime('%m'), + utc_time.strftime('%d'), + ) if self.browser == 'chrome': # For some reason this doesn't work... element.send_keys(Keys.TAB) else: element.send_keys(' ') - self.enter_time(element, hour, minute, am_pm) + self.enter_time( + element, + utc_time.strftime('%I'), + utc_time.strftime('%M'), + utc_time.strftime('%p'), + ) - def enter_timestamp_temporary(self, e, y, mo, d, h, mi, am_pm): - """Use this temporarily until we use moment.js.""" - if self.browser == 'chrome': - raise unittest.SkipTest('Selenium + Chrome + timestamp == 😢') - e.send_keys('{}-{}-{}T{}:{}:00Z'.format( - y, mo, d, h if am_pm.lower().startswith('a') else h + 12, mi - )) + def select_by_index(self, element, index): + """Select an element from a <select> <option> list. + + For some reason on Android selecting an option works but raises an + exception.... + So... ingnore the exception. + """ + andr = self.browser == 'android' + if andr and self.version < StrictVersion('5.0'): + self.click(self.drv.find_element_by_tag_name('select')) + self.drv.switch_to.window('NATIVE_APP') + self.click( + self.drv.find_elements_by_tag_name('TextView')[index + 2] + ) + self.drv.switch_to.window('WEBVIEW_0') + return + try: + element.select_by_index(index) + except WebDriverException: + if andr: + pass + else: + raise + + def select_multiple(self, *indices): + if self.browser == 'android' and self.version < StrictVersion('5.0'): + self.click(self.drv.find_element_by_tag_name('select')) + self.drv.switch_to.window('NATIVE_APP') + options = self.drv.find_elements_by_tag_name('CheckedTextView') + for index in indices: + self.click(options[index]) + self.click(self.drv.find_elements_by_tag_name('Button')[-1]) + self.drv.switch_to.window('WEBVIEW_0') + return + options = self.drv.find_elements_by_tag_name('option') + for index in indices: + self.click(options[index]) class TestAuth(DriverTest): @@ -369,6 +502,14 @@ def test_login(self): class AdminTest(DriverTest): + @classmethod + def setUpClass(cls): + super().setUpClass() + if cls.browser in {'android', 'iPhone'}: + cls.skipTest( + cls, 'The admin interface has no mobile design (yet).' + ) + def setUp(self): super().setUp() self.get('/debug/login/test_creator@fixtures.com') @@ -519,6 +660,7 @@ def test_update_settings(self): self.sleep() self.click(self.drv.find_element_by_class_name('nav-settings')) + self.sleep() self.wait_for_element('user-name') name_field = self.drv.find_element_by_id('user-name') self.click(name_field) @@ -779,9 +921,12 @@ def test_manage_renders_properly(self): .filter_by(id='b0816b52-204f-41d4-aaf0-ac6ae2970923') .scalar() ) + earliest_local = self.drv.execute_script( + 'return moment("{}").format();'.format(earliest_utc) + ) self.assertEqual( dateutil.parser.parse(stats[1].text).date(), - earliest_utc.astimezone(tzlocal()).date() + dateutil.parser.parse(earliest_local).date() ) self.assertEqual( dateutil.parser.parse(stats[2].text).date(), @@ -1034,12 +1179,11 @@ def test_change_language(self): self.click(self.drv.find_element_by_class_name('nav-settings')) self.wait_for_element('user-preferred-lang') self.click(self.drv.find_element_by_css_selector( - '#user-preferred-lang option:nth-of-type(2)')) + '#user-preferred-lang option:nth-of-type(2)' + )) save_btn = self.drv.find_element_by_class_name('btn-save-user') - self.sleep() - save_btn.click() - self.sleep() + self.click(save_btn) # refresh the page self.drv.refresh() @@ -1171,9 +1315,12 @@ def test_view_data_renders_properly(self): .filter_by(id='b0816b52-204f-41d4-aaf0-ac6ae2970923') .scalar() ) + earliest_local = self.drv.execute_script( + 'return moment("{}").format();'.format(earliest_utc) + ) self.assertEqual( dateutil.parser.parse(stats[1].text).date(), - earliest_utc.astimezone(tzlocal()).date() + dateutil.parser.parse(earliest_local).date() ) self.assertEqual( dateutil.parser.parse(stats[2].text).date(), @@ -1194,7 +1341,7 @@ def get_single_node_survey_id(self, question_type): def get_last_submission(self, survey_id): self.sleep() - return ( + result = ( self.session .query(Submission) .filter_by(survey_id=survey_id) @@ -1202,6 +1349,25 @@ def get_last_submission(self, survey_id): .limit(1) .one() ) + andr = self.browser == 'android' + if andr and self.version < StrictVersion('5.0'): + # For some reason the Android 4.4 tests report the wrong time + # for a while... This gets around that problem. + try: + existing = self.existing + except AttributeError: + self.existing = result + else: + result = ( + self.session + .query(Submission) + .filter(Submission.id != existing.id) + .filter(Submission.survey_id == survey_id) + .order_by(Submission.save_time.desc()) + .limit(1) + .one() + ) + return result @report_success_status def test_login(self): @@ -1267,22 +1433,15 @@ def test_change_language(self): # login as enumerator self.get('/debug/login/test_enumerator@fixtures.com') + self.sleep() self.get('/enumerate/{}'.format(survey_id)) self.wait_for_element('menu', By.CLASS_NAME) self.click(self.drv.find_element_by_class_name('menu')) - self.click( - self.drv - .find_element_by_class_name('language_select') - ) - - self.assertEqual( - len(self.drv.find_elements_by_css_selector( - '.language_select option')), 3) - - self.click(self.drv.find_elements_by_css_selector( - '.language_select option')[1]) + lang = Select(self.drv.find_element_by_class_name('language_select')) + self.assertEqual(len(lang.options), 3) + self.select_by_index(lang, 1) self.sleep() @@ -1472,6 +1631,9 @@ def test_single_text_question(self): @report_success_status def test_single_photo_question(self): + if self.browser == 'android' and self.version < StrictVersion('5.0'): + # http://caniuse.com/#feat=stream + self.skipTest('getUserMedia does not work in the <5 AOSP browser') survey_id = self.get_single_node_survey_id('photo') existing_submission = self.get_last_submission(survey_id) @@ -1558,7 +1720,7 @@ def test_single_timestamp_question(self): self.click(self.drv.find_element_by_class_name('navigate-right')) self.enter_timestamp( self.drv.find_element_by_tag_name('input'), - '2015', '08', '11', '3', '33', 'PM' + '2015-08-11T15:33:00' ) self.click(self.drv.find_element_by_class_name('navigate-right')) self.click(self.drv.find_element_by_class_name('navigate-right')) @@ -1568,12 +1730,7 @@ def test_single_timestamp_question(self): self.assertIsNot(existing_submission, new_submission) answer = new_submission.answers[0].answer - date_answer = answer.date() - self.assertEqual(date_answer.isoformat(), '2015-08-11') - time_answer = answer.timetz() - answer_parts = re.split('[-+]', time_answer.isoformat()) - self.assertEqual(len(answer_parts), 2, msg=answer_parts) - self.assertEqual(answer_parts[0], '15:33:00') + self.assertEqual(answer.isoformat(), '2015-08-11T15:33:00+00:00') @report_success_status def test_single_location_question(self): @@ -1669,7 +1826,8 @@ def test_single_multiple_choice_question(self): self.wait_for_element('navigate-right', By.CLASS_NAME) self.click(self.drv.find_element_by_class_name('navigate-right')) - self.click(self.drv.find_elements_by_tag_name('option')[1]) + e_by_tag = self.drv.find_element_by_tag_name + self.select_by_index(Select(e_by_tag('select')), 1) self.click(self.drv.find_element_by_class_name('navigate-right')) self.click(self.drv.find_element_by_class_name('navigate-right')) @@ -1716,8 +1874,7 @@ def test_select_multiple(self): self.wait_for_element('navigate-right', By.CLASS_NAME) self.click(self.drv.find_element_by_class_name('navigate-right')) - self.click(self.drv.find_elements_by_tag_name('option')[1]) - self.click(self.drv.find_elements_by_tag_name('option')[2]) + self.select_multiple(1, 2) self.click(self.drv.find_element_by_class_name('navigate-right')) self.click(self.drv.find_element_by_class_name('navigate-right')) @@ -1768,7 +1925,8 @@ def test_other(self): self.wait_for_element('navigate-right', By.CLASS_NAME) self.click(self.drv.find_element_by_class_name('navigate-right')) - self.click(self.drv.find_elements_by_tag_name('option')[-1]) + e_by_tag = self.drv.find_element_by_tag_name + self.select_by_index(Select(e_by_tag('select')), 3) ( self.drv .find_element_by_tag_name('input') @@ -2173,16 +2331,8 @@ def test_required_question_bad_answer(self): alert = self.drv.switch_to.alert alert.accept() - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_element_by_tag_name('input') - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys('3') - .perform() + self.input_field().send_keys( + Keys.RIGHT * 14, Keys.BACK_SPACE * 14, '3' ) self.click(self.drv.find_element_by_class_name('navigate-right')) @@ -2250,28 +2400,14 @@ def test_allow_multiple_cant_fool_required(self): # TODO: change this behavior alert = self.drv.switch_to.alert alert.accept() + if self.browser == 'android': + self.sleep() - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_elements_by_tag_name('input')[0] - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys('3') - .perform() + self.drv.find_elements_by_tag_name('input')[0].send_keys( + Keys.RIGHT * 15, Keys.BACK_SPACE * 15, '3' ) - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_elements_by_tag_name('input')[-1] - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys('4') - .perform() + self.drv.find_elements_by_tag_name('input')[-1].send_keys( + Keys.RIGHT * 14, Keys.BACK_SPACE * 14, '4' ) self.click(self.drv.find_element_by_class_name('navigate-right')) @@ -2772,16 +2908,8 @@ def test_branch_nesting(self): 'integer_0' ) - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_element_by_tag_name('input') - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys('15') - .perform() + self.drv.find_element_by_tag_name('input').send_keys( + Keys.BACK_SPACE * 2, '15' ) self.click(self.drv.find_element_by_class_name('navigate-right')) self.assertEqual( @@ -3017,17 +3145,7 @@ def test_multiple_buckets_for_same_branch(self): 'branch' ) self.click(self.drv.find_element_by_class_name('page_nav__prev')) - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_element_by_tag_name('input') - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys('25') - .perform() - ) + self.input_field().send_keys(Keys.BACK_SPACE, '25') self.click(self.drv.find_element_by_class_name('navigate-right')) self.assertEqual( self.drv.find_element_by_tag_name('h3').text, @@ -3099,34 +3217,14 @@ def test_integer_buckets(self): 'b0' ) self.click(self.drv.find_element_by_class_name('page_nav__prev')) - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_element_by_tag_name('input') - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys('4') - .perform() - ) + self.input_field().send_keys(Keys.BACK_SPACE, '4') self.click(self.drv.find_element_by_class_name('navigate-right')) self.assertEqual( self.drv.find_element_by_tag_name('h3').text, 'b1' ) self.click(self.drv.find_element_by_class_name('page_nav__prev')) - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_element_by_tag_name('input') - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys('1') - .perform() - ) + self.input_field().send_keys(Keys.BACK_SPACE, '1') self.click(self.drv.find_element_by_class_name('navigate-right')) self.assertEqual( self.drv.find_element_by_tag_name('h3').text, @@ -3151,34 +3249,14 @@ def test_integer_buckets_open_ranges(self): 'b0' ) self.click(self.drv.find_element_by_class_name('page_nav__prev')) - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_element_by_tag_name('input') - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys('15') - .perform() - ) + self.input_field().send_keys(Keys.BACK_SPACE * 2, '15') self.click(self.drv.find_element_by_class_name('navigate-right')) self.assertEqual( self.drv.find_element_by_tag_name('h3').text, 'b1' ) self.click(self.drv.find_element_by_class_name('page_nav__prev')) - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_element_by_tag_name('input') - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys('5') - .perform() - ) + self.input_field().send_keys(Keys.BACK_SPACE * 2, '5') self.click(self.drv.find_element_by_class_name('navigate-right')) self.assertEqual( self.drv.find_element_by_tag_name('h3').text, @@ -3203,17 +3281,7 @@ def test_integer_buckets_total_open(self): 'b0' ) self.click(self.drv.find_element_by_class_name('page_nav__prev')) - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_element_by_tag_name('input') - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys('999') - .perform() - ) + self.input_field().send_keys(Keys.BACK_SPACE * 4, '999') self.click(self.drv.find_element_by_class_name('navigate-right')) self.assertEqual( self.drv.find_element_by_tag_name('h3').text, @@ -3242,34 +3310,14 @@ def test_decimal_buckets(self): 'b0' ) self.click(self.drv.find_element_by_class_name('page_nav__prev')) - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_element_by_tag_name('input') - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys('4.2') - .perform() - ) + self.input_field().send_keys(Keys.BACK_SPACE * 3, '4.2') self.click(self.drv.find_element_by_class_name('navigate-right')) self.assertEqual( self.drv.find_element_by_tag_name('h3').text, 'b1' ) self.click(self.drv.find_element_by_class_name('page_nav__prev')) - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_element_by_tag_name('input') - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys('1.2') - .perform() - ) + self.input_field().send_keys(Keys.BACK_SPACE * 3, '1.2') self.click(self.drv.find_element_by_class_name('navigate-right')) self.assertEqual( self.drv.find_element_by_tag_name('h3').text, @@ -3294,34 +3342,14 @@ def test_decimal_buckets_open_ranges(self): 'b0' ) self.click(self.drv.find_element_by_class_name('page_nav__prev')) - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_element_by_tag_name('input') - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys('15.1') - .perform() - ) + self.input_field().send_keys(Keys.BACK_SPACE * 4, '15.1') self.click(self.drv.find_element_by_class_name('navigate-right')) self.assertEqual( self.drv.find_element_by_tag_name('h3').text, 'b1' ) self.click(self.drv.find_element_by_class_name('page_nav__prev')) - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_element_by_tag_name('input') - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys('5.1') - .perform() - ) + self.input_field().send_keys(Keys.BACK_SPACE * 4, '5.1') self.click(self.drv.find_element_by_class_name('navigate-right')) self.assertEqual( self.drv.find_element_by_tag_name('h3').text, @@ -3346,17 +3374,7 @@ def test_decimal_buckets_total_open(self): 'b0' ) self.click(self.drv.find_element_by_class_name('page_nav__prev')) - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_element_by_tag_name('input') - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys('999.1') - .perform() - ) + self.input_field().send_keys(Keys.BACK_SPACE * 6, '999.1') self.click(self.drv.find_element_by_class_name('navigate-right')) self.assertEqual( self.drv.find_element_by_tag_name('h3').text, @@ -3384,17 +3402,7 @@ def test_date_buckets(self): 'b0' ) self.click(self.drv.find_element_by_class_name('page_nav__prev')) - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_element_by_tag_name('input') - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys(Keys.DELETE) - .perform() - ) + self.input_field().send_keys(Keys.BACK_SPACE * 10) self.enter_date( self.drv.find_element_by_tag_name('input'), '2015', '01', '04' @@ -3405,17 +3413,7 @@ def test_date_buckets(self): 'b1' ) self.click(self.drv.find_element_by_class_name('page_nav__prev')) - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_element_by_tag_name('input') - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys(Keys.DELETE) - .perform() - ) + self.input_field().send_keys(Keys.BACK_SPACE * 10) self.enter_date( self.drv.find_element_by_tag_name('input'), '2015', '01', '01' @@ -3447,17 +3445,7 @@ def test_date_buckets_open_ranges(self): 'b0' ) self.click(self.drv.find_element_by_class_name('page_nav__prev')) - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_element_by_tag_name('input') - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys(Keys.DELETE) - .perform() - ) + self.input_field().send_keys(Keys.BACK_SPACE * 10) self.enter_date( self.drv.find_element_by_tag_name('input'), '2015', '11', '22' @@ -3468,17 +3456,7 @@ def test_date_buckets_open_ranges(self): 'b1' ) self.click(self.drv.find_element_by_class_name('page_nav__prev')) - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_element_by_tag_name('input') - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys(Keys.DELETE) - .perform() - ) + self.input_field().send_keys(Keys.BACK_SPACE * 10) self.enter_date( self.drv.find_element_by_tag_name('input'), '2015', '01', '05' @@ -3506,17 +3484,7 @@ def test_date_buckets_total_open(self): 'b0' ) self.click(self.drv.find_element_by_class_name('page_nav__prev')) - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_element_by_tag_name('input') - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys(Keys.DELETE) - .perform() - ) + self.input_field().send_keys(Keys.BACK_SPACE * 10) self.enter_date( self.drv.find_element_by_tag_name('input'), '2070', '01', '05' @@ -3531,16 +3499,16 @@ def test_date_buckets_total_open(self): def test_timestamp_buckets(self): survey_id = self.survey_with_branch( 'timestamp', - '(2015-01-01T1:00:00Z, 2015-01-03:1:00:00Z)', - '[2015-01-04T1:00:00Z, 2015-01-05T1:00:00Z]' + '(2015-01-01T12:00:00Z, 2015-01-03T12:00:00Z)', + '[2015-01-04T12:00:00Z, 2015-01-05T12:00:00Z]' ) self.get('/enumerate/{}'.format(survey_id)) self.wait_for_element('navigate-right', By.CLASS_NAME) self.click(self.drv.find_element_by_class_name('navigate-right')) - self.enter_timestamp_temporary( + self.enter_timestamp( self.drv.find_element_by_tag_name('input'), - '2015', '01', '02', '01', '00', 'AM' + '2015-01-02T12:00:00' ) self.click(self.drv.find_element_by_class_name('navigate-right')) self.assertEqual( @@ -3548,20 +3516,10 @@ def test_timestamp_buckets(self): 'b0' ) self.click(self.drv.find_element_by_class_name('page_nav__prev')) - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_element_by_tag_name('input') - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys(Keys.DELETE) - .perform() - ) - self.enter_timestamp_temporary( + self.input_field().send_keys(Keys.RIGHT * 30, Keys.BACK_SPACE * 30) + self.enter_timestamp( self.drv.find_element_by_tag_name('input'), - '2015', '01', '04', '01', '00', 'AM' + '2015-01-04T12:00:00' ) self.click(self.drv.find_element_by_class_name('navigate-right')) self.assertEqual( @@ -3569,20 +3527,10 @@ def test_timestamp_buckets(self): 'b1' ) self.click(self.drv.find_element_by_class_name('page_nav__prev')) - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_element_by_tag_name('input') - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys(Keys.DELETE) - .perform() - ) - self.enter_timestamp_temporary( + self.input_field().send_keys(Keys.RIGHT * 30, Keys.BACK_SPACE * 30) + self.enter_timestamp( self.drv.find_element_by_tag_name('input'), - '2015', '01', '01', '01', '00', 'AM' + '2015-01-01T12:00:00' ) self.click(self.drv.find_element_by_class_name('navigate-right')) self.assertEqual( @@ -3603,7 +3551,7 @@ def test_timestamp_buckets_open_ranges(self): self.click(self.drv.find_element_by_class_name('navigate-right')) self.enter_timestamp( self.drv.find_element_by_tag_name('input'), - '2014', '11', '22', '01', '00', 'AM' + '2014-11-22T01:00:00' ) self.click(self.drv.find_element_by_class_name('navigate-right')) self.assertEqual( @@ -3611,20 +3559,10 @@ def test_timestamp_buckets_open_ranges(self): 'b0' ) self.click(self.drv.find_element_by_class_name('page_nav__prev')) - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_element_by_tag_name('input') - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys(Keys.DELETE) - .perform() - ) + self.input_field().send_keys(Keys.RIGHT * 30, Keys.BACK_SPACE * 30) self.enter_timestamp( self.drv.find_element_by_tag_name('input'), - '2015', '11', '22', '01', '00', 'AM' + '2015-11-22T01:00:00' ) self.click(self.drv.find_element_by_class_name('navigate-right')) self.assertEqual( @@ -3632,20 +3570,10 @@ def test_timestamp_buckets_open_ranges(self): 'b1' ) self.click(self.drv.find_element_by_class_name('page_nav__prev')) - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_element_by_tag_name('input') - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys(Keys.DELETE) - .perform() - ) + self.input_field().send_keys(Keys.RIGHT * 30, Keys.BACK_SPACE * 30) self.enter_timestamp( self.drv.find_element_by_tag_name('input'), - '2015', '01', '05', '01', '00', 'AM' + '2015-01-05T01:00:00' ) self.click(self.drv.find_element_by_class_name('navigate-right')) self.assertEqual( @@ -3662,7 +3590,7 @@ def test_timestamp_buckets_total_open(self): self.click(self.drv.find_element_by_class_name('navigate-right')) self.enter_timestamp( self.drv.find_element_by_tag_name('input'), - '1970', '01', '05', '01', '00', 'AM' + '1970-01-05T01:00:00' ) self.click(self.drv.find_element_by_class_name('navigate-right')) self.assertEqual( @@ -3670,20 +3598,10 @@ def test_timestamp_buckets_total_open(self): 'b0' ) self.click(self.drv.find_element_by_class_name('page_nav__prev')) - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_element_by_tag_name('input') - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys(Keys.DELETE) - .perform() - ) + self.input_field().send_keys(Keys.RIGHT * 30, Keys.BACK_SPACE * 30) self.enter_timestamp( self.drv.find_element_by_tag_name('input'), - '2070', '01', '05', '01', '00', 'AM' + '2070-01-05T01:00:00' ) self.click(self.drv.find_element_by_class_name('navigate-right')) self.assertEqual( @@ -3746,24 +3664,26 @@ def test_multiple_choice_buckets(self): survey_id = survey.id + element_by_tag = self.drv.find_element_by_tag_name + self.get('/enumerate/{}'.format(survey_id)) self.wait_for_element('navigate-right', By.CLASS_NAME) self.click(self.drv.find_element_by_class_name('navigate-right')) - self.click(self.drv.find_elements_by_tag_name('option')[1]) + self.select_by_index(Select(element_by_tag('select')), 1) self.click(self.drv.find_element_by_class_name('navigate-right')) self.assertEqual( self.drv.find_element_by_tag_name('h3').text, 'b0' ) self.click(self.drv.find_element_by_class_name('page_nav__prev')) - self.click(self.drv.find_elements_by_tag_name('option')[2]) + self.select_by_index(Select(element_by_tag('select')), 2) self.click(self.drv.find_element_by_class_name('navigate-right')) self.assertEqual( self.drv.find_element_by_tag_name('h3').text, 'b1' ) self.click(self.drv.find_element_by_class_name('page_nav__prev')) - self.click(self.drv.find_elements_by_tag_name('option')[3]) + self.select_by_index(Select(element_by_tag('select')), 3) self.click(self.drv.find_element_by_class_name('navigate-right')) self.assertEqual( self.drv.find_element_by_tag_name('h3').text, @@ -3925,17 +3845,7 @@ def test_logic_integer_min_max(self): 1 ) - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_element_by_tag_name('input') - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys('2') - .perform() - ) + self.input_field().send_keys(Keys.BACK_SPACE, '2') self.assertEqual( len(self.drv.find_elements_by_css_selector('input:invalid')), @@ -3946,17 +3856,7 @@ def test_logic_integer_min_max(self): 0 ) - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_element_by_tag_name('input') - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys('12') - .perform() - ) + self.input_field().send_keys(Keys.BACK_SPACE, '12') self.assertEqual( len(self.drv.find_elements_by_css_selector('input:invalid')), @@ -4017,17 +3917,7 @@ def test_logic_decimal_min_max(self): 1 ) - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_element_by_tag_name('input') - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys('2') - .perform() - ) + self.input_field().send_keys(Keys.BACK_SPACE, '2') self.assertEqual( len(self.drv.find_elements_by_css_selector('input:invalid')), @@ -4038,17 +3928,7 @@ def test_logic_decimal_min_max(self): 0 ) - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_element_by_tag_name('input') - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys('12') - .perform() - ) + self.input_field().send_keys(Keys.BACK_SPACE, '12') self.assertEqual( len(self.drv.find_elements_by_css_selector('input:invalid')), @@ -4116,17 +3996,7 @@ def test_logic_date_min_max(self): .send_keys(Keys.LEFT, Keys.LEFT) ) else: - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_element_by_tag_name('input') - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys(Keys.DELETE) - .perform() - ) + self.input_field().send_keys(Keys.BACK_SPACE * 10) self.enter_date( self.drv.find_element_by_tag_name('input'), '2015', '09', '02' @@ -4148,17 +4018,7 @@ def test_logic_date_min_max(self): .send_keys(Keys.LEFT, Keys.LEFT) ) else: - ( - ActionChains(self.drv) - .key_down( - self.control_key, - self.drv.find_element_by_tag_name('input') - ) - .send_keys('a') - .key_up(self.control_key) - .send_keys(Keys.DELETE) - .perform() - ) + self.input_field().send_keys(Keys.BACK_SPACE * 10) self.enter_date( self.drv.find_element_by_tag_name('input'), '2015', '09', '12' @@ -4212,7 +4072,8 @@ def test_add_new_facility(self): ) # navigate to end of survey and save - self.click(self.drv.find_elements_by_tag_name('option')[1]) + facility_type = Select(self.drv.find_element_by_tag_name('select')) + self.select_by_index(facility_type, 1) self.click(self.drv.find_element_by_class_name('navigate-right')) self.click(self.drv.find_element_by_class_name('navigate-right')) @@ -4269,7 +4130,8 @@ def test_add_new_facility_revisit_cuts_out(self): .send_keys('new facility') ) # navigate to end of survey and save - self.click(self.drv.find_elements_by_tag_name('option')[1]) + facility_type = Select(self.drv.find_element_by_tag_name('select')) + self.select_by_index(facility_type, 1) self.click(self.drv.find_element_by_class_name('navigate-right')) self.click(self.drv.find_element_by_class_name('navigate-right')) @@ -4335,7 +4197,8 @@ def test_revisit_offline_then_online(self): .send_keys('new facility') ) # navigate to end of survey and save - self.click(self.drv.find_elements_by_tag_name('option')[1]) + facility_type = Select(self.drv.find_element_by_tag_name('select')) + self.select_by_index(facility_type, 1) self.click(self.drv.find_element_by_class_name('navigate-right')) self.click(self.drv.find_element_by_class_name('navigate-right')) @@ -4437,7 +4300,7 @@ def get_single_node_survey_id(self, question_type): def get_last_submission(self, survey_id): self.sleep() - return ( + result = ( self.session .query(Submission) .filter_by(survey_id=survey_id) @@ -4445,6 +4308,25 @@ def get_last_submission(self, survey_id): .limit(1) .one() ) + andr = self.browser == 'android' + if andr and self.version < StrictVersion('5.0'): + # For some reason the Android 4.4 tests report the wrong time + # for a while... This gets around that problem. + try: + existing = self.existing + except AttributeError: + self.existing = result + else: + result = ( + self.session + .query(Submission) + .filter(Submission.id != existing.id) + .filter(Submission.survey_id == survey_id) + .order_by(Submission.save_time.desc()) + .limit(1) + .one() + ) + return result @report_success_status def test_revisit_offline_entirely(self): @@ -4478,7 +4360,8 @@ def test_revisit_offline_entirely(self): .send_keys('new facility') ) # navigate to end of survey and save - self.click(self.drv.find_elements_by_tag_name('option')[1]) + facility_type = Select(self.drv.find_element_by_tag_name('select')) + self.select_by_index(facility_type, 1) self.click(self.drv.find_element_by_class_name('navigate-right')) self.click(self.drv.find_element_by_class_name('navigate-right')) @@ -4510,6 +4393,10 @@ def test_revisit_offline_entirely(self): class TestEnumerateSlowRevisit(DriverTest): def setUp(self): + is_travis = os.environ.get('TRAVIS', 'f').startswith('t') + if is_travis and not SAUCE_CONNECT: + raise unittest.SkipTest("These just don't work reliably on Travis") + super().setUp() try: @@ -4517,7 +4404,7 @@ def setUp(self): 'http://localhost:9999/debug/toggle_revisit_slow?state=true' ) except urllib.error.URLError: - pass + self.fail('Revisit cannot be set to slow mode') def tearDown(self): super().tearDown() @@ -4544,13 +4431,20 @@ def test_single_facility_question_loading(self): start_time = time.time() self.get('/enumerate/{}'.format(survey_id)) - overlay = self.drv.find_elements_by_class_name('loading-overlay') - finish_time = time.time() - - self.assertGreater(finish_time - start_time, 2) + if self.browser == 'android': + overlay = self.drv.find_elements_by_class_name('loading-overlay') + self.assertEqual(len(overlay), 1) + self.sleep(2) + noverlay = self.drv.find_elements_by_class_name('loading-overlay') + self.assertEqual(len(noverlay), 0) + else: + # first load, should be slow because revisit is hit + overlay = self.drv.find_elements_by_class_name('loading-overlay') + finish_time = time.time() - # overlay should not be present - self.assertEqual(len(overlay), 0) + # overlay should not be present + self.assertEqual(len(overlay), 0) + self.assertGreater(finish_time - start_time, 2) @report_success_status def test_facilities_only_fetched_on_first_load(self): @@ -4559,20 +4453,24 @@ def test_facilities_only_fetched_on_first_load(self): """ survey_id = self.get_single_node_survey_id('facility') - # first load, should be slow because revisit is hit start_time = time.time() self.get('/enumerate/{}'.format(survey_id)) - overlay = self.drv.find_elements_by_class_name('loading-overlay') - finish_time = time.time() + if self.browser == 'android': + overlay = self.drv.find_elements_by_class_name('loading-overlay') + self.assertEqual(len(overlay), 1) + self.sleep(2) + noverlay = self.drv.find_elements_by_class_name('loading-overlay') + self.assertEqual(len(noverlay), 0) + else: + # first load, should be slow because revisit is hit + overlay = self.drv.find_elements_by_class_name('loading-overlay') + finish_time = time.time() - self.assertGreater(finish_time - start_time, 2) - # overlay should not be present - self.assertEqual(len(overlay), 0) + # overlay should not be present + self.assertEqual(len(overlay), 0) + self.assertGreater(finish_time - start_time, 2) # second load, should be fast because revisit is not hit - start_time = time.time() self.drv.refresh() - overlay = self.drv.find_elements_by_class_name('loading-overlay') - finish_time = time.time() - - self.assertLess(finish_time - start_time, 2) + nnoverlay = self.drv.find_elements_by_class_name('loading-overlay') + self.assertEqual(len(nnoverlay), 0) diff --git a/tox.ini b/tox.ini index cdd9a0ce..d8db2af6 100644 --- a/tox.ini +++ b/tox.ini @@ -9,6 +9,7 @@ deps= -rrequirements.txt beautifulsoup4 py-dateutil + pytz selenium passenv=TRAVIS DISPLAY SAUCE_CONNECT SAUCE_USERNAME SAUCE_ACCESS_KEY BROWSER DB_PORT_5432_TCP_ADDR DB_PORT_5432_TCP_PORT POSTGRES_PASSWORD POSTGRES_DB DB_ENV_POSTGRES_PASSWORD DB_DEV_PORT_5432_TCP_ADDR DB_DEV_PORT_5432_TCP_PORT DB_DEV_ENV_POSTGRES_PASSWORD