diff --git a/.jshintrc b/.jshintrc new file mode 100644 index 0000000..4bb9177 --- /dev/null +++ b/.jshintrc @@ -0,0 +1,22 @@ +{ + "curly": true, + "eqeqeq": true, + "immed": true, + "indent": 2, + "latedef": true, + "newcap": true, + "noarg": true, + "node": true, + "predef": [ + "describe", + "it", + "before", + "beforeEach", + "after", + "afterEach", + "browser" + ], + "proto": true, + "sub": true, + "undef": true +} \ No newline at end of file diff --git a/.npmignore b/.npmignore index 011fe20..c429bf2 100644 --- a/.npmignore +++ b/.npmignore @@ -3,3 +3,4 @@ .idea/ tmp/ npm-debug.log +sauce_connect.lo* diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..4c69cee --- /dev/null +++ b/.travis.yml @@ -0,0 +1,12 @@ +language: node_js +node_js: + - '0.10' + - '0.12' +env: + global: + - secure: "Vw5cnzdO8//kMTK5GKo9Rw8BXHL0pOkONSeQw7gwsY3RD35biBg8szT6ejUeo8He28XQ0zUjlH/2hnazLFXCMC5b4ZRMMRR3hvzzR50rRGM3C0jKuycC6KVK+aoLL3blRrICOeITvyyRsgb9HrBdixa7IObwju36e6npzONEPKM=" + - secure: "mA5tCzY11lmTXQ0W3TOEMfn2KRkVrc5maWrFeSaIRd3OxAoKQkxbgeKurxfM8HNhMIV9yurPtcM4vp+OT+gB8IKwGiN4LfJfn90Kc/5C3rj7BLWBH03gVahDOEg4NK2/JJBWSJZj/vldDWAsnKpWjRmw/aaEwX2suKSPFBVmg9o=" + +before_install: + - npm install -g npm@~1.4.6 + - npm install -g grunt-cli diff --git a/Gruntfile.js b/Gruntfile.js index d34d8d7..cdce954 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1,42 +1,169 @@ +'use strict'; + module.exports = function (grunt) { - 'use strict'; + // Project configuration. grunt.initConfig({ jshint: { all: ['Gruntfile.js', 'tasks/**/*.js', 'test/*.js'], options: { - curly: true, - eqeqeq: true, - es5: true, - immed: true, - indent: 2, - latedef: true, - newcap: true, - noarg: true, - node: true, - nonew: true, - sub: true, - undef: true, - predef: [ - "describe", - "it", - "before", - "beforeEach", - "after", - "afterEach", - "browser" - ] + jshintrc: true } }, - mochaWD: { - src: ['test/sanity.js'], + mochaWebdriver: { options: { - timeout: 10000, - reporter: 'spec' + timeout: 1000 * 60 * 3 + }, + phantom: { + src: ['test/sanity.js'], + options: { + testName: 'phantom test', + usePhantom: true, + phantomPort: 5555, + reporter: 'spec' + } + }, + phantomCapabilities: { + src: ['test/phantom-capabilities.js'], + options: { + testName: 'phantom capabilities test', + usePhantom: true, + phantomPort: 5555, + usePromises: true, + reporter: 'spec', + // see https://github.com/detro/ghostdriver + phantomCapabilities: { + 'phantomjs.page.settings.userAgent': 'customUserAgent', + 'phantomjs.page.customHeaders.grunt-mocha-webdriver-header': 'VALUE' + } + } + }, + phantomFlag: { + src: ['test/phantom-flags.js'], + options: { + testName: 'phantom flags test', + usePhantom: true, + phantomPort: 5555, + usePromises: true, + reporter: 'spec', + phantomFlags: [ + '--webdriver-logfile', 'phantom.log' + ] + } + }, + promises: { + src: ['test/promiseAPi.js'], + options: { + testName: 'phantom test', + usePhantom: true, + usePromises: true, + reporter: 'spec' + } + }, + requires: { + src: ['test/requires.js'], + options: { + testName: 'phantom requires test', + usePhantom: true, + reporter: 'spec', + require: ['test/support/index.js'] + } }, - basic: { + sauce: { + src: ['test/sanity.js'], + options: { + testName: 'sauce test', + concurrency: 2, + testTags: ['mochaWebDriver'], + build: 'testBuild', + browsers: [ + {browserName: 'internet explorer', platform: 'Windows 7', version: '9'}, + {browserName: 'internet explorer', platform: 'Windows 7', version: '8'}, + {browserName: 'chrome', platform: 'Windows 7', version: ''} + ] + } + }, + tunnelOptions: { + src: ['test/tunnelOptions.js'], + options: { + // changing log file to sauce_connect.log.custom + tunnelFlags: ['-l', 'sauce_connect.log.custom'], + testName: 'sauce tunnel flags test', + concurrency: 2, + usePromises: true, + browsers: [ + {browserName: 'internet explorer', platform: 'Windows 7', version: '9'} + ] + } + }, + saucePromises: { + src: ['test/promiseAPI.js'], + options: { + testName: 'sauce promises test', + concurrency: 2, + usePromises: true, + browsers: [ + {browserName: 'internet explorer', platform: 'Windows 7', version: '9'}, + {browserName: 'internet explorer', platform: 'Windows 7', version: '8'}, + {browserName: 'chrome', platform: 'Windows 7', version: ''} + ] + } + }, + sauceSecure: { + src: ['test/promiseAPi.js'], + options: { + testName: 'sauce secure commands test', + secureCommands: true, + usePromises: true, + browsers: [ + {browserName: 'chrome', platform: 'Windows 7', version: ''} + ] + } + }, + customReporter: { + src: ['test/promiseAPI.js'], + options: { + tunneled: false, + testName: 'custom reporter test', + reporter: 'spec', + // customReporter: false, // (false is default) + concurrency: 1, + usePromises: true, + browsers: [ + {browserName: 'chrome', platform: 'Windows 7', version: '36'} + ] + } + }, + selenium: { + src: ['test/sanity.js'], + options: { + testName: 'selenium test', + concurrency: 2, + hostname: '127.0.0.1', + port: '4444', + autoInstall: true, + browsers: [ + {browserName: 'firefox'}, + // {browserName: 'internet explorer', platform: 'Windows 7', version: '8'}, + {browserName: 'chrome'} + ] + } + }, + seleniumPromises: { + src: ['test/promiseAPi.js'], + options: { + testName: 'selenium promises test', + concurrency: 2, + usePromises: true, + hostname: '127.0.0.1', + port: '4444', + browsers: [ + {browserName: 'firefox'}, + {browserName: 'chrome'} + ] + } } } }); @@ -46,6 +173,17 @@ module.exports = function (grunt) { grunt.loadNpmTasks('grunt-contrib-jshint'); // Default task. - grunt.registerTask('test', ['mochaWD']); + grunt.registerTask('test', [ 'jshint', + 'mochaWebdriver:phantom', + 'mochaWebdriver:phantomCapabilities', + 'mochaWebdriver:promises', + 'mochaWebdriver:requires', + 'mochaWebdriver:sauce', + 'mochaWebdriver:tunnelOptions', + 'mochaWebdriver:saucePromises', + 'mochaWebdriver:customReporter' + ]); + + grunt.registerTask('testSelenium', ['mochaWebdriver:selenium', 'mochaWebdriver:seleniumPromises']); grunt.registerTask('default', ['jshint', 'test']); }; diff --git a/History.md b/History.md new file mode 100644 index 0000000..d300f94 --- /dev/null +++ b/History.md @@ -0,0 +1,105 @@ +### v1.2.2 +- Fix bug that prevented mocha failures from failing tests +- Update license + +### v1.2.1 +- Update location of selenium-launcher dependency +- Merge #79. + +### v1.2.0 +- Drop support for Node 0.8 +- Add custom reporter for Saucelabs / Selenium (#81) (@saadtazi) + +### v1.1.2 +- Update dependencies + +### v1.1.1 + - Fix `opts.autoInstall` behavior (@ChrisWren) + +### v1.1.0 + - Moved changelog to History.md (@binarykitchen) + - Added autoinstall options to automatically install chromedriver and selenium (@ChrisWren) + - Fixes and cleanup (@ChrisWren) + - Updated dependencies (@ChrisWren, @binarykitchen); + +### v1.0.6 + - Report pass/fail status to Sauce Labs (@ChrisWren) + +### v1.0.5 + - Check if phantom is closed before killing it (#68) (@binarykitchen) + +### v1.0.4 + - Shortened Sauce URL for copyability (#62) (@ChrisWren) + - Fixed Sauce Labs spelling and added error message (#63) (@ChrisWren) + - Add support for "build" tagging (#64) (@ChrisWren) + +### v1.0.3 + - Make PhantomJS process interruptable (@shawnzhu) + - Correctly report exit code failure for test error (@shawnzhu) + +### v1.0.2 + - Add phantomJS flags (@saadtazi) + +### v1.0.1 + - Improve Sauce test logging + +### v1.0.0 + - Move to latest version of sauce tunnel / sauce connect + - Added ability to specify phantomjs capabilities (@saadtazi) + - Added secure commands for selenium (@saadtazi) + +### v0.9.16 + - `tunnelFlags` options, courtesy of @saadtazi + +### v0.9.15 + - Add Selenium support (thanks to @saadtazi) + +### v0.9.14 + - Bump to 1.15.1 of Mocha, and expose mocha options to tests + +### v0.9.13 + - Bump to 0.2.3 of WD, and expose the wd instance to tests + +### v0.9.12 + - Bump to latst sauce-tunnel + +### v0.9.11 + - Fix #8, async failures were causing grunt exit + +### v0.9.10 + - Fix `promiseChain` on Sauce + +### v0.9.9 + - Update wd.js to 0.2.0, and switch to the new `promiseChain` API + +### v0.9.8 + - `ignoreSslErrors` option added as a phantom CLI passthrough + +### v0.9.7 + - Prevent errors from causing Phantom to exit early + - Better error messages via wd.js + +### v0.9.6 + - PhantomJS integration failed to report the correct status code + when exiting Grunt after tests failed. + +### v0.9.5 + - Implement `require` option for pre-require hook + +### v0.9.4 + - Run phantom from grunt-mocha-webdriver directly + +### v0.9.3 + - Enable Promise support (Phantom and SauceLabs) + +### v0.9.2 + - Update docs + - Add initial promise support (Phantom only) + +### v0.9.1 + - Setup Travis for CI + - Improve SauceLabs test failure handling + - Test count was off by 1 + +### v0.9.0 + - Initial release diff --git a/LICENSE-MIT b/LICENSE similarity index 96% rename from LICENSE-MIT rename to LICENSE index 6b03a34..151d713 100644 --- a/LICENSE-MIT +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2013 Justin Reidy +Copyright (c) 2014 Justin Reidy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation diff --git a/README.md b/README.md deleted file mode 100644 index 2ef116b..0000000 --- a/README.md +++ /dev/null @@ -1,17 +0,0 @@ -##Rationale -`grunt-mocha-webdriver` is a combination of [mocha-cloud](https://github.com/visionmedia/mocha-cloud) and [grunt-saucelabs](https://github.com/axemclion/grunt-saucelabs). -The former library doesn't have Grunt integration built in, and is designed for running tests inside the browser; the latter library can launch a grid of browsers on SauceLabs, -but doesn't support Mocha. - -This library is designed for launching server-side mocha tests against either a local PhantomJS instance, -or a Selenium grid service like SauceLabs. - -###Using grunt-mocha-webdriver with Phantomjs - -1. Install phantomjs >= 1.8 -Homebrew makes this easy. `brew install phantomjs` - -2. Run PhantomJS with WebDriver support -```shell -phantomjs --webdriver= -``` diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..b368086 --- /dev/null +++ b/Readme.md @@ -0,0 +1,201 @@ +# grunt-mocha-webdriver +[![NPM version](https://badge.fury.io/js/grunt-mocha-webdriver.png)](http://badge.fury.io/js/grunt-mocha-webdriver) [![Build Status](https://travis-ci.org/jmreidy/grunt-mocha-webdriver.svg?branch=master)](https://travis-ci.org/jmreidy/grunt-mocha-webdriver) [![david-dm-status-badge](https://david-dm.org/jmreidy/grunt-mocha-webdriver.png)](https://david-dm.org/jmreidy/grunt-mocha-webdriver#info=dependencies&view=table) + [![david-dm-status-badge](https://david-dm.org/jmreidy/grunt-mocha-webdriver/dev-status.png)](https://david-dm.org/jmreidy/grunt-mocha-webdriver#info=devDependencies&view=table) +> A [Grunt](http://gruntjs.com) task that runs Mocha-based functional tests against +a Webdriver-enabled source: specifically, PhantomJS for local testing and Sauce Labs +for comprehensive cross-browser testing. + +This plugin is a combination of [mocha-cloud](https://github.com/visionmedia/mocha-cloud) and +[grunt-saucelabs](https://github.com/axemclion/grunt-saucelabs). The former +library doesn't have Grunt integration built in, and is designed for running +tests inside the browser; the latter library can launch a grid of browsers on +Sauce Labs, but doesn't support Mocha. + +##Getting Started +This plugin requires Grunt `>=0.4.0`; connecting to Sauce Labs requires java. + +Install the plugin with: + +```shell +npm install grunt-mocha-webdriver --save-dev +``` + +Then add this line to your project's Gruntfile: +```shell +grunt.loadNpmTasks('grunt-mocha-webdriver'); +``` + +###Using grunt-mocha-webdriver with Phantomjs +In version 0.9.4 and later, phantom is included via its NPM module. In order +to run tests against phantom, simply add the `usePhantom` flag to the options hash. +The plugin defaults to hitting port 4444, but you can specify your own port via +the `phantomPort` option. + +###Using grunt-mocha-webdriver with your own Selenium server + In version 0.9.15 and later, You can run your tests against your own Selenium server instance. + To do so, use ``hostname`` and ``port`` options. + Don't forget to remove ``username`` and ``key``. + Note that the Selenium server should be started and ready before starting the tests. + +##Documentation +Run this task with the `mochaWebdriver` grunt command. For this plugin, the Grunt +`src` property will specify which test files should be run with Mocha in +the `mochaWebdriver` multitask. These tests should be structured as normal +Mocha tests, but should use `this.browser` to refer to a WebDriver browser +which will be injected into the test's context. The browser can be driven +with the API specified in [WD.js](https://github.com/admc/wd). The default +is to use the callback-enabled version of WD.js, but `usePromises` can be passed +as `true` to switch to the Promise-enabled version. + +As of version `0.2.3` of WD.js, wd [provides the ability](https://github.com/admc/wd#adding-custom-methods) +to add test methods to its default set of capabilities. `grunt-mocha-webdriver` +exposes the `wd` instance in the same way that `browser` is exposed, so that +you can easily add your own test methods to wd. + +Also, Mocha options are exposed to tests. This is especially helpful if you want to reuse the +defined Mocha timeout for your Webdriver tests. For example you can do this in your +Webdriver based E2E tests: + +```js +this.browser.waitForElementByCss('.aClass', this.mochaOptions.timeout, cb); +``` + +Please look at this project's Gruntfile and tests to see all that in action. + +###Options +The usual Mocha options are passed through this task to a new Mocha instance. +Please note that while it's possible to specify the Mocha `reporter` for +tests running on Phantom and when `concurrency` is 1 on saucelabs or selenium, +the task will default to a custom reporter if concurrency is more than 1 +and the browser is not phantom. This restriction is in place to handle concurrent +Sauce Labs/Selenium testing sessions, which could pollute the log. + +The following options can be supplied to the task: + +####usePhantom +Type: Boolean + +Specifies whether the task should test against a PhantomJS instance instead +of Sauce Labs. Defaults to false. If true, the tests will run against Phantom +INSTEAD of running against Sauce Labs. + +####phantomPort +Type: Int (Default: 4444) + +if testing against PhantomJS with the `usePhantom` flag, specify the port +to test against. + +####phantomCapabilities +Type: Object (Default: {}) + +if testing against PhantomJS with the `usePhantom` flag, specify the +[browser capabilities](https://github.com/detro/ghostdriver#what-extra-webdriver-capabilities-ghostdriver-offers). + +####phantomFlags +Type: array (Default: []) + +if testing against PhantomJS with the `usePhantom` command-line options, specify start additional flags to use. Check [here](http://phantomjs.org/api/command-line.html) or type `phantomjs -h` for complete list of flags. + +####usePromises +Type: Boolean + +Specifies whether to use the Promise chain version of the WD.js API. Defaults to +false (the callback version). + +####ignoreSslErrors +Type: Boolean + +A passthrough to the Phantom CLI runner to ignore errors with SSL certs. + +####require +Type: Array + +An array of paths for requiring before running Mocha tests. Useful for +pre-requires that manipulate Mocha's global environment (e.g.g making Sinon +globally available). + +####username +Type: String + +The Sauce Labs username to use. Defaults to value of env var `SAUCE_USERNAME`. + +####key +Type: String + +The Sauce Labs API key to use. Defaults to value of env var `SAUCE_ACCESS_KEY`. + +####secureCommands +Type: Boolean (Default: false) + +If true, it will use saucelabs, with default `hostname` set to `127.0.0.1` and `port` set to `4445` +in order to send selenium commands through Sauce Connect tunnel (more info +[here](https://saucelabs.com/docs/connect#selenium-relay)). + +####autoInstall +Type: Boolean + +If `true` this will download Selenium and Chrome Driver to run tests locally. + +####hostname +Type: String + +If specified, it will connect that selenium server instead of `ondemand.saucelabs.com`. + +####port +Type: Int + +Selenium server port. Should be used in conjonction with ``hostname``. + +####identifier +Type: Number + +A Unique identifier for the generated tunnel to Sauce Labs. Will be automatically +generated if not specified. Useful for connected to existing Sauce tunnels. + +####concurrency +Type: Int + +The number of concurrent browser sessions to spin up on Sauce Labs. Defaults to 1. + +####tunneled +Type: Boolean +A boolean value to indicate if the tunnel is to be created or not. + +####tunnelFlags +Type: Array +An array of option flags for Sauce Connect. See the list of available options + [here](https://saucelabs.com/docs/connect#connect-flags). + +####testName +Type: String + +The name of the test, as reported to Sauce Labs. + +####testTags +Type: [String] + +An array of tags to associate with the test, as reported to Sauce Labs. + +####browsers +Type: [Object] + +An array of objects specifying which browser options should be passed to Sauce Labs. +Unless a test is run against Phantom (e.g. `usePhantom` is true), this option +*must* be specified. Each browser hash should specify: `browserName`. For saucelabs tests, `platform`, +and `version` are also required. + +##Contributing +In lieu of a formal styleguide, take care to maintain the existing coding style. Add +unit tests for any new or changed functionality. Lint and test your code using `grunt`. + +##Release History +See History.md + +##Contributors + - Author: [Justin Reidy](https://github.com/jmreidy) + - You? + +##License +Copyright (c) 2014 Justin Reidy + +Licensed under the MIT license. diff --git a/package.json b/package.json index f0360ce..99d1e05 100644 --- a/package.json +++ b/package.json @@ -1,28 +1,49 @@ { - "name": "grunt-mocha-wd", - "version": "0.1.0", - "description": "Grunt task to run Mocha tests against a Selenium grid", - "main": "index.js", + "name": "grunt-mocha-webdriver", + "description": "Grunt task to run Mocha tests against PhantomJS or Saucelabs", + "version": "1.2.2", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "grunt test" }, - "repository": "", "author": "Justin Reidy ", - "license": { - "type": "MIT", - "url": "https://github.com/jmreidy/grunt-mocha-sauce/blob/master/LICENSE-MIT" + "contributors": [ + { + "name": "Justin Reidy", + "email": "jmreidy@rzrsharp.net" + } + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/jmreidy/grunt-mocha-webdriver.git" }, + "bugs": "https://github.com/jmreidy/grunt-mocha-webdriver/issues/", + "keywords": [ + "grunt", + "gruntplugin", + "mocha", + "e2e", + "webdriver", + "phantomjs", + "saucelabs" + ], + "main": "index.js", "dependencies": { - "wd": "~0.0.32" + "wd": "^0.3.3", + "async": "^0.9.0", + "mocha": "^1.20.0", + "sauce-tunnel": "^2.1.0", + "selenium-launcher": "git+https://github.com/wookiehangover/nodejs-selenium-launcher.git" }, "devDependencies": { - "grunt-contrib-jshint": "~0.4.3", - "async": "~0.2.8", - "mocha": "~1.9.0", - "grunt": "~0.4.0" + "grunt-contrib-jshint": "~0.10.0", + "phantomjs": "~1.9.2" }, "peerDependencies": { - "mocha": "~1.9.0", - "grunt": "~0.4.0" + "grunt": "~0.4.0", + "phantomjs": "~1.9.2" + }, + "engines": { + "node": ">= 0.8.0" } } diff --git a/tasks/Sauce-Connect.jar b/tasks/Sauce-Connect.jar deleted file mode 100644 index 328ebb7..0000000 Binary files a/tasks/Sauce-Connect.jar and /dev/null differ diff --git a/tasks/grunt-mocha-wd.js b/tasks/grunt-mocha-wd.js index 4884240..f85ab7b 100644 --- a/tasks/grunt-mocha-wd.js +++ b/tasks/grunt-mocha-wd.js @@ -1,199 +1,343 @@ 'use strict'; var wd = require('wd'); -var request = require('request').defaults({jar:false}); -var Mocha = require('mocha'); -var path = require('path'); -var proc = require('child_process'); +var SauceTunnel = require('sauce-tunnel'); +var _ = require('grunt').util._; +var async = require('async'); +var runner = require('./lib/mocha-runner'); +var phantom = require('phantomjs'); +var childProcess = require('child_process'); +var BaseReporter = require('mocha').reporters.Base; +var seleniumLauncher = require('selenium-launcher'); +var color = BaseReporter.color; /* * grunt-mocha-sauce * https://github.com/jmreidy/grunt-mocha-sauce * - * Copyright (c) 2013 Justin Reidy + * Copyright (c) 2014 Justin Reidy * Licensed under the MIT license */ - module.exports = function (grunt) { - grunt.registerMultiTask('mochaWD', 'Run mocha tests against PhantomJS and SauceLabs', function () { + grunt.registerMultiTask('mochaWebdriver', 'Run mocha tests against PhantomJS and Sauce Labs', function () { var opts = this.options({ username: process.env.SAUCE_USERNAME, key: process.env.SAUCE_ACCESS_KEY, identifier: Math.floor((new Date()).getTime() / 1000 - 1230768000).toString(), + concurrency: 1, + testName: "", + testTags: [], + build: process.env.TRAVIS_BUILD_NUMBER || process.env.BUILD_NUMBER || process.env.BUILD_TAG || process.env.CIRCLE_BUILD_NUM, + tunnelFlags: null, tunneled: true, - testTimeout: (1000 * 60 * 5), - tunnelTimeout: 120, - testInterval: 1000 * 5, - testReadyTimeout: 1000 * 5, - testname: "", - tags: [], - browsers: [{}] + secureCommands: false, + phantomCapabilities: {}, + phantomFlags: [] }); grunt.util.async.forEachSeries(this.files, function (fileGroup, next) { - var tunnel = new SauceTunnel(opts.username, opts.key, opts.identitied, opts.tunneled, opts.tunnelTimeout); - grunt.log.writeln("=> Connecting to Saucelabs ..."); - tunnel.start(function(isCreated) { - if (!isCreated) return next(false); - grunt.log.ok("Connected to Saucelabs."); - var browser = wd.remote(); - - - browser.init({}, function () { - runTestsForBrowser(opts, fileGroup, browser, tunnel, next); - }); - }); + if (opts.usePhantom) { + runTestsOnPhantom(fileGroup, opts, next); + } + else if (opts.autoInstall || opts.hostname && !opts.secureCommands) { + runTestsOnSelenium(fileGroup, opts, next); + } + else { + runTestsOnSaucelabs(fileGroup, opts, next); + } }, this.async()); }); - function runTestsForBrowser(opts, fileGroup, browser, tunnel, next) { - var mocha = new Mocha(opts); + function runTestsForBrowser(opts, fileGroup, browser, next) { var onTestFinish = function(err) { - browser.quit(); - if (tunnel) { - tunnel.stop(function () { - next(err); - }); + // report mocha test failure + var callback = function() { + next(err); + }; + if (opts.usePromises) { + browser.quit().nodeify(callback); } else { - next(err); + browser.quit(callback); } }; + opts.wd = wd; + runner(opts, fileGroup, browser, grunt, onTestFinish); + } - mocha.suite.on('pre-require', function (context, file, m) { - context.browser = browser; + function configureLogEvents(tunnel) { + var methods = ['write', 'writeln', 'error', 'ok', 'debug']; + methods.forEach(function (method) { + tunnel.on('log:'+method, function (text) { + grunt.log[method](text); + }); + tunnel.on('verbose:'+method, function (text) { + grunt.verbose[method](text); + }); }); + } - grunt.file.expand({filter: 'isFile'}, fileGroup.src).forEach(function (f) { - mocha.addFile(path.resolve(f)); - }); + function runTestsOnPhantom(fileGroup, opts, next) { + var browser; + var phantomPort = opts.phantomPort || 4444; + var phantomCapabilities = opts.phantomCapabilities; + if (opts.usePromises) { + browser = wd.promiseChainRemote({port: phantomPort}); + } + else { + browser = wd.remote({port: phantomPort}); + } + grunt.log.writeln('Running webdriver tests against PhantomJS.'); - try { - mocha.run(function(errCount) { - onTestFinish(errCount === 0); + startPhantom(phantomPort, opts, function (err, phantomProc) { + if (err) { return next(err); } + browser.init(phantomCapabilities, function () { + runTestsForBrowser(opts, fileGroup, browser, function (err) { + + function onClose() { + grunt.log.writeln('Phantom exited.'); + next(err); + } + + // the process might already be closed due to an internal crash, + // so check first is already killed to avoid a deadlock. + if (phantomProc.killed) { + onClose(); + } + else { + phantomProc.on('close', function() { + onClose(); + }); + + phantomProc.kill(); + } + }); }); - } catch (e) { - grunt.log.error("Mocha failed to run"); - grunt.log.error(e.stack); - onTestFinish(false); - } + }); + } - function SauceTunnel(user, key, identifier, tunneled, tunnelTimeout) { - this.user = user; - this.key = key; - this.identifier = identifier; - this.tunneled = tunneled; - this.tunnelTimeout = tunnelTimeout; - this.baseUrl = ["https://", this.user, ':', this.key, '@saucelabs.com', '/rest/v1/', this.user].join(""); - }; - - SauceTunnel.prototype.openTunnel = function(callback) { - var args = ["-jar", __dirname + "/Sauce-Connect.jar", this.user, this.key, "-i", this.identifier]; - this.proc = proc.spawn('java', args); - var calledBack = false; - - this.proc.stdout.on('data', function(d) { - var data = typeof d !== 'undefined' ? d.toString() : ''; - if (typeof data === 'string' && !data.match(/^\[-u,/g)) { - grunt.verbose.debug(data.replace(/[\n\r]/g, '')); + function startPhantom(port, opts, next) { + var phantomOpts = opts.phantomFlags || []; + phantomOpts.push('--webdriver', port); + if (opts.ignoreSslErrors) { + phantomOpts.push('--ignore-ssl-errors', 'yes'); + } + + var phantomProc = childProcess.spawn(phantom.path, phantomOpts); + var stopPhantomProc = function() { + phantomProc.kill(); + }; + // stop child phantomjs process when interrupting master process + process.on('SIGINT', stopPhantomProc); + + phantomProc.on('exit', function () { + process.removeListener('SIGINT', stopPhantomProc); + }); + phantomProc.stdout.setEncoding('utf8'); + var onPhantomData = function (data) { + if (data.match(/running/i)) { + grunt.log.writeln('PhantomJS started.'); + phantomProc.stdout.removeListener('data', onPhantomData); + next(null, phantomProc); } - if (typeof data === 'string' && data.match(/Connected\! You may start your tests/)) { - grunt.verbose.ok('=> Sauce Labs Tunnel established'); - if (!calledBack) { - calledBack = true; - callback(true); - } + else if (data.match(/error/i)) { + grunt.log.error('Error starting PhantomJS'); + next(new Error(data)); } - }); + }; + phantomProc.stdout.on('data', onPhantomData); + } + + /** + * Extracts wd connection params from grunt options + * + * Utility function that returns named params + * that can be used by wd.remote or wd.promiseChainRemote + */ + function extractConnectionInfo(opts) { + var params = {}; + var defaultServer = opts.secureCommands ? + { hostname: '127.0.0.1', port: 4445 } : + { hostname: 'ondemand.saucelabs.com', port: 80 }; - this.proc.stderr.on('data', function(data) { - grunt.log.error(data.toString().replace(/[\n\r]/g, '')); + params.hostname = opts.hostname || defaultServer.hostname; + params.port = opts.port || defaultServer.port; + if (opts.key) { + params.accessKey = opts.key; + } + ['auth', 'username'].forEach(function(prop) { + if (opts[prop]) { + params[prop] = opts[prop]; + } }); + return params; + } + + /** + * Init a browser + */ + function initBrowser(browserOpts, opts, mode, fileGroup, cb) { + var funcName = opts.usePromises ? 'promiseChainRemote': 'remote'; + var browser = wd[funcName](extractConnectionInfo(opts)); + browser.browserTitle = browserOpts.browserTitle; + browser.mode = mode; - this.proc.on('exit', function(code) { - grunt.verbose.ok('Sauce Labs Tunnel disconnected ', code); - if (!calledBack) { - calledBack = true; - callback(false); + browser.mode = mode; + if (opts.testName) { + browserOpts.name = opts.testName; + } + if (opts.testTags) { + browserOpts.tags = opts.testTags; + } + if (opts.build) { + browserOpts.build = opts.build; + } + if (opts.identifier && opts.tunneled) { + browserOpts['tunnel-identifier'] = opts.identifier; + } + + browser.init(browserOpts, function (err) { + if (err) { + grunt.log.error(err); + grunt.log.error('Could not initialize browser - ' + mode); + grunt.log.error('Make sure Sauce Labs supports the following browser/platform combo' + + ' on ' + color('bright yellow', 'saucelabs.com/platforms') + ': ' + browserOpts.browserTitle); + return cb(false); } + runTestsForBrowser(opts, fileGroup, browser, cb); }); - }; - - SauceTunnel.prototype.getTunnels = function(callback) { - request({ - url: this.baseUrl + '/tunnels', - json: true - }, function(err, resp, body) { - callback(body); + } + + // used by runTestsOnSaucelabs or runTestsOnSeleni + var browserFailed = false; + + function pushToQueue(testQueue, browserOpts, browserTitle) { + testQueue.push(browserOpts, function (err) { + if (err) { + browserFailed = true; + } + grunt.log.verbose.writeln('%s test complete, %s tests remaining', browserTitle, testQueue.length()); }); - }; + } - SauceTunnel.prototype.killAllTunnels = function(callback) { - if (!this.tunneled) { - return callback(); + function startBrowserTests(testQueue, mode, browserOpts) { + var browserTitle = ''+browserOpts.browserName; + if (browserOpts.version) { + browserTitle = browserTitle + ' ' + browserOpts.version; + } + if (browserOpts.platform) { + browserTitle = browserTitle + ' on ' + browserOpts.platform; } - var me = this; - grunt.verbose.debug("Trying to kill all tunnels"); - this.getTunnels(function(tunnels) { - (function killTunnel(i) { - if (i >= tunnels.length) { - setTimeout(callback, 1000 * 5); - return; + browserOpts.browserTitle = browserTitle; + grunt.log.verbose.writeln('Queueing ' + browserTitle + ' - ' + mode); + pushToQueue(testQueue, browserOpts, browserTitle); + } + + function runTestsOnSaucelabs(fileGroup, opts, next) { + if (opts.browsers) { + var tunnel = new SauceTunnel(opts.username, opts.key, opts.identifier, opts.tunneled, opts.tunnelFlags); + configureLogEvents(tunnel); + + grunt.log.writeln("=> Connecting to Sauce Labs ..."); + + tunnel.start(function(isCreated) { + if (!isCreated) { + return next(new Error('Failed to create Sauce tunnel.')); } - grunt.log.writeln("=> Killing tunnel %s", tunnels[i]); - request({ - method: "DELETE", - url: me.baseUrl + "/tunnels/" + tunnels[i], - json: true - }, function() { - killTunnel(i + 1); + grunt.log.ok("Connected to Sauce Labs."); + + var testQueue = async.queue(function (browserOpts, cb) { + // browserOpts, opts, usePromises, errorMsg, fileGroup, cb + initBrowser(browserOpts, + opts, + "saucelabs", + fileGroup, + cb); + }, opts.concurrency); + + opts.browsers.forEach(function(browserOpts) { + startBrowserTests(testQueue, 'saucelabs', browserOpts); }); - }(0)); - }); - }; - SauceTunnel.prototype.start = function(callback) { - var me = this; - if (!this.tunneled) { - return callback(true); + testQueue.drain = function () { + var err; + if (browserFailed) { + err = new Error('One or more tests on Sauce Labs failed.'); + } + tunnel.stop(function () { + next(err); + }); + }; + }); } - this.getTunnels(function(tunnels) { - if (!tunnels) { - grunt.verbose.error("=> Could not get tunnels for Sauce Labs. Still continuing to try connecting to Sauce Labs".inverse); - } - if (tunnels && tunnels.length > 0) { - grunt.log.writeln("=> Looks like there are existing tunnels to Sauce Labs, need to kill them. TunnelID:%s", tunnels); - (function waitForTunnelsToDie(retryCount) { - if (retryCount > 5) { - grunt.verbose.writeln("=> Waited for %s retries, now trying to shut down all tunnels and try again", retryCount); - me.killAllTunnels(function() { - me.start(callback); - }); + else { + grunt.log.writeln('No browsers configured for running on Saucelabs.'); + } + } + + function runTestsOnSelenium(fileGroup, opts, next) { + var seleniumServers = []; + if (opts.browsers) { + grunt.log.writeln("=> Connecting to Selenium ..."); + var testQueue = async.queue(function (browserOpts, cb) { + function afterSelenium () { + var browser = initBrowser(browserOpts, + opts, + "selenium", + fileGroup, + cb); + } + if (opts.autoInstall) { + seleniumLauncher({ chrome: browserOpts.browserName === 'chrome' }, function(err, selenium) { + seleniumServers.push(selenium); + grunt.log.writeln('Selenium Running'); + if(err){ + selenium.exit(); + grunt.fail.fatal(err); + return; + } + opts.port = selenium.port; + afterSelenium(); + }, opts.concurrency); } else { - grunt.verbose.debug("=> %s. Sauce Labs tunnels already exist, will try to connect again %s milliseconds.", retryCount, me.tunnelTimeout / 5); - setTimeout(function() { - waitForTunnelsToDie(retryCount + 1); - }, me.tunnelTimeout / 5); + afterSelenium(); } - }(0)); - } else { - grunt.verbose.writeln("=> Sauce Labs trying to open tunnel".inverse); - me.openTunnel(function(status) { - callback(status); - }); - } - }); - }; + }, opts.autoInstall ? Object.keys(opts.browsers).length : 1); - SauceTunnel.prototype.stop = function(callback) { - if (this.proc) { - this.proc.kill(); + opts.browsers.forEach(function (browserOpts) { + startBrowserTests(testQueue, 'selenium', browserOpts); + }); + + testQueue.drain = function () { + var err; + seleniumServers.forEach(function(seleniumServer) { + seleniumServer.kill(); + }); + if (browserFailed) { + err = new Error('One or more tests on Selenium failed.'); + } + next(err); + }; } - this.killAllTunnels(function() { - callback(); - }); - }; + else { + grunt.log.writeln('No browsers configured for running on Saucelabs.'); + } + } +}; + +//wd.js monkey patch for clearer errors +var _newError = wd.webdriver.prototype._newError; +wd.webdriver.prototype._newError = function (opts) { + var err = _newError(opts); + try { + err = new Error(err.cause.value.message + .match(/([\s\S]*) caused/)[1] + .match(/'([\s\S]*)'\n/)[1] + ); + } + catch (e) {} + return err; }; diff --git a/tasks/lib/mocha-runner.js b/tasks/lib/mocha-runner.js new file mode 100644 index 0000000..d9541e5 --- /dev/null +++ b/tasks/lib/mocha-runner.js @@ -0,0 +1,91 @@ +'use strict'; + +var Mocha = require('mocha'); +var color = Mocha.reporters.Base.color; +var path = require('path'); +var Module = require('module'); +var generateSauceReporter = require('./mocha-sauce-reporter'); +var fs = require('fs'); +var path = require('path'); +var domain = require('domain'); + +module.exports = function (opts, fileGroup, browser, grunt, onTestFinish) { + //browserTitle means we're on a SL test + if (browser.browserTitle) { + if (opts.reporter && opts.concurrency > 1) { + if (!opts.customReporter) { + console.log(color('medium', 'ignoring "reporter" option because concurrency is > 1')); + opts.reporter = generateSauceReporter(browser); + } + } + if (opts.customReporter) { + // to allow customReporter + // check ./mocha-sauce-reporter or https://github.com/saadtazi/gmwd-teamcity-reporter + opts.originalReporter = opts.originalReporter || opts.reporter; + opts.reporter = require(opts.originalReporter)(browser, opts); + } + + } + + var cwd = process.cwd(); + module.paths.push(cwd, path.join(cwd, 'node_modules')); + if (opts && opts.require) { + var mods = opts.require; + if (!(mods instanceof Array)) { mods = [mods]; } + mods.forEach(function(mod) { + var abs = fs.existsSync(mod) || fs.existsSync(mod + '.js'); + if (abs) { + mod = path.resolve(mod); + } + require(mod); + }); + } + + var mocha = new Mocha(opts); + + mocha.suite.on('pre-require', function () { + this.ctx.browser = browser; + this.ctx.wd = opts.wd; + this.ctx.mochaOptions = opts; + }); + + grunt.file.expand({filter: 'isFile'}, fileGroup.src).forEach(function (f) { + var filePath = path.resolve(f); + if (Module._cache[filePath]) { + delete Module._cache[filePath]; + } + mocha.addFile(filePath); + }); + + try { + if (mocha.files.length) { + mocha.loadFiles(); + } + + var runDomain = domain.create(); + var mochaOptions = mocha.options; + var mochaRunner = new Mocha.Runner(mocha.suite); + new mocha._reporter(mochaRunner); + mochaRunner.ignoreLeaks = (mochaOptions.ignoreLeaks !== false); + mochaRunner.asyncOnly = mochaOptions.asyncOnly; + if (mochaOptions.grep) { + mochaRunner.grep(mochaOptions.grep, mochaOptions.invert); + } + if (mochaOptions.globals) { + mochaRunner.globals(mochaOptions.globals); + } + runDomain.on('error', mochaRunner.uncaught.bind(mochaRunner)); + runDomain.run(function () { + mochaRunner.run(function(errCount) { + var err; + if (errCount !==0) { + err = new Error('Tests encountered ' + errCount + ' errors.'); + } + onTestFinish(err); + }); + }); + } catch (e) { + grunt.log.error("Mocha failed to run"); + onTestFinish(e); + } +}; diff --git a/tasks/lib/mocha-sauce-reporter.js b/tasks/lib/mocha-sauce-reporter.js new file mode 100644 index 0000000..bba0f86 --- /dev/null +++ b/tasks/lib/mocha-sauce-reporter.js @@ -0,0 +1,64 @@ +'use strict'; + +var BaseReporter = require('mocha').reporters.Base; +var color = BaseReporter.color; + +module.exports = function (browser) { + + var formatErrorMessage = function (e) { + var msg = e.title; + if (e.err) { + if (e.err.message) { + msg += (': ' + e.err.message + ' '); + } + if (e.err.stack) { + msg += (e.err.stack); + } + } + return msg; + }; + + var SauceReporter = function(runner) { + BaseReporter.call(this, runner); + + var numberTests = 0; + var passes = 0; + var failures = 0; + var failInfo = {}; + + runner.on('test end', function () { + numberTests++; + }); + + runner.on('pass', function () { passes++; }); + runner.on('fail', function (e) { + failures++; + failInfo[runner.suite.title] = failInfo[runner.suite.title] || []; + failInfo[runner.suite.title].push(formatErrorMessage(e)); + }); + + runner.on('end', function(){ + console.log(); + console.log('Tests complete for ' + browser.browserTitle); + console.log(color('bright pass', '%d %s'), numberTests, 'tests run.'); + if (passes) { + console.log(color('green', '%d %s'), passes, 'tests passed.'); + } + if (failures) { + console.log(color('fail', '%d %s'), failures, 'tests failed.'); + for(var i in failInfo) { + console.log(color('fail', i + '\n\t' + failInfo[i].join('\n\t'))); + } + } + if (browser.mode === 'saucelabs') { + browser.sauceJobStatus(failures === 0); + console.log('Test video at: https://saucelabs.com/tests/' + browser.sessionID); + } + console.log(); + }); + }; + + SauceReporter.prototype.__proto__ = BaseReporter.prototype; + + return SauceReporter; +}; diff --git a/test/phantom-capabilities.js b/test/phantom-capabilities.js new file mode 100644 index 0000000..bbe33d6 --- /dev/null +++ b/test/phantom-capabilities.js @@ -0,0 +1,19 @@ +var assert = require('assert'), + fs = require('fs'), + path = require('path'); + +describe('Phantomjs browser', function () { + + + it('should allow to pass phantomjs capabilities', function (done) { + var searchBox; + var browser = this.browser; + browser.get('http://beta.saadtazi.com/api/echo/headers.html') + .elementsByCssSelector('.grunt-mocha-webdriver-header') + .then(function(elts) { + assert.equal(elts.length, 1); + }) + .then(done, done); + }); +}); + diff --git a/test/phantom-flags.js b/test/phantom-flags.js new file mode 100644 index 0000000..0d5a775 --- /dev/null +++ b/test/phantom-flags.js @@ -0,0 +1,21 @@ +var assert = require('assert'), + fs = require('fs'), + path = require('path'); + +describe('Phantomjs browser', function () { + + after(function() { + fs.unlinkSync('phantom.log'); + }); + + it('should allow to pass phantomjs start flags', function (done) { + var searchBox; + var browser = this.browser; + browser.get('http://www.google.com') + .then(function() { + assert.ok(fs.statSync('phantom.log').isFile()); + }) + .nodeify(done); + }); +}); + diff --git a/test/promiseAPI.js b/test/promiseAPI.js new file mode 100644 index 0000000..e53ba58 --- /dev/null +++ b/test/promiseAPI.js @@ -0,0 +1,25 @@ +var assert = require('assert'); + +describe('Promise-enabled WebDriver', function () { + + describe('injected browser executing a Google Search', function () { + + it('performs as expected', function (done) { + var searchBox; + var browser = this.browser; + browser.get('http://google.com') + .elementByName('q') + .then(function (el) { + searchBox = el; + return searchBox.type('webdriver'); + }) + .then(function () { + return searchBox.getAttribute('value'); + }) + .then(function (val) { + return assert.equal(val, 'webdriver'); + }) + .then(done, done); + }); + }); +}); diff --git a/test/requires.js b/test/requires.js new file mode 100644 index 0000000..39f0055 --- /dev/null +++ b/test/requires.js @@ -0,0 +1,11 @@ +/*global globalVar:false*/ + +var assert = require('assert'); + +describe('A Mocha test run by grunt-mocha-sauce', function () { + + it('can reference globals provided in a pre-require', function () { + assert.ok(globalVar); + }); + +}); diff --git a/test/sanity.js b/test/sanity.js index 11ce8ed..fffddbd 100644 --- a/test/sanity.js +++ b/test/sanity.js @@ -1,20 +1,30 @@ var assert = require('assert'); var async = require('async'); -describe('A Mocha test run by grunt-mocha-sauce', function () { +describe('A Mocha test run by grunt-mocha-webdriver', function () { it('has a browser injected into it', function () { - assert.ok(browser); + assert.ok(this.browser); + }); + + it('has wd injected into it for customizing', function () { + assert.equal(this.wd, require('wd')); + }); + + it('has mochaOptions injected into it for reuse', function () { + assert.equal(this.mochaOptions.timeout, 1000 * 60 * 3); }); }); + describe('A basic Webdriver example', function () { describe('injected browser executing a Google Search', function () { it('performs as expected', function (done) { var searchBox; + var browser = this.browser; async.waterfall([ function(cb) { browser.get('http://google.com', cb); diff --git a/test/support/index.js b/test/support/index.js new file mode 100644 index 0000000..699b911 --- /dev/null +++ b/test/support/index.js @@ -0,0 +1 @@ +global.globalVar = 'globalVar'; diff --git a/test/tunnelOptions.js b/test/tunnelOptions.js new file mode 100644 index 0000000..dc5a58a --- /dev/null +++ b/test/tunnelOptions.js @@ -0,0 +1,17 @@ +var assert = require('assert'), + fs = require('fs'), + path = require('path'); + +describe('Tunnel option flags', function () { + + + it('should perform as expected', function (done) { + var searchBox; + var browser = this.browser; + browser.get('http://google.com') + .then(function() { + assert(fs.existsSync(path.join(__dirname, '../sauce_connect.log.custom')), 'custom log file does not exist'); + }) + .then(done, done); + }); +});