diff --git a/.env-template b/.env-template new file mode 100644 index 00000000..8c4fe11c --- /dev/null +++ b/.env-template @@ -0,0 +1,3 @@ +SAUCE_USERNAME=ascribe +SAUCE_ACCESS_KEY= +SAUCE_DEFAULT_URL= diff --git a/.eslintrc b/.eslintrc index d41c0a2a..5751f3ad 100644 --- a/.eslintrc +++ b/.eslintrc @@ -2,7 +2,7 @@ "parser": "babel-eslint", "env": { "browser": true, - "es6": true + "es6": true, }, "rules": { "new-cap": [2, {newIsCap: true, capIsNew: false}], diff --git a/.gitignore b/.gitignore index 30c9eae9..f5bf11e8 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ node_modules/* build .DS_Store +.env diff --git a/package.json b/package.json index c961e9c3..091203b5 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,12 @@ }, "scripts": { "lint": "eslint ./js", + "preinstall": "export SAUCE_CONNECT_DOWNLOAD_ON_INSTALL=true", "postinstall": "npm run build", "build": "gulp build --production", - "start": "node server.js" + "start": "node server.js", + "test": "mocha", + "tunnel": "node test/tunnel.js" }, "browser": { "fineUploader": "./js/components/ascribe_uploader/vendor/s3.fine-uploader.js" @@ -35,8 +38,14 @@ "devDependencies": { "babel-eslint": "^3.1.11", "babel-jest": "^5.2.0", - "gulp-sass": "^2.1.1", - "jest-cli": "^0.4.0" + "chai": "^3.4.1", + "chai-as-promised": "^5.1.0", + "colors": "^1.1.2", + "dotenv": "^1.2.0", + "jest-cli": "^0.4.0", + "mocha": "^2.3.4", + "sauce-connect-launcher": "^0.13.0", + "wd": "^0.4.0" }, "dependencies": { "alt": "^0.16.5", @@ -62,7 +71,7 @@ "gulp-if": "^1.2.5", "gulp-minify-css": "^1.1.6", "gulp-notify": "^2.2.0", - "gulp-sass": "^2.0.1", + "gulp-sass": "^2.1.1", "gulp-sourcemaps": "^1.5.2", "gulp-template": "~3.0.0", "gulp-uglify": "^1.2.0", diff --git a/test/.eslintrc b/test/.eslintrc new file mode 100644 index 00000000..64f8d90a --- /dev/null +++ b/test/.eslintrc @@ -0,0 +1,36 @@ +{ + "parser": "babel-eslint", + "env": { + "mocha": true, + "node": true + }, + "rules": { + "new-cap": [2, {newIsCap: true, capIsNew: false}], + "quotes": [2, "single"], + "eol-last": [0], + "no-mixed-requires": [0], + "no-underscore-dangle": [0], + "global-strict": [2, "always"], + "no-trailing-spaces": [2, { skipBlankLines: true }], + "no-console": 0, + "camelcase": [2, {"properties": "never"}], + }, + "globals": {}, + "plugins": [], + "ecmaFeatures": { + "modules": 1, + "arrowFunctions", + "classes": 1, + "blockBindings": 1, + "defaultParams": 1, + "destructuring": 1, + "objectLiteralComputedProperties": 1, + "objectLiteralDuplicateProperties": 0, + "objectLiteralShorthandMethods": 1, + "objectLiteralShorthandProperties": 1, + "restParams": 1, + "spread": 1, + "superInFunctions": 1, + "templateStrings": 1 + } +} diff --git a/test/README.md b/test/README.md new file mode 100644 index 00000000..8fca1cac --- /dev/null +++ b/test/README.md @@ -0,0 +1,256 @@ +# TL;DR +Copy the contents of `.env-template` to `.env` and [fill up the missing keys with +information from your SauceLabs account](#how-to-set-up-your-env-config-file). + +```bash +$ npm install +$ npm run tunnel +$ npm test && git commit +``` + + +# TODO +* Use gulp to parallelize mocha invocations with different browsers +* Figure out good system for changing subdomain through test scripts + + +# Welcome to our test suite, let me be your guide + +Dear reader, first of all thanks for taking your time reading this document. +The purpose of this document is to give you an overview on what we want to test +and how we are doing it. + + +# How it works (bird's-eye view) + +You will notice that the setup is a bit convoluted. This section will explain +you why. Testing single functions in JavaScript is not that hard (if you don't +need to interact with the DOM), and can be easily achieved using frameworks +like [Mocha](https://mochajs.org/). Integration and cross browser testing is, +on the other side, a huge PITA. Moreover, "browser testing" includes also +"mobile browser testing". On the top of that the same browser (type and +version) can behave in a different way on different operating systems. + +To achieve that you can have your own cluster of machines with different +operating systems and browsers or, if you don't want to spend the rest of your +life configuring an average of 100 browsers for each different operating +system, you can pay someone else to do that. Check out [this +article](https://saucelabs.com/selenium/selenium-grid) if you want to know why +using Selenium Grid is better than a DIY approach. + +We decided to use [saucelabs](https://saucelabs.com/) cloud (they support [over +700 combinations](https://saucelabs.com/platforms/) of operating systems and +browsers) to run our tests. + + +## Components and tools + +Right now we are just running the test locally, so no Continuous Integration™. + +The components involved are: + - **[Selenium WebDriver](https://www.npmjs.com/package/wd)**: it's a library + that can control a browser. You can use the **WebDriver** to load new URLs, + click around, fill out forms, submit forms etc. It's basically a way to + control remotely a browser. The protocol (language agnostic) is called + [JsonWire](https://code.google.com/p/selenium/wiki/JsonWireProtocol), `wd` + wraps it and gives you a nice + [API](https://github.com/admc/wd/blob/master/doc/jsonwire-full-mapping.md) + you can use in JavaScript. There are other implementations in Python, PHP, + Java, etc. Also, a **WebDriver** can be initialized with a list of [desired + capabilities](https://code.google.com/p/selenium/wiki/DesiredCapabilities) + describing which features (like the platform, browser name and version) you + want to use to run your tests. + + - **[Selenium Grid](https://github.com/SeleniumHQ/selenium/wiki/Grid2)**: it's + the controller for the cluster of machines/devices that can run browsers. + Selenium Grid is able to scale by distributing tests on several machines, + manage multiple environments from a central point, making it easy to run the + tests against a vast combination of browsers / OS, minimize the maintenance + time for the grid by allowing you to implement custom hooks to leverage + virtual infrastructure for instance. + + - **[Saucelabs](https://saucelabs.com/)**: a private company providing a + cluster to run your tests on over 700 combinations of browsers/operating + systems. (They do other things, check out their websites). + + - **[SauceConnect](https://wiki.saucelabs.com/display/DOCS/Setting+Up+Sauce+Connect)**: + is a Java software by Saucelabs to connect to your `localhost` to test the + application. There is also a Node.js wrapper + [sauce-connect-launcher](https://www.npmjs.com/package/sauce-connect-launcher), + so you can use it programmatically within your code for tests. Please note + that this module is just a wrapper around the actual software. Running `npm + install` should install the additional Java software as well. + + +On the JavaScript side, we use: + - [Mocha](https://mochajs.org/): a test framework running on Node.js. + + - [chai](http://chaijs.com/): a BDD/TDD assertion library for node that can be + paired with any javascript testing framework. + + - [chaiAsPromised](https://github.com/domenic/chai-as-promised/): an extension + for Chai with a fluent language for asserting facts about promises. The + extension is actually quite cool, we can do assertions on promises without + writing callbacks but just chaining operators. Check out their `README` on + GitHub to see an example. + + - [dotenv](https://github.com/motdotla/dotenv): a super nice package to load + environment variables from `.env` into `process.env`. + + +## How to set up your `.env` config file +In the root of this repository there is a file called `.env-template`. Create a +copy and call it `.env`. This file will store some values we need to connect to +Saucelabs. + +There are two values to be set: + - `SAUCE_ACCESS_KEY` + - `SAUCE_USERNAME` + +The two keys are the [default +ones](https://github.com/admc/wd#environment-variables-for-saucelabs) used by +many products related to Saucelabs. This allow us to keep the configuration +fairly straightforward and simple. + +After logging in to https://saucelabs.com/, you can find your **api key** under +the **My Account**. Copy paste the value in your `.env` file. + + +## Anatomy of a test + +First, you need to learn how [Mocha](https://mochajs.org/) works. Brew a coffee +(or tea, if coffee is not your cup of tea), sit down and read the docs. + +Done? Great, let's move on and analyze how a test is written. + +From a very high level, the flow of a test is the following: + 1. load a page with a specific URL + 2. do something on the page (click a button, submit a form, etc.) + 3. maybe wait some seconds, or wait if something has changed + 4. check if the new page contains some text you expect to be there + +This is not set in stone, so go crazy if you want. But keep in mind that we +have a one page application, there might be some gotchas on how to wait for +stuff to happen. I suggest you to read the section [Wait for +something](https://github.com/admc/wd#waiting-for-something) to understand +better which tools you have to solve this problem. +Again, take a look to the [`wd` implementation of the JsonWire +protocol](https://github.com/admc/wd/blob/master/doc/jsonwire-full-mapping.md) +to know all the methods you can use to control the browser. + + +Import the libraries we need. + +```javascript +'use strict'; + +require('dotenv').load(); + +const wd = require('wd'); +const chai = require('chai'); +const chaiAsPromised = require('chai-as-promised'); +``` + + +Set up `chai` to use `chaiAsPromised`. + +```javascript +chai.use(chaiAsPromised); +chai.should(); +``` + +`browser` is the main object to interact with Saucelab "real" browsers. We will +use this object a lot. It allows us to load pages, click around, check if a +specific text is present, etc. + +```javascript +describe('Login logs users in', function() { + let browser; +``` + +Create the driver to control the browser. `before` will be executed once at the +start of the test before any `it` functions. Use `beforeEach` instead if you'd +like to run some code before each `it` function. + +```javascript + before(function() { + browser = wd.promiseChainRemote('ondemand.saucelabs.com', 80); + + // Start the browser, go to /login, and wait for the react app to render + return browser + .init({ browserName, version, platform }) + .get(config.APP_URL + '/login') + .waitForElementByCss('.ascribe-default-app', asserters.isDisplayed, 10000); + }); +``` + +Close the browser after finishing all tests. `after` will be executed at the end +of all `it` functions. Use `afterEach` instead if you'd like to run some code +after each `it` function. + +```javascript + after(function() { + // Destroys the browser session + return browser.quit(); + }); +``` + +The actual test. We query the `browser` object to get the title of the page. +Note that `.title()` returns a `promise` **but**, since we are using +`chaiAsPromised`, we have some syntactic sugar to handle the promise in line, +without writing new functions. + +```javascript + it('should contain "Log in" in the title', function() { + return browser.title().should.become('Log in'); + }); +}); +``` + +All together: + +```javascript +function testSuite(browserName, version, platform) { + describe(`[${browserName} ${version} ${platform}] Login logs users in`, function() { + // Set timeout to zero so Mocha won't time out. + this.timeout(0); + let browser; + + before(function() { + // No need to inject `username` or `access_key`, by default the constructor + // looks up the values in `process.env.SAUCE_USERNAME` and `process.env.SAUCE_ACCESS_KEY` + browser = wd.promiseChainRemote('ondemand.saucelabs.com', 80); + + // Start the browser, go to /login, and wait for the react app to render + return browser + .init({ browserName, version, platform }) + .get(config.APP_URL + '/login') + .waitForElementByCss('.ascribe-default-app', asserters.isDisplayed, 10000); + }); + + after(function() { + return browser.quit(); + }); + + it('should contain "Log in" in the title', function() { + return browser.title().should.become('Log in'); + }); + }); +} +``` + +## How to run the test suite +To run the tests, type: +```bash +$ mocha +``` + +By default the test suite runs on `http://www.localhost.com:3000/`, if you +want to change the URL, change the `APP_URL` env variable. + + +# How to have fun +Try this! +```bash +$ mocha -R nyan +``` diff --git a/test/config.js b/test/config.js new file mode 100644 index 00000000..400c6d90 --- /dev/null +++ b/test/config.js @@ -0,0 +1,18 @@ +'use strict'; + +require('dotenv').load(); + + +// https://code.google.com/p/selenium/wiki/DesiredCapabilities +const BROWSERS = [ + 'chrome,47,WINDOWS', + 'chrome,46,WINDOWS', + 'firefox,43,MAC', + 'internet explorer,10,VISTA' +]; + + +module.exports = { + BROWSERS: BROWSERS.map(x => x.split(',')), + APP_URL: process.env.SAUCE_DEFAULT_URL || 'http://www.localhost.com:3000' +}; diff --git a/test/setup.js b/test/setup.js new file mode 100644 index 00000000..334423e7 --- /dev/null +++ b/test/setup.js @@ -0,0 +1,50 @@ +'use strict'; + +const config = require('./config'); +const colors = require('colors'); +const sauceConnectLauncher = require('sauce-connect-launcher'); + + +let globalSauceProcess; + +if (!process.env.SAUCE_USERNAME) { + console.log(colors.red('SAUCE_USERNAME is missing. Please check the README.md file.')); + process.exit(1); //eslint-disable-line no-process-exit +} + +if (!process.env.SAUCE_ACCESS_KEY) { + console.log(colors.red('SAUCE_ACCESS_KEY is missing. Please check the README.md file.')); + process.exit(1); //eslint-disable-line no-process-exit +} + + +if (process.env.SAUCE_AUTO_CONNECT) { + before(function(done) { + console.log(colors.yellow('Setting up tunnel from Saucelabs to your lovely computer, will take a while.')); + // Creating the tunnel takes a bit of time. For this case we can safely disable Mocha timeouts. + this.timeout(0); + + sauceConnectLauncher(function (err, sauceConnectProcess) { + if (err) { + console.error(err.message); + return; + } + globalSauceProcess = sauceConnectProcess; + done(); + }); + }); + + + after(function (done) { + // Creating the tunnel takes a bit of time. For this case we can safely disable it. + this.timeout(0); + + if (globalSauceProcess) { + globalSauceProcess.close(done); + } + }); +} else if (config.APP_URL.match(/localhost/)) { + console.log(colors.yellow(`You are running tests on ${config.APP_URL}, make sure you already have a tunnel running.`)); + console.log(colors.yellow('To create the tunnel, run:')); + console.log(colors.yellow(' $ node test/tunnel.js')); +} diff --git a/test/test-login.js b/test/test-login.js new file mode 100644 index 00000000..e2736fe1 --- /dev/null +++ b/test/test-login.js @@ -0,0 +1,50 @@ +'use strict'; + +const Q = require('q'); +const wd = require('wd'); +const asserters = wd.asserters; // Commonly used asserters for async waits in the browser +const chai = require('chai'); +const chaiAsPromised = require('chai-as-promised'); +const config = require('./config.js'); + +chai.use(chaiAsPromised); +chai.should(); + + +function testSuite(browserName, version, platform) { + describe(`[${browserName} ${version} ${platform}] Login logs users in`, function() { + // Set timeout to zero so Mocha won't time out. + this.timeout(0); + let browser; + + before(function() { + // No need to inject `username` or `access_key`, by default the constructor + // looks up the values in `process.env.SAUCE_USERNAME` and `process.env.SAUCE_ACCESS_KEY` + browser = wd.promiseChainRemote('ondemand.saucelabs.com', 80); + + // Start the browser, go to /login, and wait for the react app to render + return browser + .configureHttp({ baseUrl: config.APP_URL }) + .init({ browserName, version, platform }) + .get('/login') + .waitForElementByCss('.ascribe-default-app', asserters.isDisplayed, 10000) + .catch(function (err) { + console.log('Failure -- unable to load app.'); + console.log('Skipping tests for this browser...'); + return Q.reject(err); + }); + }); + + after(function() { + return browser.quit(); + }); + + it('should contain "Log in" in the title', function() { + return browser. + waitForElementByCss('.ascribe-login-wrapper', asserters.isDisplayed, 2000) + title().should.become('Log in'); + }); + }); +} + +config.BROWSERS.map(x => testSuite(...x)); diff --git a/test/tunnel.js b/test/tunnel.js new file mode 100644 index 00000000..2a7fa371 --- /dev/null +++ b/test/tunnel.js @@ -0,0 +1,23 @@ +'use strict'; + +const config = require('./config'); //eslint-disable-line no-unused-vars +const colors = require('colors'); +const sauceConnectLauncher = require('sauce-connect-launcher'); + + +function connect() { + console.log(colors.yellow('Setting up tunnel from Saucelabs to your lovely computer, will take a while.')); + // Creating the tunnel takes a bit of time. For this case we can safely disable Mocha timeouts. + + sauceConnectLauncher(function (err) { + if (err) { + console.error(err.message); + return; + } + console.log(colors.green('Connected! Keep this process running and execute your tests.')); + }); +} + +if (require.main === module) { + connect(); +}