diff --git a/.gitignore b/.gitignore index fc829f9..b08b0cf 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,4 @@ dependency-reduced-pom.xml dist/ .env .DS_Store - +.vscode diff --git a/README.md b/README.md index 768f9fc..4407e90 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,26 @@ # VisualReview API for Protractor Provides an API to send screenshots to [VisualReview](https://github.com/xebia/VisualReview) from your [Protractor](https://github.com/angular/protractor) tests. See [the example](example-project/README.md) for a quick demo. + ## Requirements -Requires Protractor 1.4.0 or higher. +* Requires Protractor 1.4.0 or higher. +* Requires node 6.4.0 or higher. + -## Getting started +## Usage +### Getting started First add visualreview-protractor to your protractor project's dependencies. ```shell -npm install visualreview-protractor --save-dev +npm install visualreview-protractor --save ``` -Then configure visualreview-protractor in your protractor configuration file. Here's an example: +### Configure Visual Review on the Protractor config file +To configure visualreview-protractor in your protractor configuration file you will need to add the following information: ```javascript const VisualReview = require('visualreview-protractor'); -var vr = new VisualReview({ +const vr = new VisualReview({ hostname: 'localhost', port: 7000, scheme: 'https', //(optional: http|https, http is used if not specified) @@ -26,78 +31,228 @@ exports.config = { [..], - /* - Both .initRun and .cleanup return a q-style promise. If you have some - other things happening in before- and afterLaunch, be sure to - return these promise objects. - */ - beforeLaunch: function () { - // Creates a new run under project name 'myProject', suite 'mySuite'. - // Since VisualReview version 0.1.1, projects and suites are created on the fly. - return vr.initRun('myProject', 'mySuite'); - - // Additionally you can provide the branchName this run has been initiated on. - // This defaults to "master". Uses this to create a baseline for a specific feature branch - // Note that this feature requires VisualReview server version 0.1.5 or higher. - // For example: - // return vr.initRun('myProject', 'mySuite', 'my-feature-branch'); - }, - - afterLaunch: function (exitCode) { - // finalizes the run, cleans up temporary files - return vr.cleanup(exitCode); - }, - params: { visualreview: vr // provides API to your tests } } ``` -Now you can use the visualreview-protractor API in your tests. For example: +Optionally you can configure default parameters to be used in each run. + +```javascript +new VisualReview({ + hostname: 'localhost', + port: 7000, + projectName: 'myProject', + compareSettings: { + precision: 90 + } +}); +``` + +Note that `host` and `port` should only be defined here. The remaining can be overwritten by run or by screenshot. + +### Configure a run on your tests +Now you can use the visualreview-protractor API in your tests. + +Start by initiating a run under the current project and the given suite. Remember to return the promises from `.initRun()` and `.cleanup()`. + +```javascript +const vr = browser.params.visualreview; +const vrRun = vr.getVisualReviewRun({ + suiteName: 'mySuite', + compareSettings: { + precision: 50 + } +}); + +describe('angularjs homepage', function () { + beforeAll(function () { + // Creates a new run under project name 'myProject', suite 'mySuite'. + return vrRun.initRun(); + }); + + afterAll(function () { + return vrRun.cleanup(); + }); +}); +``` + +### Take a Screenshot + +Now you can use the run instance to take screenshots. + +To take a screenshot of the viewport: ```javascript -var vr = browser.params.visualreview; describe('angularjs homepage', function() { - it('should open the homepage', function() { + it('should open the homepage', function () { browser.get('https://docs.angularjs.org'); - vr.takeScreenshot('AngularJS-homepage'); + vrRun.takeScreenshot('AngularJS-homepage'); }); }); ``` -## Config +To take a screenshot of the viewport, excluding some parts: -The VisualReview accepts a config object such as: +```javascript +describe('angularjs homepage', function() { + it('should open the homepage', function () { + browser.get('https://docs.angularjs.org'); + vrRun.takeScreenshot('AngularJS-homepage', { + exclude: [ + { x: 0, y: 0, height: 100, width: 100 }, + element(by.css('.main-body-grid div.grid-right')), + element(by.css('.brand')) + ] + }); + }); +}); ``` + +To take a screenshot of a single element, excluding some parts: + +```javascript +describe('angularjs homepage', function() { + it('should open the homepage', function () { + browser.get('https://docs.angularjs.org'); + + vrRun.takeScreenshot('Header', { + include: element(by.css('.main-body-grid div.grid-left')), + exclude: [ + element(by.css('[href="api/ng/function/angular.bind"]')) + ] + }); + }); +}); +``` + + +## API +### `VisualReview(options)` + +Returns a `VisualReview` instance. + +#### `options` +Accepts `options` marked with "✔ Global". + +### `VisualReview.getVisualReviewRun(options)` + +Returns a `VisualReviewRun` instance. + +#### `options` +Accepts `options` marked with "✔ Run". + +### `VisualReviewRun.takeScreenshot(name, options)` + +#### `name` + +Type: `string` +Default: `undefined` + +The name of the project. + +#### `options` +Accepts `options` marked with "✔ Screenshot". + + +## Options + +The VisualReview accepts a config object such as: + +```javascript { hostname: 'localhost', - port: 7000 + port: 7000, + projectName: 'myProject', + suiteName: 'mySuite', + disabled: false, + compareSettings: { + precision: 90 + }, + propertiesFn: () => ({ someProperty: 'someValue' }), + include: element(by.css('.some-element')), + exclude: [ + { x: 0, y: 0, height: 100, width: 100 }, + element(by.css('.some-element-child')) + ] } ``` -Other options are: +### `hostname` -* disabled, default false, a boolean value whether to disable the actual calls to the VisualReview object. -* propertiesFn, a function with a capabilities argument that is used to uniquely identify a screenshot. For example the following configuration omits the browser version as a screenshot identifying property: +Type: `string` +Default: `undefined` +Support: ✔ Global | ✘ Run | ✘ Screenshot -``` -propertiesFn: function (capabilities) { - return { - 'os': capabilities.get('platform'), - 'browser': capabilities.get('browserName') - }; - } -``` +The hostname of the VisualReview server. -* compareSettings, to define the precision of each pixel comparison. The value '0' will result in a failure whenever a difference has been found. Default '0'. This feature requires VisualReview server version 0.1.5 or higher. +### `port` + +Type: `number` +Default: `undefined` +Support: ✔ Global | ✘ Run | ✘ Screenshot + +The port of the VisualReview server. + +### `projectName` + +Type: `string` +Default: `undefined` +Support: ✔ Global | ✘ Run | ✘ Screenshot + +The name of the project. + +### `disabled` + +Type: `boolean` +Default: `false` + +The name of the project. + +### `compareSettings.precision` + +Type: `number` +Default: 0 +Support: ✔ Global | ✔ Run | ✔ Screenshot + +Defines the precision of each pixel comparison. The value '0' will result in a failure whenever a difference has been found. + +### `propertiesFn` + +Type: `Function` +Default: `(capabilities) => ({ os: capabilities.get('platform'), browser: capabilities.get('browserName'), version: capabilities.get('version') });` +Support: ✔ Global | ✔ Run | ✔ Screenshot + +A function to provide the properties for each screenshot. +By default uses a function to extract the `os`, `browser`, and `version` from the browser capabilities. + +### `suiteName` + +Type: `string` +Default: `undefined` +Support: ✘ Global | ✔ Run | ✘ Screenshot + +The name of the suite. + +### `include` + +Type: `ElementFinder` +Default: `undefined` +Support: ✘ Global | ✘ Run | ✔ Screenshot + +The page element to limit the screenshot area. If not defined, the viewport area is used. + +### `exclude` + +Type: `Array` +Default: `[]` +Support: ✘ Global | ✘ Run | ✔ Screenshot + +Array of areas to exclude when comparing the screenshot with the baseline. +The areas can be defined bounding boxes or as elements matching a given element finder. -``` -compareSettings: { - precision: 7 -} -``` ## License Copyright © 2015 Xebia diff --git a/example-project/README.md b/example-project/README.md index 387e81d..81253dc 100644 --- a/example-project/README.md +++ b/example-project/README.md @@ -1,4 +1,4 @@ -# VisualReview-protractor example project +# PPBF-VisualReview-Protractor example project Example on how to use the test visual regression using the VisualReview protractor API. This demo opens a few pages and takes a few screenshots so changes to these pages can be evaluated over time. diff --git a/example-project/package.json b/example-project/package.json index 3848605..7ef7af5 100644 --- a/example-project/package.json +++ b/example-project/package.json @@ -5,8 +5,12 @@ "private": true, "license": "Apache License 2.0", "dependencies": { - "jasmine": "^2.3.1", - "protractor": "^1.6.1", - "visualreview-protractor": "^0.1.0" + "jasmine": "2.3.1", + "protractor": "5.4.1", + "webdriver-manager": "12.0.6" + }, + "scripts": { + "test": "protractor protractor.js", + "update-webdriver": "webdriver-manager update" } } diff --git a/example-project/protractor.js b/example-project/protractor.js index 33803be..e3f27d4 100644 --- a/example-project/protractor.js +++ b/example-project/protractor.js @@ -1,7 +1,15 @@ -const VisualReview = require('visualreview-protractor'); +//Change this to use the package.json version after the release +var VisualReview = require('../visualreview-protractor'); var vr = new VisualReview({ hostname: 'localhost', - port: 7000 + port: 80, + projectName: 'Example Project Name', + // Global Properties Function - Only get the Browser + propertiesFn: (capabilities) => { + return { + browser: capabilities.get('browserName') + }; + } }); exports.config = { @@ -10,21 +18,19 @@ exports.config = { 'spec.js' ], - framework: 'jasmine2', - - beforeLaunch: function() { - // Creates a new run under project name 'myProject', suite 'mySuite'. - return vr.initRun('myProject', 'mySuite'); + capabilities: { + browserName: 'chrome', + shardTestFiles: false, + maxInstances: 25 }, - afterLaunch: function(exitCode) { - // finalizes the run, cleans up temporary files - return vr.cleanup(exitCode); - }, + framework: 'jasmine2', + + seleniumAddress: null, // expose VisualReview protractor api in tests params: { - visualreview: vr + visualreview: vr } }; diff --git a/example-project/spec.js b/example-project/spec.js index 5ea0c7d..6096165 100644 --- a/example-project/spec.js +++ b/example-project/spec.js @@ -1,23 +1,149 @@ +/** + * To have short it descriptions names here are some subtitles: + * CR - Check test result + * DCR - Don't Check test result + * UTO - Use Test Options + * DUTO - Don't use test options + * UM - Use Mask + * DUM - Don't use masks + * VS - Viewport Screenshot + * ES - Element Screenshot + * OP - Overrided Properties + */ var vr = browser.params.visualreview; +var vrRun = vr.getVisualReviewRun({ + suiteName: 'Test Suite Name' +}); + +//Global Test Options +var ignoreMainGridRight = element(by.css('.main-body-grid div.grid-right')); +var ignoreHeaderBrand = element(by.css('.brand')); +var testGlobalOptions = { + exclude: [ignoreMainGridRight, ignoreHeaderBrand], + propertiesFn: (capabilities, propertiesFunction) => { + var properties = propertiesFunction(capabilities); + //Remove OS from the default properties + properties.os = capabilities.get('platform'); + return properties; + } +}; -describe('angularjs homepage', function() { +describe('AngularJS Homepage', function () { beforeAll(function () { browser.manage().window().setSize(800, 1100); + vrRun.initRun(); + + this.injectorLeftMenuLink = browser.element(by.css('[href="api/ng/function/angular.injector"]')); + this.isArrayLeftMenuLink = browser.element(by.css('[href="api/ng/function/angular.isArray"]')); }); - it('should open the homepage', function() { + beforeEach(function () { browser.get('https://docs.angularjs.org'); - vr.takeScreenshot('AngularJS-homepage'); + browser.sleep(2000); }); - it('should to go the docs', function () { - element(by.css('[href="api/ng/function/angular.injector"]')).click() - vr.takeScreenshot('Injector'); + afterAll(function () { + //only used to print link for the run results page + vrRun.cleanup(); }); - it('should edit the source', function () { - element(by.css('[href="guide/di"]')).click() - vr.takeScreenshot('Guide'); + + it('VS|DCR|DUTO|DUM - MainPage', function () { + vrRun.takeScreenshot('VS|DCR|DUTO|DUM - MainPage'); }); -}); + + it('VS|CR|DUTO|DUM - MainPage', function () { + var result = vrRun.takeScreenshot('VS|CR|DUTO|DUM - MainPage').then(s => s.getResult()); + + expect(result).toBe(true); + }); + + it('VS|CR|UTO|UM - MainPage - Ignore Main Right Grid and Brand', function () { + + var result = vrRun.takeScreenshot('VS|CR|UTO|UM - MainPage', testGlobalOptions) + .then(s => s.getResult()); + + expect(result).toBe(true); + }); + + it('VS|DCR|UTO|UM - Injector - Ignore Main Right Grid and Brand', function () { + // Click on the Left Menu injector option + this.injectorLeftMenuLink.click() + + vrRun.takeScreenshot('VS|DCR|UTO|UM - Injector', testGlobalOptions); + }); + + it('VS|CR|UTO|UM - Injector - Ignore Main Right Grid and Brand', function () { + // Click on the Left Menu injector option + this.injectorLeftMenuLink.click() + + var result = vrRun.takeScreenshot('VS|CR|UTO|UM - Injector', testGlobalOptions).then(s => s.getResult()); + + expect(result).toBe(true); + }); + + it('VS|DCR|DUTO|DUM - Injector - Ignore Main Right Grid and Brand', function () { + // Click on the Left Menu injector option + this.injectorLeftMenuLink.click() + + vrRun.takeScreenshot('VS|DCR|DUTO|DUM - Injector'); + }); + + it('VS|CR|UTO|UM - IsArray - Ignore Main Right Grid and Brand', function () { + // Click on the Left Menu isArray option + this.isArrayLeftMenuLink.click() + + vrRun.takeScreenshot('VS|CR|UTO|UM - IsArray', testGlobalOptions); + }); + + it('ES|CR|UTO|UM - Left Grid - Ignore Bind text', function () { + + var elementToTakeScreenshot = element(by.css('.main-body-grid div.grid-left')); + var elementToIgnore = browser.element(by.css('[href="api/ng/function/angular.bind"]')); + + var testOptions = { + include: elementToTakeScreenshot, + exclude: [elementToIgnore] + }; + + var result = vrRun.takeScreenshot('ES|CR|UTO|UM - Left Grid ', testOptions) + .then(s => s.getResult()) + + expect(result).toBe(true); + }); + + it('ES|CR|UTO|DUM - Left Grid', function () { + var elementToTakeScreenshot = element(by.css('.main-body-grid div.grid-left')); + var testOptions = { + include: elementToTakeScreenshot, + }; + var result = vrRun.takeScreenshot('ES|CR|UTO|DUM - Left Grid ', testOptions) + .then(s => s.getResult()) + + expect(result).toBe(true); + }); + + it('ES|CR|UTO|DUM|OP - Left Grid - Override Properties Function', function () { + /* + We are changing the properties function when creating the VisualReview object on the protractor configuration + file to have only the browserName. + We are adding the os on the suite options + And on this test we are going to remove the os. + So this screenshot should not have the OS info on the Visual Review Server + */ + var elementToTakeScreenshot = element(by.css('.main-body-grid div.grid-left')); + var testOptions = { + include: elementToTakeScreenshot, + propertiesFn: (capabilities, propertiesFunction) => { + var properties = propertiesFunction(capabilities); + delete properties.os; + return properties; + } + }; + var result = vrRun.takeScreenshot('ES|CR|UTO|DUM|OP - Left Grid ', testOptions) + .then(s => s.getResult()) + + expect(result).toBe(true); + }); +}); \ No newline at end of file diff --git a/lib/vr-client.js b/lib/vr-client.js index 6c2d5a8..18c0a98 100644 --- a/lib/vr-client.js +++ b/lib/vr-client.js @@ -14,9 +14,8 @@ * limitations under the License. */ -const util = require('util'); -const q = require('q'); -const request = require('request'); +var q = require('q'); +var request = require('request'); var _hostname, _port, _scheme, _strictSSL; @@ -28,7 +27,8 @@ module.exports = function (hostname, port, scheme, strictSSL) { return { createRun: createRun, - sendScreenshot: sendScreenshot + sendScreenshot: sendScreenshot, + getRunAnalysisByRunId: getRunAnalysisByRunId }; }; @@ -42,7 +42,7 @@ module.exports = function (hostname, port, scheme, strictSSL) { * If an error has occurred, the promise will reject with a string containing an error message. * */ -function createRun (projectName, suiteName, branchName) { +function createRun(projectName, suiteName, branchName) { return _callServer('post', 'runs', { branchName: branchName || 'master', projectName: projectName, @@ -66,41 +66,71 @@ function createRun (projectName, suiteName, branchName) { } return createdRun; - }).catch(function (error) { + }).catch((error) => { return q.reject('an error occurred while creating a new run on the VisualReview server: ' + error); }); } /** * Uploads an new screenshot to the given run. - * @param name - * @param runId - * @param metaData - * @param properties - * @param png + * @param {String} name The name/Id of the test + * @param {number} runId The runId where the screenshot belongs + * @param {JSON} metaData NOT USED NOW, but can be usefull in the future + * @param {JSON} properties Properties of the browser + * @param {*} png Screenshot + * @param {Array} mask Array of Masks that we want to add to this screenshot * @returns {Promise} a promise. If an error has occured, the promise will reject with a string containing an error message. */ -function sendScreenshot (name, runId, metaData, properties, compareSettings, png) { - return _callServer('post', 'runs/' + runId + '/screenshots', null, { +function sendScreenshot(name, runId, metaData, properties, compareSettings, png, mask) { + var requestOptions = { meta: JSON.stringify(metaData), properties: JSON.stringify(properties), - compareSettings: JSON.stringify(compareSettings), + compareSettings: compareSettings ? JSON.stringify(compareSettings) : '{}', screenshotName: name, file: { - value: new Buffer(png, 'base64'), + value: new Buffer.from(png, 'base64'), options: { filename: 'file.png', contentType: 'image/png' } } - }).catch(function (error) { - return q.reject('an error occured while sending a screenshot to the VisualReview server: ' + error); - }); + }; + + //Adds mask excluded zones if they are defined + if (mask) { + requestOptions.mask = JSON.stringify(mask); + } + return _callServer('post', 'runs/' + runId + '/screenshots', null, requestOptions) + .catch(function (error) { + return q.reject('an error occured while sending a screenshot to the VisualReview server: ' + error); + }); } -function _callServer (method, path, jsonBody, multiPartFormOptions) { - var defer = q.defer(); +/** + * Extracts the run results + * @param runId The run Id of the run that we want to get the data from + * @returns {Promise} a promise. If an error has occured, the promise will reject with a string containing an error message. + */ +function getRunAnalysisByRunId(runId) { + return _callServer('get', 'runs/' + runId + '/analysis', null) + .then(function (results) { + return results; + }) + .catch( (error) =>{ + throw new Error('An error occured while trying to get the data from the VisualReview server: ' + error); + }); +} +/** + * Generic function to call the Visual Review API + * @param {String} method GET or POST + * @param {String} path API route + * @param {JSON} jsonBody Body parameters + * @param {JSON} multiPartFormOptions To use on the POST calls that have multipart/form-data + * @returns {Promise} a promise. If an error has occured, the promise will reject with a string containing an error message. + */ +function _callServer(method, path, jsonBody, multiPartFormOptions) { + var defer = q.defer(); var requestOptions = { method: method.toUpperCase(), uri: _scheme+'://' + _hostname + ':' + _port + '/api/' + path, @@ -120,19 +150,23 @@ function _callServer (method, path, jsonBody, multiPartFormOptions) { requestOptions.formData = multiPartFormOptions; } - request(requestOptions, function (error, response, body) { - if (error) { - defer.reject(error); - } else if (parseInt(response.statusCode) >= 400 && parseInt(response.statusCode) < 600) { - defer.reject('code ' + response.statusCode + ": " + body); - } else { - try { - defer.resolve(body); - } catch (e) { - defer.reject("could not parse JSON response from server " + e); + try { + request(requestOptions, function (error, response, body) { + if (error) { + defer.reject(error); + } else if (parseInt(response.statusCode) >= 400 && parseInt(response.statusCode) < 600) { + defer.reject('code ' + response.statusCode + ": " + body); + } else { + try { + defer.resolve(body); + } catch (e) { + defer.reject("could not parse JSON response from server " + e); + } } - } - }); + }); + } catch (err) { + defer.reject('Error when trying to make the request: ' + err); + } return defer.promise; } diff --git a/package.json b/package.json index e2f7151..cc70cca 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "LICENSE", "README.md", "visualreview-protractor.js", + "visualreview-run.js", "lib/" ], "main": "visualreview-protractor.js", diff --git a/visualreview-protractor.js b/visualreview-protractor.js index dbce049..8753615 100644 --- a/visualreview-protractor.js +++ b/visualreview-protractor.js @@ -14,169 +14,99 @@ limitations under the License. */ -const fs = require('fs'); -const q = require('q'); -const request = require('request'); - -const VrClient = require('./lib/vr-client.js'); - -const RUN_PID_FILE = '.visualreview-runid.pid'; -const LOG_PREFIX = 'VisualReview-protractor: '; - -var _hostname, _port, _client, _metaDataFn, _propertiesFn, _compareSettingsFn, _scheme, _strictSSL; - -module.exports = function (options) { - _hostname = options.hostname || 'localhost'; - _port = options.port || 7000; - _scheme = options.scheme || 'http'; - _strictSSL = options.strictSSL === false ? false : true; - _disabled = options.disabled || false; - _client = new VrClient(_hostname, _port, _scheme, _strictSSL); - _metaDataFn = options.metaDataFn || function () { return {}; }; - _propertiesFn = options.propertiesFn || function (capabilities) { - return { - 'os': capabilities.get('platform'), - 'browser': capabilities.get('browserName'), - 'version': capabilities.get('version') - }; - }; - _compareSettingsFn = options.compareSettings || null; - - return { - initRun: initRun, - takeScreenshot: takeScreenshot, - cleanup: cleanup - }; -}; - /** - * Initializes a run on the given project's suite name. - * @param projectName - * @param suiteName - * @param branchName - * @returns {Promise} a promise which resolves a new Run object with the fields run_id, project_id, suite_id and branch_name. - * If an error has occurred, the promise will reject with a string containing an error message. + * Global Options + * @typedef {Object} GlobalOptions + * @property {String} projectName The project name. Can only be defined here. + * @property {String} host Host. Can only be defined here. + * @property {Number} port Host port number. Can only be defined here. + * @property {Boolean} disabled True if we don't want to take screenshots at all. + * @property {Boolean} strictSSL True to use strict ssl. + * @property {String} scheme Http or Https + * @property {function} propertiesFn function to use to extract properties. + * Can be overrided on the suite options. + * Can be overrided on the screenshot options. + * + * Suite Options + * @typedef {Object} SuiteOptions + * @property {String} suiteName The suite name. Can only be defined here. + * @property {Boolean} disabled True if we don't want to take screenshots on this suite. + * It will be overrided if the global options disabled is true. + * @property {function} propertiesFn function to use to extract properties. + * Can be overrided on the screenshot options. + * It will override the global options property. */ -function initRun (projectName, suiteName, branchName) { - if(_disabled) { - return q.resolve(); - } - return _client.createRun(projectName, suiteName, branchName).then( function (createdRun) { - if (createdRun) { - _logMessage('created run with ID ' + createdRun.run_id); - return _writeRunIdFile(JSON.stringify(createdRun)); - } - }.bind(this), - function (err) { - _throwError(err); - }); -} - -/** - * Instructs Protractor to create a screenshot of the current browser and sends it to the VisualReview server. - * @param name the screenshot's name. - * @returns {Promise} - */ -function takeScreenshot (name) { - if(_disabled) { - return q.resolve(); - } - - return browser.driver.controlFlow().execute(function () { - return q.all([_getProperties(browser), _getMetaData(browser), browser.takeScreenshot(), _readRunIdFile()]).then(function (results) { - var properties = results[0], - metaData = results[1], - compareSettings = _compareSettingsFn, - png = results[2], - run = results[3]; - - if (!run || !run.run_id) { - _throwError('VisualReview-protractor: Could not send screenshot to VisualReview server, could not find any run ID. Was initRun called before starting this test? See VisualReview-protractor\'s documentation for more details on how to set this up.'); - } - - return _client.sendScreenshot(name, run.run_id, metaData, properties, compareSettings, png) - .catch(function (err) { - _throwError('Something went wrong while sending a screenshot to the VisualReview server. ' + err); - }); - }); - }.bind(this)); -} +var VisualReviewRun = require('./visualreview-run'); /** - * Cleans up any created temporary files. - * Call this in Protractor's afterLaunch configuration function. - * @param exitCode Protractor's exit code, used to indicate if the test run generated errors. - * @returns {Promise} + * This class contains the global options for the Visual Review + * Its main purpose is to store the global options and provide the instances for the runs. + * This should be the only one to have the host, port and projectName properties. + * Even so, they can be override by the suite options. + * + * @class VisualReview */ -function cleanup (exitCode) { - if(_disabled) { - return q.resolve(); - } - - var defer = q.defer(); - - _readRunIdFile().then(function (run) { - _logMessage('test finished. Your results can be viewed at: ' + - _scheme+'://' + _hostname + ':' + _port + '/#/' + run.project_id + '/' + run.suite_id + '/' + run.run_id + '/rp'); - fs.unlink(RUN_PID_FILE, function (err) { - if (err) { - defer.reject(err); - } else { - defer.resolve(); - } - }); - }); - - return defer.promise; -} - -function _writeRunIdFile (run) { - var defer = q.defer(); - fs.writeFile(RUN_PID_FILE, run, function (err) { - if (err) { - defer.reject("VisualReview-protractor: could not write temporary runId file. " + err) - } else { - defer.resolve(run); +module.exports = class VisualReview { + + /** + * Contructor of the class + * @param {GlobalOptions} options of the project + * @example + * { + * hostname: 'localhost', + * port: 7000, + * projectName: 'EXAMPLE PROJECT', + * disabled: false, + * propertiesFn: function (capabilities) { + * return { + * os: capabilities.get('platform'), + * browser: capabilities.get('browserName') + * }; + * } + * } + */ + constructor(options) { + + if (!options.projectName) { + throw new Error('Project Name must be defined.'); + } + if (!options.hostname) { + throw new Error('Hostname must be defined.'); + } + if (!options.port) { + throw new Error('Port must be defined.'); } - }); - - return defer.promise; -} -function _readRunIdFile () { - var defer = q.defer(); + //Get Properties Function. If not defined uses the default + this.propertiesFn = options.propertiesFn || function (capabilities) { + return { + os: capabilities.get('platform'), + browser: capabilities.get('browserName'), + version: capabilities.get('version') + } + }; - fs.readFile(RUN_PID_FILE, function (err, data) { - if (err) { - defer.reject("VisualReview-protractor: could not read temporary run pid file + " + err); - } else { - defer.resolve(JSON.parse(data)); - } - }); - return defer.promise; -} + this.globalOptions = options; + } -function _getProperties (browser) { - return browser.getCapabilities() - .then(_propertiesFn) - .then(function (properties) { - return browser.manage().window().getSize().then(function (size) { - properties.resolution = size.width + 'x' + size.height; - return properties; - }); - }); -} -function _getMetaData (browser) { - return browser.getCapabilities().then(_metaDataFn); -} + /** + * Retrieves a VisualReviewRun instance that will be used individually for each test suite + * @param {SuiteOptions} suiteOptions All the suite options that the user wants to add or override + * @returns {VisualReviewRun} VisualReviewRun with the suite specific options + * @example + * { + * suiteName: 'My Suite', + * propertiesFn: (capabilities, defaultFn) => { + * var defaultCapabiolities = defaultFn; + * return def; + * } + */ + getVisualReviewRun(suiteOptions) { + return new VisualReviewRun(this.globalOptions, suiteOptions); + } -function _logMessage (message) { - console.log(LOG_PREFIX + message); -} +}; -function _throwError (message) { - throw new Error(LOG_PREFIX + message); -} diff --git a/visualreview-run.js b/visualreview-run.js new file mode 100644 index 0000000..9bd5584 --- /dev/null +++ b/visualreview-run.js @@ -0,0 +1,421 @@ +/* + Copyright 2015 Xebia + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +/** +* Global Options +* @typedef {Object} GlobalOptions +* @property {String} projectName The project name. Can only be defined on the VisualReview class. +* @property {String} host Host. Can only be defined on the VisualReview class. +* @property {Number} port Host port number. Can only be defined on the VisualReview class. +* @property {Boolean} strictSSL True to use strict ssl. +* @property {String} scheme Http or Https +* @property {Boolean} disabled True if we don't want to take screenshots at all. +* @property {function} propertiesFn function to use to extract properties. +* Can be overrided on the suite options. +* Can be overrided on the screenshot options. +* +* Suite Options +* @typedef {Object} SuiteOptions +* @property {String} suiteName The suite name. Can only be defined here. +* @property {Boolean} disabled True if we don't want to take screenshots on this suite. +* It will be overrided if the global options disabled is true. +* @property {function} propertiesFn function to use to extract properties. +* Can be overrided on the screenshot options. +* It will override the global options property. +* +* Screenshot Options +* @typedef {Object} ScreenshotOptions +* @property {Array} exclude Masks or ElementFinder to exclude from the screenshot +* @property {ElementFinder} include ElementFinder to reach the element that will be the screenshot target +* @property {function} propertiesFn function to use to extract properties +* +* Mask of an element +* @typedef {Object} Mask +* @property {Number} x X coordinate of the element +* @property {Number} y Y coordinate of the element +* @property {Number} width width of the element +* @property {Number} height heigh of the element +*/ + +var q = require('q'); +var VrClient = require('./lib/vr-client.js'); + +/** + * This class contains the suite options for the Visual Review + * Its main purpose is to store the options for the suite run . + * The host, port and projectName properties should not be defined here, although they can.If + * they are defined here they will override the global options. + * Even so, they can be override by the test options when taking the screenshot. + * + * @class VisualReviewRun + */ +module.exports = class VisualReviewRun { + + + /** + * Contructor of the class + * @param {GlobalOptions} globalOptions + * @param {SuiteOptions} suiteOptions + * @example + * { + * globalOptions:{ + * hostname: 'localhost', + * port: 7000, + * projectName: 'EXAMPLE PROJECT', + * disabled: false, + * propertiesFn: (capabilities) => { + * return { + * os: capabilities.get('platform'), + * browser: capabilities.get('browserName') + * }; + * } + * }, + * suiteOptions: { + * suiteName: 'My Suite', + * propertiesFn: (capabilities, defaultFn) => { + * var defaultCapabiolities = defaultFn; + * return def; + * } + * } + * } + */ + constructor(globalOptions, suiteOptions) { + + if (!suiteOptions.suiteName) { + throw new Error('Suite Name must by defined must be defined!!'); + } + + // Options that are set on the global Options only + this.projectName = globalOptions.projectName; + this.hostname = globalOptions.hostname; + this.port = globalOptions.port; + this.branchName = globalOptions.branchName; + this.scheme = globalOptions.scheme; + this.strictSSL = globalOptions.strictSSL; + + //Get Properties Function. If not defined uses the global + this.propertiesFn = suiteOptions.propertiesFn || globalOptions.propertiesFn; + + //Run specific options + this.suiteName = suiteOptions.suiteName; + //create the VRClient + this.client = new VrClient(this.hostname, this.port, this.scheme, this.strictSSL); + + //Disabled special case + this.disabled = !!(globalOptions.disabled || suiteOptions.disabled); + + // To be filled after the run starts + this.projectId; + this.suiteId; + this.runId; + + //Capabilities to not make a second request to browser.getCapabilities + this.capabilities; + } + + /** + * Starts a Run on the Visual Review Server + * @returns {Promise} a promise which resolves when the run is created successfuly on the server + * If an error has occurred, the promise will reject with a string containing an error message. + */ + initRun() { + //check disabled scenario + if (this.disabled) { + return; + } + + return browser.driver.controlFlow().execute(() => { + return this.client.createRun(this.projectName, this.suiteName, this.branchName).then((createdRun) => { + if (createdRun) { + this.projectId = createdRun.project_id; + this.suiteId = createdRun.suite_id; + this.runId = createdRun.run_id; + + //Log run creation into console + this._logMessage('Created run with ID ' + createdRun.run_id); + } + }).catch((err) => { + this._throwError(err); + }); + + }); + } + + /** + * Takes the screenshot and sends it to the VisualReview Server + * @param {String} name Name of the screenshot + * @param {ScreenshotOptions} options options of the screenshot + */ + takeScreenshot(name, options) { + var elementToTakeScreenshot = options && options.include ? options.include : null; + var masksToAdd = options && options.exclude ? options.exclude : null; + var propertiesFn = options && options.propertiesFn ? options.propertiesFn : this.propertiesFn; + + if (options && options.disabled) { + this._logMessage("Test screenshot is disabled. No Screenshot will be taken!."); + return { + getResult: () => { return true; } + }; + } + + return browser.driver.controlFlow().execute(() => { + + return this._getMasks(masksToAdd, elementToTakeScreenshot).then(masks => { + // send screenshot of an element + return this._takeScreenshotAndSendToClient(name, elementToTakeScreenshot, masks, propertiesFn).then((result) => { + return result; + }).catch(err => { + this._throwError(err); + }); + }); + }); + } + + /** + * Returns all the masks for the screenshot in the correct format + * + * NOTES: 1 - You can pass an array with masks or ElementFinders. + * 2 - If we are taking a screenshot to an element, the masks coordinates + * will be corrected regarding the element screenshot mask. + * @param {Array} masks Masks or elements to extract masks from + * @param {ElementFinder} relatedElement element that will have masks + * @return {Promise>} All the masks that the screenshot will have + */ + _getMasks(masks, relatedElement) { + var defer = q.defer(); + var testMasks = []; + var mask = { + excludeZones: [] + }; + + //Check masks (Elements to Exclude) + if (masks && masks.length > 0) { + var promises = []; + + masks.forEach(mk => { + if (mk.x && mk.y && mk.width && mk.height) { + testMasks.push(mk); + } else { + promises.push(this._getMaskForElement(mk)); + } + }); + + q.all(promises).then((masksProcessed) => { + //add the elements maks to the mask array + masksProcessed.forEach(m => testMasks.push(m)); + + if (relatedElement) { + + // Get the element mask to fix all the others that will be defined for the viewport screenshot + this._getMaskForElement(relatedElement).then((screenshotElementMask) => { + + testMasks.forEach(m => { + var correctedMask = { + height: m.height, + x: m.x - screenshotElementMask.x, + width: m.width, + y: m.y - screenshotElementMask.y + }; + + mask.excludeZones.push(correctedMask); + }); + + defer.resolve(mask); + }); + } else { + mask.excludeZones = testMasks; + defer.resolve(mask); + } + }); + } else { + defer.resolve({}); + }; + + return defer.promise; + }; + + /** + * Instructs Protractor to take a screenshot and sends it to the VisualReview server. + * @param {String} name the screenshot's name. + * @param {ElementFinder} element ElementFinder if you don't want to take a picture of the entire screen + * @param {Array} mask Array of masks that you want to add to the screenshot. + * @returns {Promise} Promise that will be solved when the screenshot is sent to the server. + * It returns a function that enables the user to get the result of the screenshot if he wants to. + */ + _takeScreenshotAndSendToClient(name, element, masks, propertiesFn) { + + return this._getProperties(propertiesFn).then(properties => { + var maskToUse = masks ? Object.assign(masks) : {}; + var elementToTakeScreenShot = element ? element : browser; + + return elementToTakeScreenShot.takeScreenshot().then((png) => { + + if (!this.runId) { + return q.reject('VisualReview-protractor: Could not send screenshot to VisualReview server, could not find any run ID. Was initRun called before starting this test? See VisualReview-protractor\'s documentation for more details on how to set this up.'); + } + + return this.client.sendScreenshot(name, this.runId, {}, properties, this.compareSettings, png, maskToUse) + .then(() => { + return { + getResult: () => { return this._getTestResult(name); } + }; + }) + .catch((err) => { + return q.reject('Something went wrong while sending a screenshot to the VisualReview server. ' + err); + }); + }); + }).catch((err) => { + return q.reject('Something went wrong while trying to get the capabilities for this test ' + err); + });; + } + + /** + * Extracts the mask from an element + * @param {ElementFinder} elem Element that we want to extract the mask from + * @returns {Promise} Promise that will be solkved into the mask of the element. + * If something goes wrong we throw an error. + * @example + * { + * height: 200, + * x: 0, + * width: 45, + * y: 0 + * } + */ + _getMaskForElement(elem) { + var defer = q.defer(); + elem.getSize().then((size) => { + elem.getLocation().then((location) => { + defer.resolve({ + height: size.height, + x: location.x, + width: size.width, + y: location.y + }) + }).catch((err) => { + defer.reject('Error trying to get the mask location for the element ', err); + }); + }).catch((err) => { + defer.reject('Error trying to get the mask size for the element ', err); + }); + + return defer.promise; + } + + + /** + * Gets the test result from the analysis of the run. + * @param {String} testName + * @returns {Promise} Promise that will be resolved into a boolean. True if the test passed. + */ + _getTestResult(testName) { + return browser.driver.controlFlow().execute(() => { + return this.client.getRunAnalysisByRunId(this.runId) + .then((results) => { + return this._extractResultsforTest(results, testName); + }) + .catch((err) => { + this._throwError('Something went wrong while getting the test results. ' + err); + }); + }); + } + + + /** + * Returns the test result + * Everything that is not "accepted" will be considered a failure + * @param {Object} results results of the run. + * @param {String} testName test name + * @returns {boolean} True if the screenshot was accepted. + */ + _extractResultsforTest(results, testName) { + var parsedResults = JSON.parse(results); + var tests = parsedResults.diffs.filter((test) => { + return test.after.screenshotName == testName; + }); + + if (tests.length !== 1) { + console.log(tests); + this._logMessage('There are 0 or more than 1 screenshot with this name : ', testName); + return false; + } + + return tests[0].status === "accepted"; + } + + /** + * Returns the system capabilities if they are not defined + * @returns {Object} Object that represents the system properties + */ + _getProperties(propertiesFn) { + return browser.manage().window().getSize().then((size) => { + return this._getCapabilities().then(() => { + var properties = propertiesFn(this.capabilities, this.propertiesFn); + properties.resolution = size.width + 'x' + size.height; + return properties; + }); + }); + } + + /** + * Gets the current browser Capabilities. + * Note: It will only extract the capabilities from the browser once. + * @returns {Promise} Browser Capabilities + */ + _getCapabilities() { + var defer = q.defer(); + + if (!this.capabilities) { + browser.getCapabilities().then((capabilities) => { + this.capabilities = capabilities; + defer.resolve(); + }); + } else { + defer.resolve(); + } + + return defer.promise; + } + + /** + * Logs the pretended message on the console + * @param {String} message Message to log + */ + _logMessage(message) { + console.log(`[Visual Review][${this.projectName}][${this.suiteName}][${this.runId}] ${message}`); + } + + /** + * Throws an error with the pretended message + * @param {String} message Pretended Message + */ + _throwError(message) { + throw `[Visual Review][${this.projectName}][${this.suiteName}][${this.runId}] ${message}`; + } + + /** + * Prints the location results + * NOTE: Can be used in the future to present more complete logs + * @returns {Promise} + */ + cleanup() { + if (!this.disabled) { + this._logMessage('Tests finished. Your results can be viewed at: ' + + 'http://' + this.hostname + ':' + this.port + '/#/' + this.projectId + '/' + this.suiteId + '/' + this.runId + '/rp'); + } + } +}; +