├── .npmrc ├── .npmignore ├── test ├── mocha.opts ├── .eslintrc.js ├── setup.js ├── test-utils.js ├── event-source.js ├── utils.js ├── runner │ ├── all-suites-runner.js │ ├── index.js │ └── specific-suites-runner.js ├── tests-index.js ├── reuse-loader.js ├── reporter.js ├── app.js └── tests-model.js ├── .eslintignore ├── bin └── gemini-gui ├── .eslintrc.js ├── lib ├── client │ ├── .eslintrc.js │ ├── xhr.js │ ├── index.js │ ├── suite-controls.js │ ├── section-list.js │ ├── controller.js │ └── section.js ├── views │ ├── partials │ │ ├── skip-result.hbs │ │ ├── controls.hbs │ │ ├── meta-info.hbs │ │ ├── success-result.hbs │ │ ├── error-result.hbs │ │ ├── no-reference-result.hbs │ │ ├── cswitcher.hbs │ │ ├── suite-controls.hbs │ │ ├── fail-result.hbs │ │ ├── suite.hbs │ │ └── state.hbs │ └── main.hbs ├── runner │ ├── runner.js │ ├── all-suites-runner.js │ ├── index.js │ └── specific-suites-runner.js ├── client-utils.js ├── event-source.js ├── utils.js ├── find-gemini.js ├── reuse-loader.js ├── cli.js ├── common │ └── tests-index.js ├── reporter.js ├── server.js ├── tests-model.js ├── app.js └── static │ └── main.css ├── assets └── screenshot.png ├── .gitignore ├── .travis.yml ├── AUTHORS ├── LICENSE ├── CLA.md ├── package.json ├── README.md └── CHANGELOG.md /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .* 2 | test/ 3 | assets/ 4 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require ./test/setup 2 | --recursive 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /coverage 3 | /lib/static 4 | -------------------------------------------------------------------------------- /bin/gemini-gui: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('../lib/cli').run(); 4 | -------------------------------------------------------------------------------- /test/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'gemini-testing/tests' 3 | }; 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'gemini-testing', 3 | root: true 4 | }; 5 | -------------------------------------------------------------------------------- /lib/client/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'gemini-testing/browser' 3 | }; 4 | -------------------------------------------------------------------------------- /assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/gemini-gui/HEAD/assets/screenshot.png -------------------------------------------------------------------------------- /lib/views/partials/skip-result.hbs: -------------------------------------------------------------------------------- 1 |

Test is skipped.{{#if suite.skipComment}} Reason: {{{suite.skipComment}}} {{/if}}

2 | -------------------------------------------------------------------------------- /lib/views/partials/controls.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{> cswitcher}} 3 | {{> suite-controls}} 4 | {{> meta-info}} 5 |
6 | -------------------------------------------------------------------------------- /lib/views/partials/meta-info.hbs: -------------------------------------------------------------------------------- 1 |
2 |
Meta-info
3 |
4 |
{{metaInfo}}
5 |
6 |
7 | -------------------------------------------------------------------------------- /lib/views/partials/success-result.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{> controls}} 3 |
4 | Reference image 5 |
6 |
7 | -------------------------------------------------------------------------------- /lib/views/partials/error-result.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{> suite-controls}} 4 | {{> meta-info}} 5 |
6 |
 7 |         {{stack}}
 8 |     
9 |
10 | -------------------------------------------------------------------------------- /lib/views/partials/no-reference-result.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{> controls}} 3 |
No reference image available
4 |
5 | Current image 6 |
7 |
8 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chai = require('chai'); 4 | 5 | global.sinon = require('sinon'); 6 | global.assert = chai.assert; 7 | 8 | chai.use(require('chai-as-promised')); 9 | chai.use(require('sinon-chai')); 10 | sinon.assert.expose(chai.assert, {prefix: ''}); 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | lib-cov 3 | lcov.info 4 | *.seed 5 | *.log 6 | *.csv 7 | *.dat 8 | *.out 9 | *.pid 10 | *.gz 11 | .DS_Store 12 | 13 | pids 14 | logs 15 | results 16 | build 17 | .grunt 18 | 19 | node_modules 20 | coverage 21 | 22 | /lib/static/client.js 23 | /lib/static/client.js.map.json 24 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: '4' 3 | script: 4 | - npm run lint 5 | - npm test --coverage 6 | env: 7 | global: 8 | - CXX=g++-4.8 9 | 10 | addons: 11 | apt: 12 | sources: 13 | - ubuntu-toolchain-r-test 14 | packages: 15 | - gcc-4.8 16 | - g++-4.8 17 | -------------------------------------------------------------------------------- /lib/views/partials/cswitcher.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 | -------------------------------------------------------------------------------- /lib/views/partials/suite-controls.hbs: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
5 | -------------------------------------------------------------------------------- /lib/runner/runner.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var inherit = require('inherit'); 4 | 5 | var Runner = inherit({ 6 | __constructor: function(collection) { 7 | this._collection = collection; 8 | }, 9 | 10 | run: function(runHandler) { 11 | return runHandler(this._collection); 12 | } 13 | }); 14 | 15 | module.exports = Runner; 16 | -------------------------------------------------------------------------------- /lib/runner/all-suites-runner.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var inherit = require('inherit'), 4 | Runner = require('./runner'); 5 | 6 | var AllSuitesRunner = inherit(Runner, { 7 | run: function(runHandler) { 8 | this._collection.enableAll(); 9 | 10 | return this.__base(runHandler); 11 | } 12 | }); 13 | 14 | module.exports = AllSuitesRunner; 15 | -------------------------------------------------------------------------------- /lib/client-utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const format = require('util').format; 4 | 5 | // this code is executed on client side, so we can not use features from es5 6 | exports.wrapLinkByTag = function(text) { 7 | return text.replace(/https?:\/\/[^\s]*/g, function(url) { 8 | return format('%s', url, url); 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /lib/runner/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('lodash'), 4 | AllSuitesRunner = require('./all-suites-runner'), 5 | SpecificSuitesRunner = require('./specific-suites-runner'); 6 | 7 | exports.create = function(collection, specificSuites) { 8 | if (_.isEmpty(specificSuites)) { 9 | return new AllSuitesRunner(collection); 10 | } 11 | return new SpecificSuitesRunner(collection, [].concat(specificSuites)); 12 | }; 13 | -------------------------------------------------------------------------------- /lib/views/partials/fail-result.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{> controls}} 3 |
4 |
Reference
5 | Reference image 6 |
7 |
8 |
Current
9 | Current image 10 |
11 |
12 |
Diff
13 | Diff image 14 |
15 |
16 | -------------------------------------------------------------------------------- /test/test-utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const format = require('util').format; 4 | 5 | const mkDummyCollection_ = () => { 6 | const collection = sinon.stub().named('SuiteCollection'); 7 | 8 | ['enable', 'enableAll', 'disable', 'disableAll'].forEach((method) => { 9 | collection[method] = sinon.stub().named(format('SuiteCollection.%s()', method)); 10 | }); 11 | 12 | collection.topLevelSuites = sinon.stub() 13 | .named('SuiteCollection.topLevelSuites()') 14 | .returns([]); 15 | 16 | return collection; 17 | }; 18 | 19 | exports.mkDummyCollection = mkDummyCollection_; 20 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | The following authors have created the source code of "Gemini GUI" 2 | published and distributed by YANDEX LLC as the owner: 3 | 4 | Sergey Tatarintsev 5 | Anton Usmansky 6 | Evgeniy Konstantinov 7 | Dmitriy Dudkevich 8 | Rostislav Shtanko 9 | Alexandr Tikvach 10 | 11 | Contributers: 12 | 13 | Ilia Akhmadullin 14 | Alexander Mekhonoshin 15 | Mickaël PERRIN 16 | Todd Wolfson 17 | Roman Rozhdestvenskiy 18 | -------------------------------------------------------------------------------- /lib/event-source.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var stringify = require('json-stringify-safe'); 4 | 5 | function EventSource() { 6 | this._connections = []; 7 | } 8 | 9 | EventSource.prototype = { 10 | constructor: EventSource, 11 | 12 | addConnection: function(connection) { 13 | this._connections.push(connection); 14 | }, 15 | 16 | emit: function(event, data) { 17 | this._connections.forEach(function(connection) { 18 | connection.write('event: ' + event + '\n'); 19 | connection.write('data: ' + stringify(data) + '\n'); 20 | connection.write('\n\n'); 21 | }); 22 | } 23 | }; 24 | 25 | module.exports = EventSource; 26 | -------------------------------------------------------------------------------- /lib/client/xhr.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.post = function(url, data, callback) { 4 | if (!callback) { 5 | callback = data; 6 | data = null; 7 | } 8 | var xhr = new XMLHttpRequest(); 9 | xhr.open('POST', url, true); 10 | xhr.setRequestHeader('Content-Type', 'application/json'); 11 | xhr.onload = function() { 12 | var data = JSON.parse(xhr.responseText); 13 | if (xhr.status === 200) { 14 | callback(null, data); 15 | } else { 16 | callback(new Error(data.error)); 17 | } 18 | }; 19 | 20 | if (data) { 21 | xhr.send(JSON.stringify(data)); 22 | } else { 23 | xhr.send(); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /lib/views/partials/suite.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{suite.name}} 4 | 9 |
10 |
11 | {{#each states}} 12 | {{> state this}} 13 | {{/each}} 14 | 15 | {{#each children}} 16 | {{> suite}} 17 | {{/each}} 18 |
19 |
20 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const zlib = require('zlib'); 5 | 6 | const Promise = require('bluebird'); 7 | const tar = require('tar-fs'); 8 | 9 | exports.decompress = function(source, destination) { 10 | return new Promise((resolve, reject) => { 11 | fs.createReadStream(source) 12 | .on('error', reject) 13 | .pipe(zlib.createGunzip()) 14 | .on('error', reject) 15 | .pipe(tar.extract(destination)) 16 | .on('error', reject) 17 | .on('finish', resolve); 18 | }); 19 | }; 20 | 21 | class ExtendableError extends Error { 22 | constructor(message, cause) { 23 | super(); 24 | 25 | this.message = message; 26 | Error.captureStackTrace(this, this.constructor); 27 | if (cause && cause.stack) { 28 | this.stack = this.stack.concat('\n\nCaused by:\n' + cause.stack); 29 | } 30 | } 31 | } 32 | 33 | exports.ExtendableError = ExtendableError; 34 | -------------------------------------------------------------------------------- /lib/runner/specific-suites-runner.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var inherit = require('inherit'), 4 | Runner = require('./runner'); 5 | 6 | var SpecificSuiteRunner = inherit(Runner, { 7 | __constructor: function(collection, specificTests) { 8 | this.__base(collection); 9 | this._specificTests = specificTests; 10 | }, 11 | 12 | run: function(runHandler) { 13 | this._filter(); 14 | 15 | return this.__base(runHandler); 16 | }, 17 | 18 | _filter: function() { 19 | var testsToRun = this._specificTests.map(function(test) { 20 | return { 21 | suite: test.suite.path.replace(/,/g, ' '), //converting path to suite fullName 22 | state: test.state.name, 23 | browserId: test.browserId 24 | }; 25 | }); 26 | 27 | var _this = this; 28 | 29 | this._collection.disableAll(); 30 | testsToRun.forEach(function(test) { 31 | _this._collection.enable(test.suite, {state: test.state, browser: test.browserId}); 32 | }); 33 | } 34 | }); 35 | 36 | module.exports = SpecificSuiteRunner; 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 YANDEX LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /test/event-source.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const EventSource = require('../lib/event-source'); 4 | 5 | describe('lib/event-source', () => { 6 | beforeEach(() => { 7 | this.source = new EventSource(); 8 | this.connection = { 9 | write: sinon.stub() 10 | }; 11 | 12 | this.source.addConnection(this.connection); 13 | }); 14 | 15 | it('should send event to connections in text/event-stream format', () => { 16 | this.source.emit('event', {data: 'value'}); 17 | 18 | assert.strictEqual(this.connection.write.callCount, 3); 19 | assert.equal(this.connection.write.firstCall.args[0], 'event: event\n'); 20 | assert.equal(this.connection.write.secondCall.args[0], 'data: {"data":"value"}\n'); 21 | assert.equal(this.connection.write.thirdCall.args[0], '\n\n'); 22 | }); 23 | 24 | it('should handle circular references format', () => { 25 | const a = {b: true}; 26 | a.c = a; 27 | 28 | this.source.emit('event', a); 29 | 30 | assert.strictEqual(this.connection.write.callCount, 3); 31 | assert.equal(this.connection.write.firstCall.args[0], 'event: event\n'); 32 | assert.equal(this.connection.write.secondCall.args[0], 'data: {"b":true,"c":"[Circular ~]"}\n'); 33 | assert.equal(this.connection.write.thirdCall.args[0], '\n\n'); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ExtendableError = require('../lib/utils').ExtendableError; 4 | 5 | describe('lib/utils', () => { 6 | describe('Error', () => { 7 | it('should be an Error inheritor', () => { 8 | const error = new ExtendableError('msg'); 9 | 10 | assert.isTrue(error instanceof Error); 11 | }); 12 | 13 | it('should create regular error if only message specified', () => { 14 | const error = new ExtendableError('msg'); 15 | 16 | assert.equal(error.message, 'msg'); 17 | assert.match(error.stack, /^Error: msg\n/); 18 | assert.notInclude(error.stack, '\nCaused by:\n'); 19 | }); 20 | 21 | it('should create regular error if cause exception has no stack', () => { 22 | const error = new ExtendableError('msg', {}); 23 | 24 | assert.equal(error.message, 'msg'); 25 | assert.match(error.stack, /^Error: msg\n/); 26 | assert.notInclude(error.stack, '\nCaused by:\n'); 27 | }); 28 | 29 | it('should create error with extended stack if cause exception has stack', () => { 30 | const error = new ExtendableError('msg', {stack: 'Custom stack'}); 31 | 32 | assert.equal(error.message, 'msg'); 33 | assert.match(error.stack, /^Error: msg\n/); 34 | assert.include(error.stack, '\nCaused by:\n'); 35 | assert.include(error.stack, '\nCustom stack'); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /lib/views/partials/state.hbs: -------------------------------------------------------------------------------- 1 |
4 |
{{state.name}}
5 |
6 | {{#each browsers}} 7 |
11 |
12 | {{browserId}} 13 | 18 | 19 |
20 |
21 | {{#ifCond status '===' 'skipped'}} 22 | {{> skip-result}} 23 | {{else ifCond status '===' 'fail'}} 24 | {{> fail-result}} 25 | {{else ifCond status '===' 'error'}} 26 | {{> error-result}} 27 | {{else}} 28 | {{> success-result}} 29 | {{/ifCond}} 30 |
31 |
32 | {{/each}} 33 |
34 |
35 | -------------------------------------------------------------------------------- /lib/find-gemini.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var path = require('path'), 3 | resolve = require('resolve'), 4 | // semver = require('semver'), 5 | chalk = require('chalk'), 6 | shell = require('shelljs'); 7 | 8 | // COMPATIBLE_GEMINI = '^4.0.0'; 9 | 10 | module.exports = function findGemini() { 11 | var geminiPackage; 12 | try { 13 | geminiPackage = resolve.sync('gemini/package.json', { 14 | basedir: process.cwd(), 15 | moduleDirectory: [ 16 | path.resolve(__dirname, '../../'), 17 | 'node_modules', 18 | shell.exec('npm root --global', {silent: true}).stdout.trim() 19 | ] 20 | }); 21 | } catch (e) { 22 | console.error(chalk.red('Error:'), 'gemini not found'); 23 | console.error('Local gemini installation is required to use GUI'); 24 | console.error('Run ', chalk.cyan('npm install gemini'), 'to install gemini'); 25 | throw e; 26 | } 27 | 28 | // semver не может сматчиться на alpha-версию gemini (5.0.0-alpha.1) 29 | // Вернуть этот код после релиза мажорной версии gemini 30 | // var version = require(geminiPackage).version; 31 | // if (!semver.satisfies(version, COMPATIBLE_GEMINI)) { 32 | // console.error(chalk.red('Error:'), 'installed gemini is not compatible with GUI'); 33 | // console.error('gemini version should be in range', chalk.cyan(COMPATIBLE_GEMINI)); 34 | // throw new Error('No compatible version'); 35 | // } 36 | 37 | var modulePath = path.dirname(geminiPackage); 38 | return require(path.join(modulePath, 'api')); 39 | }; 40 | -------------------------------------------------------------------------------- /lib/reuse-loader.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Promise = require('bluebird'); 4 | const fs = require('fs'); 5 | const path = require('path'); 6 | const temp = require('temp').track(); 7 | const LargeDownload = require('large-download'); 8 | 9 | const utils = require('./utils'); 10 | const extract = utils.decompress; 11 | const ReuseError = utils.ExtendableError; 12 | 13 | const downloadReport = (reuseUrl) => { 14 | const tempDir = temp.mkdirSync('gemini-gui-reuse'); 15 | const archiveName = 'report.tar.gz'; 16 | const archivePath = path.resolve(tempDir, archiveName); 17 | 18 | return new LargeDownload({link: reuseUrl, destination: archivePath, retries: 3}) 19 | .load() 20 | .then(() => extract(archivePath, tempDir)) 21 | .then(() => tempDir); 22 | }; 23 | 24 | const prepareReport = (urlOrPath) => { 25 | return fs.existsSync(urlOrPath) 26 | ? Promise.resolve(path.resolve(urlOrPath)) 27 | : downloadReport(urlOrPath); 28 | }; 29 | 30 | module.exports = (urlOrPath) => { 31 | if (!urlOrPath) { 32 | return Promise.resolve(); 33 | } 34 | 35 | return prepareReport(urlOrPath) 36 | .then( 37 | (report) => { 38 | try { 39 | const data = require(path.resolve(report, 'data')); 40 | return {data, report}; 41 | } catch (e) { 42 | throw new ReuseError(`Nothing to reuse in ${report}`, e); 43 | } 44 | }, 45 | (e) => { 46 | throw new ReuseError(`Failed to reuse report from ${urlOrPath}`, e); 47 | } 48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /test/runner/all-suites-runner.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | 5 | const AllSuitesRunner = require('../../lib/runner/all-suites-runner'); 6 | const mkDummyCollection = require('../test-utils').mkDummyCollection; 7 | 8 | describe('lib/runner/all-suites-runner', () => { 9 | const sandbox = sinon.sandbox.create(); 10 | 11 | afterEach(() => sandbox.restore()); 12 | 13 | describe('run', () => { 14 | const run_ = (params) => { 15 | params = _.defaults(params || {}, { 16 | collection: params.collection || mkDummyCollection(), 17 | runHandler: params.runHandler || sandbox.stub().named('default_run_handler') 18 | }); 19 | 20 | return new AllSuitesRunner(params.collection) 21 | .run(params.runHandler); 22 | }; 23 | 24 | it('should enable all suites in collection', () => { 25 | const collection = mkDummyCollection(); 26 | 27 | run_({collection}); 28 | 29 | assert.called(collection.enableAll); 30 | }); 31 | 32 | it('should execute run handler', () => { 33 | const collection = mkDummyCollection(); 34 | const runHandler = sandbox.stub().named('run_handler'); 35 | 36 | run_({runHandler, collection}); 37 | 38 | assert.calledWith(runHandler, collection); 39 | }); 40 | 41 | it('should return result of run handler', () => { 42 | const runHandler = sandbox.stub().named('run_handler').returns('some_result'); 43 | const result = run_({runHandler}); 44 | 45 | assert.equal(result, 'some_result'); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /lib/views/main.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Gemini 5 | 6 | 7 | 8 | 9 | {{#with stats}} 10 |
11 |
Total Tests
{{total}}
12 |
Passed
{{passed}}
13 |
Failed
{{failed}}
14 |
Skipped
{{skipped}}
15 |
16 | {{/with}} 17 | 18 | {{#if suites.length}} 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | {{#each suites}} 29 | {{> suite}} 30 | {{/each}} 31 | {{/if}} 32 | 33 | {{#unless suites.length}} 34 |

No suites found.

35 | {{/unless}} 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /lib/cli.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const opener = require('opener'); 5 | const chalk = require('chalk'); 6 | const program = require('commander'); 7 | 8 | const pkg = require('../package.json'); 9 | const server = require('./server'); 10 | 11 | const collect = (newValue, array) => (array || []).concat(newValue); 12 | 13 | exports.run = () => { 14 | program 15 | .version(pkg.version) 16 | .allowUnknownOption(true) 17 | .option('-b, --browser ', 'run test only in the specified browser', collect) 18 | .option('-p, --port ', 'Port to launch server on', 8000) 19 | .option('--hostname ', 'Hostname to launch server on', 'localhost') 20 | .option('-c, --config ', 'Gemini config file', path.resolve, '') 21 | .option('-g, --grep ', 'run only suites matching the pattern', RegExp) 22 | .option('-s, --set ', 'set to run', collect) 23 | .option('-a, --auto-run', 'auto run immediately') 24 | .option('-O, --no-open', 'not to open a browser window after starting the server') 25 | .option('--reuse ', 'Filepath to gemini tests results directory OR url to tar.gz archive to reuse') 26 | .parse(process.argv); 27 | 28 | program.on('--help', () => { 29 | console.log('Also you can override gemini config options.'); 30 | console.log('See all possible options in gemini documentation.'); 31 | }); 32 | 33 | program.testFiles = [].concat(program.args); 34 | 35 | server.start(program).then((result) => { 36 | console.log(`GUI is running at ${chalk.cyan(result.url)}`); 37 | console.warn(`${chalk.red('Warning: this package is deprecated. Use html-reporter instead.')}`); 38 | console.warn(`${chalk.red('See: https://github.com/gemini-testing/gemini-gui#how-to-migrate')}`); 39 | if (program.open) { 40 | opener(result.url); 41 | } 42 | }).done(); 43 | }; 44 | -------------------------------------------------------------------------------- /test/runner/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const RunnerFactory = require('../../lib/runner'); 4 | const AllSuitesRunner = require('../../lib/runner/all-suites-runner'); 5 | const SpecificSuitesRunner = require('../../lib/runner/specific-suites-runner'); 6 | const mkDummyCollection = require('../test-utils').mkDummyCollection; 7 | 8 | describe('lib/runner/index', () => { 9 | const sandbox = sinon.sandbox.create(); 10 | 11 | afterEach(() => sandbox.restore()); 12 | 13 | describe('create', () => { 14 | it('should create AllSuitesRunner if no specific tests passed', () => { 15 | assert.instanceOf(RunnerFactory.create(), AllSuitesRunner); 16 | }); 17 | 18 | it('should pass collection to AllSuitesRunner', () => { 19 | const collection = mkDummyCollection(); 20 | const runner = RunnerFactory.create(collection); 21 | const runHandler = sandbox.stub(); 22 | 23 | runner.run(runHandler); 24 | 25 | assert.calledWith(runHandler, collection); 26 | }); 27 | 28 | it('should create SpecificSuitesRunner if specific tests passed', () => { 29 | assert.instanceOf(RunnerFactory.create(null, ['test']), SpecificSuitesRunner); 30 | }); 31 | 32 | it('should pass suite collection and tests to SpecificSuiteRunner', () => { 33 | const collection = mkDummyCollection(); 34 | const tests = [{ 35 | suite: {path: 'suite'}, 36 | state: {name: 'state'}, 37 | browserId: 'browser' 38 | }]; 39 | 40 | const runner = RunnerFactory.create(collection, tests); 41 | const runHandler = sandbox.stub(); 42 | 43 | runner.run(runHandler); 44 | 45 | assert.calledWith(runHandler, collection); 46 | assert.calledOnce(collection.enable); 47 | assert.calledWith(collection.enable, 'suite', {state: 'state', browser: 'browser'}); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /CLA.md: -------------------------------------------------------------------------------- 1 | # Notice to external contributors 2 | 3 | ## General info 4 | 5 | Hello! In order for us (YANDEX LLC) to accept patches and other contributions from you, you will have to adopt our Yandex Contributor License Agreement (the “**CLA**”). The current version of the CLA you may find here: 6 | 1) https://yandex.ru/legal/cla/?lang=en (in English) and 7 | 2) https://yandex.ru/legal/cla/?lang=ru (in Russian). 8 | 9 | By adopting the CLA, you state the following: 10 | 11 | * You obviously wish and are willingly licensing your contributions to us for our open source projects under the terms of the CLA, 12 | * You has read the terms and conditions of the CLA and agree with them in full, 13 | * You are legally able to provide and license your contributions as stated, 14 | * We may use your contributions for our open source projects and for any other our project too, 15 | * We rely on your assurances concerning the rights of third parties in relation to your contributes. 16 | 17 | If you agree with these principles, please read and adopt our CLA. By providing us your contributions, you hereby declare that you has already read and adopt our CLA, and we may freely merge your contributions with our corresponding open source project and use it in further in accordance with terms and conditions of the CLA. 18 | 19 | ## Provide contributions 20 | 21 | If you have already adopted terms and conditions of the CLA, you are able to provide your contributes. When you submit your pull request, please add the following information into it: 22 | 23 | ``` 24 | I hereby agree to the terms of the CLA available at: [link]. 25 | ``` 26 | 27 | Replace the bracketed text as follows: 28 | * `[link]` is the link at the current version of the CLA (you may add here a link https://yandex.ru/legal/cla/?lang=en (in English) or a link https://yandex.ru/legal/cla/?lang=ru (in Russian). 29 | 30 | It is enough to provide us such notification at once. 31 | 32 | ## Other questions 33 | 34 | If you have any questions, please mail us at opensource@yandex-team.ru. 35 | -------------------------------------------------------------------------------- /lib/common/tests-index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('lodash'); 4 | 5 | function TestsIndex() { 6 | this._index = {}; 7 | } 8 | 9 | TestsIndex.prototype = { 10 | constructor: TestsIndex, 11 | 12 | add: function(item) { 13 | if (!item.suite || item.suite.path === null) { 14 | return; 15 | } 16 | 17 | var indexData = this._index[item.suite.path]; 18 | if (!indexData) { 19 | indexData = this._index[item.suite.path] = { 20 | suite: null, 21 | states: {} 22 | }; 23 | } 24 | 25 | if (!item.state || item.state.name === null) { 26 | indexData.suite = item; 27 | return; 28 | } 29 | 30 | var stateData = indexData.states[item.state.name]; 31 | if (!stateData) { 32 | stateData = indexData.states[item.state.name] = { 33 | state: null, 34 | browsers: {} 35 | }; 36 | } 37 | 38 | if (!item.browserId) { 39 | stateData.state = item; 40 | return; 41 | } 42 | stateData.browsers[item.browserId] = item; 43 | }, 44 | 45 | find: function(query) { 46 | if (Array.isArray(query)) { 47 | return _(query) 48 | .map(this.find, this) 49 | .compact() 50 | .value(); 51 | } 52 | 53 | var indexData = query.suite && this._index[query.suite.path]; 54 | if (!indexData) { 55 | return null; 56 | } 57 | if (!query.state || query.state.name === null) { 58 | return indexData.suite; 59 | } 60 | var stateData = indexData.states[query.state.name]; 61 | if (!stateData) { 62 | return null; 63 | } 64 | 65 | if (!query.browserId) { 66 | return stateData.state; 67 | } 68 | 69 | return stateData.browsers[query.browserId] || null; 70 | } 71 | }; 72 | 73 | module.exports = TestsIndex; 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gemini-gui", 3 | "version": "6.0.1", 4 | "description": "GUI for gemini testing utility", 5 | "main": "lib/index.js", 6 | "bin": { 7 | "gemini-gui": "./bin/gemini-gui" 8 | }, 9 | "scripts": { 10 | "build-client": "browserify lib/client --debug -p [minifyify --compressPath . --map /client.js.map.json --output ./lib/static/client.js.map.json] -t hbsfy -o ./lib/static/client.js", 11 | "prepublish": "npm run build-client", 12 | "test": "istanbul test _mocha -- test/", 13 | "lint": "eslint ." 14 | }, 15 | "author": "Sergey Tatarintsev (https://github.com/SevInf)", 16 | "license": "MIT", 17 | "dependencies": { 18 | "bluebird": "^3.4.6", 19 | "body-parser": "^1.8.1", 20 | "chalk": "^1.0.0", 21 | "clipboard": "^1.5.12", 22 | "commander": "^2.9.0", 23 | "express": "^4.8.6", 24 | "express-handlebars": "^2.0.0", 25 | "fs-extra": "^0.30.0", 26 | "handlebars": "^1.3.0", 27 | "inherit": "^2.2.3", 28 | "json-stringify-safe": "^5.0.1", 29 | "large-download": "^1.0.0", 30 | "lodash": "^3.10.1", 31 | "opener": "^1.4.0", 32 | "resolve": "^1.0.0", 33 | "semver": "^3.0.1", 34 | "shelljs": "^0.6.0", 35 | "tar-fs": "^1.16.0", 36 | "temp": "^0.8.1" 37 | }, 38 | "devDependencies": { 39 | "browserify": "^13.0.0", 40 | "chai": "^3.5.0", 41 | "chai-as-promised": "^5.2.0", 42 | "dirty-chai": "^1.2.2", 43 | "eslint": "^3.3.1", 44 | "eslint-config-gemini-testing": "^2.2.0", 45 | "gemini": "^5.0.0", 46 | "hbsfy": "^2.1.0", 47 | "istanbul": "^0.3.2", 48 | "minifyify": "^7.3.2", 49 | "mocha": "^2.4.4", 50 | "proxyquire": "^1.7.3", 51 | "sinon": "^1.17.3", 52 | "sinon-chai": "^2.8.0", 53 | "watchify": "^3.7.0" 54 | }, 55 | "directories": { 56 | "test": "test" 57 | }, 58 | "keywords": [ 59 | "gemini", 60 | "gui", 61 | "screenshot", 62 | "test", 63 | "testing" 64 | ], 65 | "repository": { 66 | "type": "git", 67 | "url": "https://github.com/gemini-testing/gemini-gui.git" 68 | }, 69 | "bugs": { 70 | "url": "https://github.com/gemini-testing/gemini-gui/issues" 71 | }, 72 | "homepage": "https://github.com/gemini-testing/gemini-gui" 73 | } 74 | -------------------------------------------------------------------------------- /lib/client/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Controller = require('./controller'), 4 | forEach = Array.prototype.forEach, 5 | filter = Array.prototype.filter, 6 | hbruntime = require('hbsfy/runtime'); 7 | 8 | hbruntime.registerPartial('cswitcher', require('../views/partials/cswitcher.hbs')); 9 | hbruntime.registerPartial('suite-controls', require('../views/partials/suite-controls.hbs')); 10 | hbruntime.registerPartial('controls', require('../views/partials/controls.hbs')); 11 | hbruntime.registerPartial('meta-info', require('../views/partials/meta-info.hbs')); 12 | 13 | function bodyClick(e) { 14 | var target = e.target; 15 | 16 | if (target.classList.contains('cswitcher__item')) { 17 | handleColorSwitch( 18 | target, 19 | filter.call(target.parentNode.childNodes, function(node) { 20 | return node.nodeType === Node.ELEMENT_NODE; 21 | }) 22 | ); 23 | } 24 | 25 | if (target.classList.contains('meta-info__switcher')) { 26 | toggleMetaInfo(target); 27 | } 28 | } 29 | 30 | function handleColorSwitch(target, sources) { 31 | var imageBox = findClosest(target, 'image-box'); 32 | 33 | sources.forEach(function(item) { 34 | item.classList.remove('cswitcher__item_selected'); 35 | }); 36 | forEach.call(imageBox.classList, function(cls) { 37 | if (/cswitcher_color_\d+/.test(cls)) { 38 | imageBox.classList.remove(cls); 39 | } 40 | }); 41 | 42 | target.classList.add('cswitcher__item_selected'); 43 | imageBox.classList.add('cswitcher_color_' + target.dataset.id); 44 | } 45 | 46 | function findClosest(context, cls) { 47 | while ((context = context.parentNode)) { 48 | if (context.classList.contains(cls)) { 49 | return context; 50 | } 51 | } 52 | } 53 | 54 | function toggleMetaInfo(target) { 55 | target.closest('.meta-info').classList.toggle('meta-info_collapsed'); 56 | } 57 | 58 | document.addEventListener('DOMContentLoaded', function() { 59 | window.controller = new Controller(); 60 | document.body.addEventListener('click', bodyClick); 61 | 62 | // Run immediately 63 | if (document.querySelector('.report').dataset.autoRun) { 64 | window.controller.run(); 65 | } 66 | }); 67 | -------------------------------------------------------------------------------- /test/runner/specific-suites-runner.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | 5 | const SpecificSuiteRunner = require('../../lib/runner/specific-suites-runner'); 6 | const mkDummyCollection = require('../test-utils').mkDummyCollection; 7 | 8 | describe('lib/runner/specific-suites-runner', () => { 9 | const sandbox = sinon.sandbox.create(); 10 | 11 | afterEach(() => sandbox.restore()); 12 | 13 | describe('run', () => { 14 | const run_ = (params) => { 15 | params = _.defaults(params || {}, { 16 | collection: mkDummyCollection(), 17 | runHandler: params.runHandler || sandbox.stub().named('default_run_handler'), 18 | tests: [{ 19 | suite: {path: 'default_suite'}, 20 | state: {name: 'default_state'}, 21 | browserId: 'default_browser_id' 22 | }] 23 | }); 24 | 25 | return new SpecificSuiteRunner(params.collection, params.tests) 26 | .run(params.runHandler); 27 | }; 28 | 29 | it('should disable all suites in collection', () => { 30 | const collection = mkDummyCollection(); 31 | 32 | run_({collection}); 33 | 34 | assert.called(collection.disableAll); 35 | }); 36 | 37 | it('should enable specific suites', () => { 38 | const collection = mkDummyCollection(), 39 | tests = [{ 40 | suite: {path: 'suite'}, 41 | state: {name: 'state'}, 42 | browserId: 'browser' 43 | }]; 44 | 45 | run_({collection, tests}); 46 | 47 | assert.calledOnce(collection.enable); 48 | assert.calledWith(collection.enable, 'suite', {state: 'state', browser: 'browser'}); 49 | }); 50 | 51 | it('should execute run handler', () => { 52 | const collection = mkDummyCollection(); 53 | const runHandler = sandbox.stub().named('run_handler'); 54 | 55 | run_({runHandler, collection}); 56 | 57 | assert.calledWith(runHandler, collection); 58 | }); 59 | 60 | it('should return result of run handler', () => { 61 | const runHandler = sandbox.stub().named('run_handler').returns('some_result'); 62 | const result = run_({runHandler}); 63 | 64 | assert.equal(result, 'some_result'); 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /lib/client/suite-controls.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function SuiteControls(parentNode) { 4 | this._acceptButton = parentNode.querySelector('.suite-controls__accept'); 5 | this._retryButton = parentNode.querySelector('.suite-controls__retry'); 6 | } 7 | 8 | SuiteControls.prototype = { 9 | setAsNewReference: function(retryHandler, acceptHandler) { 10 | this._setButtonEnabled(this._acceptButton, true); 11 | this._attachHandlers(retryHandler, acceptHandler); 12 | }, 13 | 14 | setAsSuccess: function(retryHandler) { 15 | this._setButtonVisible(this._acceptButton, false); 16 | this._enableIfNotRunning(this._retryButton); 17 | this._attachHandlers(retryHandler); 18 | }, 19 | 20 | setAsFailure: function(retryHandler, acceptHandler) { 21 | this._setButtonEnabled(this._acceptButton, true); 22 | this._enableIfNotRunning(this._retryButton); 23 | this._attachHandlers(retryHandler, acceptHandler); 24 | }, 25 | 26 | setAsError: function(retryHandler) { 27 | this._setButtonVisible(this._acceptButton, false); 28 | this._enableIfNotRunning(this._retryButton); 29 | this._attachHandlers(retryHandler); 30 | }, 31 | 32 | toggleRetry: function(isEnabled) { 33 | this._setButtonEnabled(this._retryButton, isEnabled); 34 | }, 35 | 36 | _enableIfNotRunning: function(button) { 37 | var isRunning = window.controller && window.controller.state === 'running'; 38 | 39 | this._setButtonEnabled(button, !isRunning); 40 | }, 41 | 42 | _setButtonEnabled: function(button, isEnabled) { 43 | if (isEnabled) { 44 | button.classList.remove('suite-controls__item_disabled'); 45 | } else { 46 | button.classList.add('suite-controls__item_disabled'); 47 | } 48 | 49 | button.disabled = !isEnabled; 50 | }, 51 | 52 | _setButtonVisible: function(button, isVisible) { 53 | if (isVisible) { 54 | button.classList.remove('suite-controls__item_hidden'); 55 | } else { 56 | button.classList.add('suite-controls__item_hidden'); 57 | } 58 | }, 59 | 60 | _attachHandlers: function(retryHandler, acceptHandler) { 61 | if (retryHandler) { 62 | this._retryButton.addEventListener('click', retryHandler); 63 | } 64 | 65 | if (acceptHandler) { 66 | this._acceptButton.addEventListener('click', acceptHandler); 67 | } 68 | } 69 | }; 70 | 71 | module.exports = SuiteControls; 72 | -------------------------------------------------------------------------------- /test/tests-index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const TestsIndex = require('../lib/common/tests-index'); 4 | const data = { 5 | onlySuite: { 6 | suite: {path: 'path/to/test'} 7 | }, 8 | 9 | suiteState: { 10 | suite: {path: 'path/to/test'}, 11 | state: {name: 'state'} 12 | }, 13 | 14 | suiteStateBrowser: { 15 | suite: {path: 'path/to/test'}, 16 | state: {name: 'state'}, 17 | browserId: 'browser' 18 | } 19 | }; 20 | 21 | describe('lib/common/tests-index', () => { 22 | beforeEach(() => { 23 | this.index = new TestsIndex(); 24 | this.index.add(data.onlySuite); 25 | this.index.add(data.suiteState); 26 | this.index.add(data.suiteStateBrowser); 27 | }); 28 | 29 | describe('add', () => { 30 | it('should silently fall if no suite.path specified', () => { 31 | const fn = () => this.index.add({noSuite: true}); 32 | 33 | assert.doesNotThrow(fn); 34 | }); 35 | }); 36 | 37 | describe('find', () => { 38 | it('should be able to find by suite', () => { 39 | const foundSuite = this.index.find(data.onlySuite); 40 | 41 | assert.deepEqual(foundSuite, data.onlySuite); 42 | }); 43 | 44 | it('should be able to find by suite and state', () => { 45 | const foundSuiteState = this.index.find(data.suiteState); 46 | 47 | assert.deepEqual(foundSuiteState, data.suiteState); 48 | }); 49 | 50 | it('should be able to find by suite, state and browser', () => { 51 | const foundSuiteStateBrowser = this.index.find(data.suiteStateBrowser); 52 | 53 | assert.deepEqual(foundSuiteStateBrowser, data.suiteStateBrowser); 54 | }); 55 | 56 | it('should return null if suite is not found', () => { 57 | const foundSuite = this.index.find({suite: {path: 'another/test'}}); 58 | 59 | assert.isNull(foundSuite); 60 | }); 61 | 62 | it('should return null if state is not found', () => { 63 | const suite = {path: 'path/to/test'}; 64 | const state = {name: 'another'}; 65 | 66 | const foundSuiteState = this.index.find({suite, state}); 67 | 68 | assert.isNull(foundSuiteState); 69 | }); 70 | 71 | it('should return null if browser is not found', () => { 72 | const suite = {path: 'path/to/test'}; 73 | const state = {name: 'state'}; 74 | const browserId = 'bro'; 75 | 76 | const foundSuiteStateBrowser = this.index.find({suite, state, browserId}); 77 | 78 | assert.isNull(foundSuiteStateBrowser); 79 | }); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gemini GUI 2 | 3 | [![Build Status](https://travis-ci.org/gemini-testing/gemini-gui.svg)](https://travis-ci.org/gemini-testing/gemini-gui) 4 | 5 | ## WARNING 6 | 7 | This package is deprecated and no longer supported. 8 | Use [html-reporter](https://github.com/gemini-testing/html-reporter) plugin and `gemini gui` command instead. 9 | 10 | ### How to migrate 11 | 12 | To be able to use `GUI` on a project you must have [gemini](https://github.com/gemini-testing/gemini) installed locally in the project. 13 | 14 | 1. Install [html-reporter](https://github.com/gemini-testing/html-reporter) plugin. 15 | 16 | 2. [Add html-reporter plugin](https://github.com/gemini-testing/html-reporter#gemini-usage) to your gemini config file. 17 | 18 | 3. Now you can use `GUI` by running `gemini gui` in the project root: 19 | 20 | ``` 21 | ./node_modules/.bin/gemini gui 22 | ``` 23 | 24 | 4. To see available options of `gemini gui` command, run in the project root: 25 | 26 | ``` 27 | ./node_modules/.bin/gemini gui --help 28 | ``` 29 | 30 | ## What is Gemini GUI? 31 | 32 | GUI for [gemini](https://github.com/gemini-testing/gemini) utility. 33 | 34 | ![screenshot](assets/screenshot.png "Screenshot") 35 | 36 | ## Installation 37 | 38 | Install globally with `npm`: 39 | 40 | ``` 41 | npm i -g gemini-gui 42 | ``` 43 | 44 | ## Running 45 | 46 | To be able to use `GUI` on a project you must have `gemini` installed 47 | locally in this project. `GUI` will not work with `gemini` below 48 | `2.0.0`. 49 | 50 | Run in the project root: 51 | 52 | `gemini-gui ./path/to/your/tests` 53 | 54 | Web browser with `GUI` loaded will be opened automatically. 55 | 56 | 57 | ## Options 58 | 59 | * `--config`, `-c` - specify config file to use. 60 | * `--port`, `-p` - specify port to run `GUI` backend on. 61 | * `--hostname` - specify hostname to run `GUI` backend on. 62 | * `--root-url`, `-r` - use specified URL, instead of `rootUrl` setting from config file. 63 | * `--grid-url` - use specified URL, instead of `gridUrl` setting from config file. 64 | * `--screenshots-dir`, `-s` - use specified directory, instead of `screenshotsDir` setting 65 | from config. 66 | * `--grep`, `-g` - find suites by name. Note that if some suite files specified search will be done 67 | only in that files. 68 | * `--debug` - enable debug mode (verbose logging). 69 | * `--auto-run`, `-a` - run gemini immediately (without pressing `run` button). 70 | * `--set`, `-s` - run set specified in config. 71 | * `--no-open`, `-O` - not to open a browser window after starting the server. 72 | * `--reuse` - filepath to gemini tests results directory OR url to tar.gz archive to reuse 73 | 74 | You can also override config file options with environment variables. Use `gemini` 75 | [documentation](https://github.com/gemini-testing/gemini#configuration) for details. 76 | -------------------------------------------------------------------------------- /lib/reporter.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = function(app) { 3 | return function reporter(runner) { 4 | function proxy(event) { 5 | runner.on(event, function(data) { 6 | app.sendClientEvent(event, data); 7 | }); 8 | } 9 | 10 | function onError(error) { 11 | var response = { 12 | suite: error.suite, 13 | state: error.state, 14 | browserId: error.browserId, 15 | stack: error.stack || error.message 16 | }; 17 | 18 | response.state.metaInfo = response.suite.metaInfo || {}; 19 | response.state.metaInfo.sessionId = error.sessionId; 20 | response.state.metaInfo.file = response.suite.file; 21 | 22 | app.sendClientEvent('err', response); 23 | } 24 | 25 | function onNoRefImage(data) { 26 | app.addNoReferenceTest(data); 27 | app.sendClientEvent('noReference', { 28 | suite: data.suite, 29 | state: data.state, 30 | browserId: data.browserId, 31 | currentURL: app.currentPathToURL(data.currentPath, data.browserId) 32 | }); 33 | } 34 | 35 | proxy('begin'); 36 | proxy('beginSuite'); 37 | proxy('beginState'); 38 | 39 | runner.on('testResult', function(data) { 40 | var response = { 41 | suite: data.suite, 42 | state: data.state, 43 | browserId: data.browserId, 44 | equal: data.equal, 45 | referenceURL: app.refPathToURL(data.referencePath, data.browserId), 46 | currentURL: app.currentPathToURL(data.currentPath) 47 | }; 48 | 49 | response.state.metaInfo = response.suite.metaInfo || {}; 50 | response.state.metaInfo.sessionId = data.sessionId; 51 | response.state.metaInfo.file = response.suite.file; 52 | 53 | if (data.equal) { 54 | app.sendClientEvent('testResult', response); 55 | return; 56 | } 57 | app.addFailedTest({ 58 | suite: data.suite, 59 | state: data.state, 60 | browserId: data.browserId, 61 | referencePath: data.referencePath, 62 | currentPath: data.currentPath 63 | }); 64 | app.buildDiff(data) 65 | .done(function(diffURL) { 66 | response.diffURL = diffURL; 67 | app.sendClientEvent('testResult', response); 68 | }); 69 | }); 70 | 71 | runner.on('err', function(error) { 72 | if (error.name === 'NoRefImageError') { 73 | onNoRefImage(error); 74 | } else { 75 | onError(error); 76 | } 77 | }); 78 | 79 | proxy('skipState'); 80 | proxy('endState'); 81 | proxy('endSuite'); 82 | proxy('end'); 83 | }; 84 | }; 85 | -------------------------------------------------------------------------------- /lib/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var path = require('path'), 3 | express = require('express'), 4 | bodyParser = require('body-parser'), 5 | exphbs = require('express-handlebars'), 6 | Promise = require('bluebird'), 7 | _ = require('lodash'), 8 | 9 | App = require('./app'); 10 | 11 | exports.start = function(options) { 12 | var app = new App(options); 13 | var server = express(); 14 | server.engine('.hbs', exphbs({ 15 | extname: '.hbs', 16 | compilerOptions: { 17 | preventIndent: true 18 | }, 19 | partialsDir: path.join(__dirname, 'views', 'partials'), 20 | helpers: { 21 | ifCond: function(a, op, b, options) { 22 | switch (op) { 23 | case '===': return (a === b) ? options.fn(this) : options.inverse(this); 24 | case '!==': return (a !== b) ? options.fn(this) : options.inverse(this); 25 | default: return options.inverse(this); 26 | } 27 | }, 28 | ifShouldCollapse: function(status, options) { 29 | switch (status) { 30 | case 'fail': 31 | case 'error': return options.inverse(this); 32 | default: return options.fn(this); 33 | } 34 | }, 35 | sectionStatus: function() { 36 | return this.status === 'error' ? 'fail' : this.status; 37 | } 38 | } 39 | })); 40 | 41 | server.set('view engine', '.hbs'); 42 | server.set('views', path.join(__dirname, 'views')); 43 | 44 | server.use(bodyParser.json()); 45 | server.use(express.static(path.join(__dirname, 'static'))); 46 | server.use(App.currentPrefix, express.static(app.currentDir)); 47 | server.use(App.diffPrefix, express.static(app.diffDir)); 48 | _.forEach(app.referenceDirs, function(dir, browserId) { 49 | server.use(App.refPrefix + '/' + browserId, express.static(dir)); 50 | }); 51 | 52 | server.get('/', function(req, res) { 53 | Promise.all([app.getTests(), app.getTestsStatus()]) 54 | .spread(function(suites, status) { 55 | res.render('main', { 56 | suites, 57 | status, 58 | autoRun: options.autoRun 59 | }); 60 | }) 61 | .catch(function(e) { 62 | res.status(500).send(e.stack); 63 | }); 64 | }); 65 | 66 | server.get('/events', function(req, res) { 67 | res.writeHead(200, {'Content-Type': 'text/event-stream'}); 68 | 69 | app.addClient(res); 70 | }); 71 | 72 | server.post('/run', function(req, res) { 73 | app.run(req.body) 74 | .catch(function(e) { 75 | console.error(e); 76 | }); 77 | 78 | res.send({status: 'ok'}); 79 | }); 80 | 81 | server.post('/update-ref', function(req, res) { 82 | app.updateReferenceImage(req.body) 83 | .then(function(referenceURL) { 84 | res.send({referenceURL: referenceURL}); 85 | }) 86 | .catch(function(error) { 87 | res.status(500); 88 | res.send({error: error.message}); 89 | }); 90 | }); 91 | 92 | return app.initialize() 93 | .then(function() { 94 | return Promise.fromCallback( 95 | callback => server.listen(options.port, options.hostname, callback) 96 | ); 97 | }) 98 | .then(function() { 99 | return { 100 | url: 'http://' + options.hostname + ':' + options.port 101 | }; 102 | }) 103 | .catch(e => { 104 | console.error(e); 105 | process.exit(1); 106 | }); 107 | }; 108 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 6.0.1 - 2018-04-19 4 | 5 | * Added deprecation warning: "This package is deprecated. Use html-reporter instead". 6 | 7 | ## 6.0.0 - 2018-03-30 8 | 9 | * `--reuse` option expect archive with report files. In v5.3.3 this option expect archive with report directory. 10 | 11 | ## 5.3.3 - 2018-03-15 12 | 13 | * Fix: reuse should work correctly for successfully passed tests 14 | 15 | ## 5.3.2 - 2018-03-15 16 | 17 | * Fixed bug: use last successful screenshot from retries if no image in result 18 | 19 | ## 4.7.0 - 2017-05-25 20 | 21 | * Added option `--no-open` for not to open a browser window after starting the server 22 | * Added CLA 23 | 24 | ## 4.6.1 - 2017-03-28 25 | 26 | * Add retry button and metaInfo section to error state. 27 | 28 | ## 4.6.0 - 2017-01-10 29 | 30 | * Add position sticky for controls. May be helpfull for big reference images 31 | 32 | ## 4.5.0 - 2016-11-02 33 | 34 | * Add ability to run sets specified in config 35 | 36 | ## 4.4.0 - 2016-10-20 37 | 38 | * Add "copy to clipboard" and "view in browser" buttons. You can copy suite name and open suite url in browser with one click now 39 | 40 | ## 4.3.5 - 2016-10-06 41 | 42 | * Replace `q` and `q-io` with `bluebird` and `fs-extra`. `Array.prototype` will not be overriden now 43 | 44 | ## 4.3.4 - 2016-09-30 45 | 46 | * Fixed bug with displaying of test url in meta-info 47 | 48 | ## 4.3.3 - 2016-09-13 49 | 50 | * Remove unnecessary output while finding global gemini 51 | 52 | ## 4.3.2 - 2016-08-31 53 | 54 | * Fixed bug with infinity pending of tests in html reporter 55 | 56 | ## 4.3.1 - 2016-08-25 57 | 58 | * Fixed passing of the 'grep' value 59 | 60 | ## 4.3.0 - 2016-08-23 61 | 62 | * Emit updateResult event during update for optimize reference image in plugin 63 | 64 | ## 4.2.1 - 2016-08-23 65 | 66 | * Fixed bug with passing config path through `--config` option 67 | 68 | ## 4.2.0 - 2016-07-28 69 | 70 | * Supported displaying of test url and session id in meta-info of HTML reporter. 71 | 72 | ## 4.1.0 - 2016-07-15 73 | 74 | * Supported option `--browser`. 75 | 76 | ## 4.0.0 - 2016-04.20 77 | 78 | * Support gemini@4.x (@sipayrt) 79 | 80 | ## 3.0.0 - 2016-03.23 81 | 82 | * Switch to gemini@3.x (@j0tunn) 83 | 84 | ## 2.2.1 - 2016-02-25 85 | 86 | Fix: retry button enabled correctly after retrying single test (@SwinX) 87 | 88 | ## 2.2.0 - 2016-02-24 89 | 90 | * Redesigned action buttons - now they are located at the top of test block near background buttons. Also accept button now works for both cases - accepting new reference and replace existing. (@SwinX) 91 | * Added possibility to retry any test, even successful (@SwinX) 92 | * Removed 'uncaughtException' handler from code. (@SwinX) 93 | 94 | ## 2.1.1 - 2016-02-03 95 | 96 | Fix: reference images for new suites saved correctly (@SwinX) 97 | 98 | ## 2.1.0 - 2016-01-29 99 | 100 | * Added `--grep` option. Searches suites by full name. (@SwinX) 101 | * Added possibility to save missing reference images (@SwinX) 102 | * Added `Retry` button to all failed tests. Added `Retry all` button to retry all failed tests (@SwinX) 103 | * Added tooltip for `replace` button. (@pazone) 104 | * Run button now has yellow color (@pazone) 105 | 106 | ## 2.0.0 - 2015-12-20 107 | 108 | * Switch to gemini@2.x (@j0tunn). 109 | 110 | ## 0.3.2 - 2015-11-02 111 | 112 | * Correctly mark tests from different suite paths in html-report (@sipayrt). 113 | * Add ability to pass arguments to gemini config (@sipayrt). 114 | * Run tests only in browsers from sets (@sipayrt). 115 | 116 | ## 0.3.1 - 2015-10-06 117 | 118 | * Update `gemini` version to v1.0.0 (@sipayrt). 119 | 120 | ## 0.3.0 - 2015-08-22 121 | 122 | * Work with `gemini` 0.13.x (@j0tunn). 123 | 124 | ## 0.2.3 - 2015-07-15 125 | 126 | * Added fix for circular JSON(@twolfson). 127 | 128 | ## 0.2.2 - 2015-05-21 129 | 130 | * Work with `gemini` 0.12.x (@SevInf). 131 | 132 | ## 0.2.1 - 2015-04-18 133 | 134 | * Work with `gemini` 0.11.x (@SevInf). 135 | 136 | ## 0.2.0 - 2015-04-17 137 | 138 | * Work with `gemini` 0.10.x (@SevInf). 139 | * Allow to choose color of background in image boxes (@unlok). 140 | * Correctly update title colors of a parent nodes when replacing 141 | the image (j0tunn). 142 | 143 | ## 0.1.3 - 2015-02-03 144 | 145 | * Ability to specify the hostname (@i-akhmadullin). 146 | 147 | ## 0.1.2 - 2014-10-24 148 | 149 | * Work with `gemini` 0.9.x 150 | 151 | ## 0.1.1 - 2014-10-01 152 | 153 | * Show correct reference image after replacing it with current. 154 | 155 | ## 0.1.0 - 2014-09-30 156 | 157 | Inital release. It is almost like HTML report, but updates in real-time 158 | and allows you to selectively update reference images. 159 | -------------------------------------------------------------------------------- /test/reuse-loader.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const Promise = require('bluebird'); 6 | const proxyquire = require('proxyquire'); 7 | const temp = require('temp'); 8 | 9 | const utils = require('./../lib/utils'); 10 | 11 | describe('lib/reuse-loader', () => { 12 | const sandbox = sinon.sandbox.create(); 13 | 14 | const mkLargeDownload = (load) => { 15 | const largeDownload = sinon.stub(); 16 | largeDownload.prototype.load = load || (() => Promise.resolve()); 17 | return largeDownload; 18 | }; 19 | 20 | const requireReuseDataFunc = (opts) => { 21 | opts = opts || {}; 22 | 23 | return proxyquire('../lib/reuse-loader', { 24 | 'large-download': opts.largeDownload || mkLargeDownload(), 25 | 'temp': { 26 | track: () => ({mkdirSync: sinon.stub().returns('/tmp')}) 27 | }, 28 | './utils': { 29 | decompress: opts.decompress || sinon.stub().returns(Promise.resolve()), 30 | Error: utils.Error 31 | } 32 | }); 33 | }; 34 | 35 | const stubModules = () => { 36 | sandbox.stub(temp, 'mkdirSync'); 37 | sandbox.stub(fs, 'existsSync').returns(false); 38 | sandbox.stub(fs, 'mkdirSync'); 39 | sandbox.stub(fs, 'readdirSync').returns([]); 40 | sandbox.stub(fs, 'statSync').returns({isDirectory: () => false}); 41 | sandbox.stub(path, 'resolve').returns('path'); 42 | }; 43 | 44 | afterEach(() => sandbox.restore()); 45 | 46 | it('should resolve if no url or path is passed', () => { 47 | const loadReuseData = requireReuseDataFunc(); 48 | stubModules(); 49 | 50 | return assert.isFulfilled(loadReuseData()); 51 | }); 52 | 53 | it('should reject if error occurred while reading file from fs', () => { 54 | const loadReuseData = requireReuseDataFunc(); 55 | stubModules(); 56 | fs.existsSync.returns(true); 57 | path.resolve.onSecondCall().throws(new Error('read file error')); 58 | 59 | return loadReuseData('path') 60 | .then( 61 | () => assert.fail('should reject'), 62 | (err) => { 63 | assert.include(err.message, 'Nothing to reuse in '); 64 | assert.include(err.stack, 'read file error'); 65 | } 66 | ); 67 | }); 68 | 69 | it('should download archive if valid url specified', () => { 70 | const largeDownloadMock = mkLargeDownload(); 71 | const loadReuseData = requireReuseDataFunc({largeDownload: largeDownloadMock}); 72 | stubModules(); 73 | 74 | return loadReuseData('url') 75 | .then(() => { 76 | assert.calledOnce(largeDownloadMock); 77 | assert.calledWith(largeDownloadMock, sinon.match({link: 'url'})); 78 | }); 79 | }); 80 | 81 | it('should reject if download fails', () => { 82 | const largeDownloadMock = mkLargeDownload(() => Promise.reject(new Error('download failed'))); 83 | const loadReuseData = requireReuseDataFunc({largeDownload: largeDownloadMock}); 84 | stubModules(); 85 | 86 | return loadReuseData('url') 87 | .then( 88 | () => assert.fail('should reject'), 89 | (err) => { 90 | assert.calledOnce(largeDownloadMock); 91 | 92 | assert.include(err.message, 'Failed to reuse report from '); 93 | assert.include(err.stack, 'download failed'); 94 | } 95 | ); 96 | }); 97 | 98 | it('should decompress archive if archive is downloaded', () => { 99 | const decompressMock = sinon.stub().returns(Promise.resolve()); 100 | const loadReuseData = requireReuseDataFunc({decompress: decompressMock}); 101 | stubModules(); 102 | 103 | return loadReuseData('url') 104 | .then(() => assert.calledOnce(decompressMock)); 105 | }); 106 | 107 | it('should reject if decompress fails', () => { 108 | const decompressMock = sinon.stub().returns(Promise.reject(new Error('extract failed'))); 109 | const loadReuseData = requireReuseDataFunc({decompress: decompressMock}); 110 | stubModules(); 111 | 112 | return loadReuseData('url') 113 | .then( 114 | () => assert.fail('should reject'), 115 | (err) => { 116 | assert.include(err.message, 'Failed to reuse report from '); 117 | assert.include(err.stack, 'extract failed'); 118 | } 119 | ); 120 | }); 121 | 122 | it('should use temp dir for reuse', () => { 123 | const loadReuseData = requireReuseDataFunc(); 124 | stubModules(); 125 | 126 | return loadReuseData('url') 127 | .then((reuseData) => { 128 | assert.isDefined(reuseData); 129 | assert.equal(reuseData.report, '/tmp'); 130 | }); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /lib/client/section-list.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Section = require('./section'), 4 | Index = require('../common/tests-index'), 5 | map = Array.prototype.map, 6 | every = Array.prototype.every; 7 | 8 | function SectionList(controller) { 9 | this._controller = controller; 10 | this._sectionsIndex = new Index(); 11 | this._sections = map.call(document.querySelectorAll('.section'), function(node) { 12 | var parentNode = this._findParentSectionNode(node), 13 | section = new Section(node, parentNode && this._sectionForNode(parentNode), this._onRunState.bind(this)); 14 | 15 | this._sectionsIndex.add(section); 16 | return section; 17 | }, this); 18 | } 19 | 20 | SectionList.prototype = { 21 | constructor: SectionList, 22 | 23 | expandAll: function() { 24 | this._sections.forEach(function(section) { 25 | section.expand(); 26 | }); 27 | }, 28 | 29 | collapseAll: function() { 30 | this._sections.forEach(function(section) { 31 | section.collapse(); 32 | }); 33 | }, 34 | 35 | acceptAll: function() { 36 | this.findFailedSections().forEach(function(section) { 37 | section.updateReference(); 38 | }); 39 | }, 40 | 41 | expandErrors: function() { 42 | this._sections.forEach(function(section) { 43 | section.expandIfError(); 44 | }); 45 | }, 46 | 47 | markAsQueued: function(leafQueries) { 48 | leafQueries = [].concat(leafQueries); 49 | 50 | var _this = this; 51 | 52 | this._sectionsIndex.find(leafQueries) 53 | .forEach(function(section) { 54 | section.setAsQueued(); 55 | 56 | while ((section = _this.findParent(section))) { 57 | section.status = 'queued'; 58 | } 59 | }); 60 | }, 61 | 62 | markAllAsQueued: function() { 63 | this._sections.forEach(function(section) { 64 | section.status = 'queued'; 65 | }); 66 | }, 67 | 68 | markIfFinished: function(section) { 69 | if (section.status === 'fail') { 70 | //already marked as fail 71 | return; 72 | } 73 | var nodes = section.domNode.querySelectorAll('.section'); 74 | var allChildrenFinished = every.call(nodes, function(node) { 75 | return this._sectionForNode(node).isFinished(); 76 | }, this); 77 | 78 | if (allChildrenFinished) { 79 | section.status = 'success'; 80 | } 81 | }, 82 | 83 | markBranchAsFailed: function(fromSection) { 84 | while ((fromSection = this.findParent(fromSection))) { 85 | fromSection.status = 'fail'; 86 | fromSection.expand(); 87 | } 88 | }, 89 | 90 | findSection: function(query) { 91 | return this._sectionsIndex.find(query); 92 | }, 93 | 94 | findParent: function(section) { 95 | if (section.browserId) { 96 | return this.findSection({ 97 | suite: section.suite, 98 | state: section.state 99 | }); 100 | } 101 | 102 | if (section.state && section.state.name) { 103 | return this.findSection({suite: section.suite}); 104 | } 105 | var parentSectionNode = this._findParentSectionNode(section.domNode); 106 | if (parentSectionNode) { 107 | return this._sectionForNode(parentSectionNode); 108 | } 109 | return null; 110 | }, 111 | 112 | findFailedStates: function() { 113 | return this.findFailedSections() 114 | .map(function(section) { 115 | return { 116 | suite: section.suite, 117 | state: section.state, 118 | browserId: section.browserId 119 | }; 120 | }); 121 | }, 122 | 123 | findFailedSections: function() { 124 | return this._sections 125 | .filter(function(section) { 126 | return section.status === 'fail' && section.browserId; 127 | }); 128 | }, 129 | 130 | toggleRetry: function(isEnabled) { 131 | this._sections.forEach(function(section) { 132 | section.toggleRetry(isEnabled); 133 | }); 134 | }, 135 | 136 | setViewLinkHost: function(host) { 137 | this._sections.forEach(function(section) { 138 | section.setViewHost(host); 139 | }); 140 | }, 141 | 142 | _findParentSectionNode: function(node) { 143 | while ((node = node.parentNode)) { 144 | if (node.classList && node.classList.contains('section')) { 145 | return node; 146 | } 147 | } 148 | return null; 149 | }, 150 | 151 | _sectionForNode: function(domNode) { 152 | var query = { 153 | suite: {path: domNode.getAttribute('data-suite-path')}, 154 | state: {name: domNode.getAttribute('data-state-name')}, 155 | browserId: domNode.getAttribute('data-browser-id') 156 | }; 157 | 158 | return this.findSection(query); 159 | }, 160 | 161 | _onRunState: function(state) { 162 | this._controller.runState(state); 163 | } 164 | }; 165 | 166 | module.exports = SectionList; 167 | -------------------------------------------------------------------------------- /lib/tests-model.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const Promise = require('bluebird'); 5 | const Index = require('./common/tests-index'); 6 | const clientUtils = require('./client-utils'); 7 | 8 | const getStatus = (skipped, reuse) => { 9 | if (skipped) { 10 | return 'skipped'; 11 | } 12 | 13 | return _.get(reuse, 'result.status', 'idle'); 14 | }; 15 | 16 | const suiteOrStateStatus = (items) => { 17 | const groups = _.groupBy(items, 'status'); 18 | 19 | if (!_.isEmpty(groups.error)) { 20 | return 'error'; 21 | } 22 | 23 | if (!_.isEmpty(groups.fail)) { 24 | return 'fail'; 25 | } 26 | 27 | if (!_.isEmpty(groups.idle)) { 28 | return 'idle'; 29 | } 30 | 31 | if (!_.isEmpty(groups.success)) { 32 | return 'success'; 33 | } 34 | 35 | return 'skipped'; 36 | }; 37 | 38 | function Tests(app) { 39 | this._app = app; 40 | this._index = new Index(); 41 | } 42 | 43 | Tests.prototype = { 44 | constructor: Tests, 45 | 46 | get data() { 47 | return this._data; 48 | }, 49 | 50 | get status() { 51 | return this._status; 52 | }, 53 | 54 | initialize(suiteCollection, rawReuseSuites) { 55 | const reuseSuites = rawReuseSuites || []; 56 | return this._mapSuites(suiteCollection.topLevelSuites(), reuseSuites) 57 | .then((data) => { 58 | this._data = data; 59 | this._status = suiteOrStateStatus(this._data); 60 | }); 61 | }, 62 | 63 | _mapSuites: function(suites, reuseSuites) { 64 | return Promise.all(suites.map((suite) => { 65 | const reuse = _.find(reuseSuites, {name: suite.name, suitePath: suite.path}); 66 | const reuseChildren = _.get(reuse, 'children') || []; 67 | 68 | return Promise.all([ 69 | this._mapSuites(suite.children, reuseChildren), 70 | this._mapStates(suite, reuseChildren) 71 | ]).spread((children, states) => { 72 | const status = suiteOrStateStatus(children.concat(states)); 73 | 74 | const data = {suite, children, states, status}; 75 | data.suite.skipComment && (data.suite.skipComment = clientUtils.wrapLinkByTag(data.suite.skipComment)); 76 | 77 | this._index.add(data); 78 | return data; 79 | }); 80 | })); 81 | }, 82 | 83 | _mapStates: function(suite, reuseStates) { 84 | return Promise.all(suite.states.map((state) => { 85 | const reuse = _.find(reuseStates, {name: state.name, suitePath: suite.path.concat(state.name)}); 86 | 87 | return this._getBrowsersData(suite, state, _.get(reuse, 'browsers')) 88 | .then((browsers) => { 89 | const status = suiteOrStateStatus(browsers); 90 | 91 | const data = {suite, state, browsers, status}; 92 | 93 | this._index.add(data); 94 | return data; 95 | }); 96 | })); 97 | }, 98 | 99 | _getBrowsersData: function(suite, state, reuseBrowsers) { 100 | return Promise.all(suite.browsers.map((browserId) => { 101 | const reuse = _.find(reuseBrowsers, {name: browserId}); 102 | 103 | let extraMeta = {}; 104 | if (reuse && reuse.result.metaInfo) { 105 | try { 106 | extraMeta = JSON.parse(reuse.result.metaInfo); 107 | // eslint-disable-next-line no-empty 108 | } catch (e) {} 109 | } 110 | 111 | const metaInfoObj = Object.assign({}, state.metaInfo, extraMeta); 112 | const metaInfo = !_.isEmpty(metaInfoObj) 113 | ? JSON.stringify(metaInfoObj, null, 4) 114 | : 'Meta info is not available'; 115 | 116 | const referencePath = this._app.getScreenshotPath(suite, state.name, browserId); 117 | const status = getStatus(state.shouldSkip(browserId), reuse); 118 | 119 | const reuseResult = reuse && _.findLast( 120 | [].concat(reuse.retries, reuse.result), 121 | (res) => res && (res.expectedPath || res.actualPath || res.diffPath) 122 | ); 123 | 124 | let currentPath; 125 | let diffPath; 126 | 127 | if (reuseResult) { 128 | currentPath = reuseResult.actualPath && this._app.createCurrentPathFor(); 129 | diffPath = reuseResult.diffPath && this._app.createDiffPathFor(); 130 | } 131 | 132 | const copyImagePromises = [ 133 | currentPath ? this._app.copyImage(reuse.result.actualPath, currentPath) : Promise.resolve(), 134 | diffPath ? this._app.copyImage(reuse.result.diffPath, diffPath) : Promise.resolve() 135 | ]; 136 | 137 | return Promise.all(copyImagePromises) 138 | .then(() => { 139 | if (status === 'skipped') { 140 | suite.skipComment = _.get(reuse, 'result.reason'); 141 | } 142 | 143 | const data = { 144 | suite, 145 | state, 146 | browserId, 147 | metaInfo, 148 | referenceURL: this._app.refPathToURL(referencePath, browserId), 149 | currentURL: currentPath && this._app.currentPathToURL(currentPath), 150 | diffURL: diffPath && this._app.diffPathToURL(diffPath), 151 | rootUrl: this._app.getRootUrlforBrowser(browserId), 152 | status, 153 | stack: status === 'error' && _.get(reuse, 'result.reason') 154 | }; 155 | 156 | if (data.status === 'fail') { 157 | this._app.addFailedTest({suite, state, browserId, referencePath, currentPath}); 158 | } 159 | 160 | this._index.add(data); 161 | return data; 162 | }); 163 | })); 164 | }, 165 | 166 | find: function(query) { 167 | return this._index.find(query); 168 | } 169 | }; 170 | 171 | module.exports = Tests; 172 | -------------------------------------------------------------------------------- /test/reporter.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const EventEmitter = require('events').EventEmitter; 4 | const Promise = require('bluebird'); 5 | const App = require('../lib/app'); 6 | const reporter = require('../lib/reporter'); 7 | 8 | describe('lib/reporter', () => { 9 | beforeEach(() => { 10 | this.app = sinon.createStubInstance(App); 11 | }); 12 | 13 | const emitToReporter = (event, data, app) => { 14 | const reporterFunc = reporter(app); 15 | const emitter = new EventEmitter(); 16 | 17 | reporterFunc(emitter); 18 | 19 | emitter.emit(event, data); 20 | }; 21 | 22 | const itShouldProxyEvent = (event, data) => { 23 | it('should proxy ' + event, () => { 24 | emitToReporter(event, data, this.app); 25 | 26 | assert.calledWith(this.app.sendClientEvent, event, data); 27 | }); 28 | }; 29 | 30 | const mkDummyError_ = (params) => { 31 | const error = new Error('example'); 32 | 33 | error.suite = params.suite || {id: -1}; 34 | error.browserId = params.browserId || 'default_browser'; 35 | error.state = params.state || {name: 'state'}; 36 | error.name = params.name || 'dummy_error'; 37 | error.sessionId = params.sessionId; 38 | 39 | return error; 40 | }; 41 | 42 | itShouldProxyEvent('begin', {}); 43 | 44 | itShouldProxyEvent('beginSuite', { 45 | suite: {name: 'test', id: 1}, 46 | browserId: 'bro' 47 | }); 48 | 49 | itShouldProxyEvent('beginState', { 50 | suite: {name: 'test', id: 1}, 51 | browserId: 'bro', 52 | state: {name: 'state'} 53 | }); 54 | 55 | itShouldProxyEvent('skipState', { 56 | suite: {name: 'test', id: 1}, 57 | browserId: 'bro', 58 | state: {name: 'state'} 59 | }); 60 | 61 | itShouldProxyEvent('endState', { 62 | suite: {name: 'test', id: 1}, 63 | browserId: 'bro', 64 | state: {name: 'state'} 65 | }); 66 | 67 | itShouldProxyEvent('endSuite', { 68 | suite: {name: 'test', id: 1}, 69 | browserId: 'bro' 70 | }); 71 | 72 | itShouldProxyEvent('end', {}); 73 | 74 | it('should replace paths with URLs if testResult emitted with equal images', () => { 75 | this.app.refPathToURL.withArgs('ref.png', 'browser').returns('/ref/browser/image.png'); 76 | this.app.currentPathToURL.withArgs('curr.png').returns('/curr/image.png'); 77 | 78 | emitToReporter('testResult', { 79 | suite: {id: 1, name: 'test', file: '/file'}, 80 | state: {name: 'state'}, 81 | browserId: 'browser', 82 | sessionId: 'session', 83 | equal: true, 84 | referencePath: 'ref.png', 85 | currentPath: 'curr.png' 86 | }, this.app); 87 | 88 | assert.calledWith(this.app.sendClientEvent, 'testResult', { 89 | suite: {id: 1, name: 'test', file: '/file'}, 90 | state: { 91 | name: 'state', 92 | metaInfo: {sessionId: 'session', file: '/file'} 93 | }, 94 | browserId: 'browser', 95 | equal: true, 96 | referenceURL: '/ref/browser/image.png', 97 | currentURL: '/curr/image.png' 98 | }); 99 | }); 100 | 101 | it('should build diff if images are not equal', () => { 102 | this.app.buildDiff.returns(Promise.resolve()); 103 | 104 | const failureData = { 105 | suite: {id: 1, name: 'test'}, 106 | state: {name: 'state'}, 107 | browserId: 'browser', 108 | equal: false, 109 | referencePath: 'ref.png', 110 | currentPath: 'curr.png' 111 | }; 112 | 113 | emitToReporter('testResult', failureData, this.app); 114 | 115 | assert.calledWith(this.app.buildDiff, failureData); 116 | }); 117 | 118 | it('should register failure', () => { 119 | this.app.buildDiff.returns(Promise.resolve()); 120 | 121 | emitToReporter('testResult', { 122 | suite: {id: 1, name: 'test', file: '/file'}, 123 | state: {name: 'state'}, 124 | browserId: 'browser', 125 | sessionId: 'session', 126 | equal: false, 127 | referencePath: 'ref.png', 128 | currentPath: 'curr.png' 129 | }, this.app); 130 | 131 | assert.calledWith(this.app.addFailedTest, { 132 | suite: {id: 1, name: 'test', file: '/file'}, 133 | state: { 134 | name: 'state', 135 | metaInfo: {sessionId: 'session', file: '/file'} 136 | }, 137 | browserId: 'browser', 138 | referencePath: 'ref.png', 139 | currentPath: 'curr.png' 140 | }); 141 | }); 142 | 143 | it('should send event with diffURL to the client', () => { 144 | this.app.refPathToURL.withArgs('ref.png', 'browser').returns('/ref/browser/image.png'); 145 | this.app.currentPathToURL.withArgs('curr.png').returns('/curr/image.png'); 146 | this.app.buildDiff.returns(Promise.resolve('/diff/image.png')); 147 | 148 | emitToReporter('testResult', { 149 | suite: {id: 1, name: 'test', file: '/file'}, 150 | state: {name: 'state'}, 151 | browserId: 'browser', 152 | sessionId: 'session', 153 | equal: false, 154 | referencePath: 'ref.png', 155 | currentPath: 'curr.png' 156 | }, this.app); 157 | 158 | return Promise.resolve().then(() => { 159 | assert.calledWith(this.app.sendClientEvent, 'testResult', { 160 | suite: {id: 1, name: 'test', file: '/file'}, 161 | state: { 162 | name: 'state', 163 | metaInfo: {sessionId: 'session', file: '/file'} 164 | }, 165 | browserId: 'browser', 166 | equal: false, 167 | referenceURL: '/ref/browser/image.png', 168 | currentURL: '/curr/image.png', 169 | diffURL: '/diff/image.png' 170 | }); 171 | }); 172 | }); 173 | 174 | it('should report error stack and metaInfo', () => { 175 | const error = mkDummyError_({ 176 | suite: {id: 1, file: '/some/file'}, 177 | browserId: 'browser', 178 | state: {name: 'state'}, 179 | sessionId: 'sessionId' 180 | }); 181 | 182 | emitToReporter('err', error, this.app); 183 | 184 | assert.calledWith(this.app.sendClientEvent, 'err', { 185 | suite: {id: 1, file: '/some/file'}, 186 | state: {metaInfo: {file: '/some/file', sessionId: 'sessionId'}, name: 'state'}, 187 | browserId: 'browser', 188 | stack: error.stack 189 | }); 190 | }); 191 | 192 | it('should register NoRefImageError as failure', () => { 193 | const error = mkDummyError_({ 194 | name: 'NoRefImageError' 195 | }); 196 | 197 | emitToReporter('err', error, this.app); 198 | 199 | assert.calledWith(this.app.addNoReferenceTest, error); 200 | }); 201 | 202 | it('should send `noReference` event to client', () => { 203 | this.app.currentPathToURL.returns('current_path'); 204 | 205 | const error = mkDummyError_({ 206 | name: 'NoRefImageError', 207 | suite: {id: 1}, 208 | state: {name: 'state'}, 209 | browserId: 'browser' 210 | }); 211 | 212 | emitToReporter('err', error, this.app); 213 | 214 | assert.calledWith(this.app.sendClientEvent, 'noReference', { 215 | suite: {id: 1}, 216 | state: {name: 'state'}, 217 | browserId: 'browser', 218 | currentURL: 'current_path' 219 | }); 220 | }); 221 | }); 222 | -------------------------------------------------------------------------------- /lib/client/controller.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var SectionList = require('./section-list'), 4 | xhr = require('./xhr'), 5 | byId = document.getElementById.bind(document), 6 | byClass = document.getElementsByClassName.bind(document); 7 | 8 | var RunStates = { 9 | RUNNING: 'running', 10 | PENDING: 'pending' 11 | }; 12 | 13 | function Controller() { 14 | this._sections = new SectionList(this); 15 | this._runButton = byId('run'); 16 | this._runFailedButton = byId('runFailed'); 17 | this._hostInput = byId('viewHostInput'); 18 | 19 | this.state = RunStates.PENDING; 20 | 21 | this._handleButtonClicks(); 22 | this._listenForEvents(); 23 | this._handleHostChange(); 24 | } 25 | 26 | Controller.prototype = { 27 | runState: function(state) { 28 | this.run(state); 29 | }, 30 | 31 | _runAllFailed: function() { 32 | var failed = this._sections.findFailedStates(); 33 | 34 | if (failed.length) { 35 | this.run(this._sections.findFailedStates()); 36 | } 37 | }, 38 | 39 | run: function(failed) { 40 | var _this = this; 41 | 42 | this._toggleButtons(false); 43 | this.state = RunStates.RUNNING; 44 | 45 | xhr.post('/run', failed, function(error) { 46 | if (error) { 47 | this.state = RunStates.PENDING; 48 | return; 49 | } 50 | return failed ? _this._sections.markAsQueued(failed) : _this._sections.markAllAsQueued(); 51 | }); 52 | }, 53 | 54 | _toggleButtons: function(isEnabled) { 55 | Array.prototype.forEach.call(byClass('button_togglable'), function(element) { 56 | element.disabled = !isEnabled; 57 | }); 58 | 59 | this._sections.toggleRetry(isEnabled); 60 | }, 61 | 62 | _handleButtonClicks: function() { 63 | var sections = this._sections, 64 | _this = this; 65 | 66 | byId('expandAll').addEventListener('click', sections.expandAll.bind(sections)); 67 | byId('collapseAll').addEventListener('click', sections.collapseAll.bind(sections)); 68 | byId('expandErrors').addEventListener('click', sections.expandErrors.bind(sections)); 69 | byId('acceptAll').addEventListener('click', sections.acceptAll.bind(sections)); 70 | byId('showOnlyErrors').addEventListener('click', function(e) { 71 | e.target.classList.toggle('button_checked'); 72 | document.body.classList.toggle('report_showOnlyErrors'); 73 | }); 74 | byId('toggleImageScale').addEventListener('click', function(e) { 75 | e.target.classList.toggle('button_checked'); 76 | document.body.classList.toggle('images_scaled'); 77 | }); 78 | 79 | this._runButton.addEventListener('click', function() { 80 | _this.run(); 81 | }); 82 | this._runFailedButton.addEventListener('click', this._runAllFailed.bind(this)); 83 | }, 84 | 85 | _handleHostChange: function() { 86 | var sections = this._sections, 87 | _this = this; 88 | 89 | this._hostInput.addEventListener('change', function() { 90 | sections.setViewLinkHost(_this._hostInput.value); 91 | // will save host to local storage 92 | if (window.localStorage) { 93 | window.localStorage.setItem('_gemini-replace-host', _this._hostInput.value); 94 | } 95 | }); 96 | 97 | // read saved host from local storage 98 | if (window.localStorage) { 99 | var host = window.localStorage.getItem('_gemini-replace-host'); 100 | if (host) { 101 | sections.setViewLinkHost(host); 102 | _this._hostInput.value = host; 103 | } 104 | } 105 | }, 106 | 107 | _listenForEvents: function() { 108 | var eventSource = new EventSource('/events'), 109 | _this = this; 110 | 111 | eventSource.addEventListener('beginSuite', function(e) { 112 | var data = JSON.parse(e.data), 113 | section = _this._sections.findSection({suite: data.suite}); 114 | 115 | if (section && section.status === 'queued') { 116 | section.status = 'running'; 117 | } 118 | }); 119 | 120 | eventSource.addEventListener('beginState', function(e) { 121 | var data = JSON.parse(e.data), 122 | section = _this._sections.findSection({ 123 | suite: data.suite, 124 | state: data.state 125 | }); 126 | 127 | if (section && section.status === 'queued') { 128 | section.status = 'running'; 129 | } 130 | }); 131 | 132 | eventSource.addEventListener('testResult', function(e) { 133 | var data = JSON.parse(e.data), 134 | section = _this._sections.findSection({ 135 | suite: data.suite, 136 | state: data.state, 137 | browserId: data.browserId 138 | }); 139 | 140 | data.metaInfo = data.state && data.state.metaInfo 141 | ? JSON.stringify(data.state.metaInfo, null, 4) 142 | : 'Meta info is not available'; 143 | 144 | if (data.equal) { 145 | section.setAsSuccess(data); 146 | } else { 147 | section.setAsFailure(data); 148 | section.expand(); 149 | _this._sections.markBranchAsFailed(section); 150 | } 151 | }); 152 | 153 | eventSource.addEventListener('skipState', function(e) { 154 | var data = JSON.parse(e.data), 155 | section = _this._sections.findSection({ 156 | suite: data.suite, 157 | state: data.state, 158 | browserId: data.browserId 159 | }); 160 | section.setAsSkipped(data); 161 | var stateSection = _this._sections.findSection({ 162 | suite: data.suite, 163 | state: data.state 164 | }); 165 | 166 | _this._sections.markIfFinished(stateSection); 167 | }); 168 | 169 | eventSource.addEventListener('err', function(e) { 170 | var data = JSON.parse(e.data), 171 | section = _this._sections.findSection({ 172 | suite: data.suite, 173 | state: data.state, 174 | browserId: data.browserId 175 | }); 176 | 177 | data.metaInfo = data.state && data.state.metaInfo 178 | ? JSON.stringify(data.state.metaInfo, null, 4) 179 | : 'Meta info is not available'; 180 | 181 | section.setAsError(data); 182 | section.expand(); 183 | _this._sections.markBranchAsFailed(section); 184 | }); 185 | 186 | eventSource.addEventListener('noReference', function(e) { 187 | var data = JSON.parse(e.data), 188 | section = _this._sections.findSection({ 189 | suite: data.suite, 190 | state: data.state, 191 | browserId: data.browserId 192 | }); 193 | 194 | section.setAsNewReference(data); 195 | section.expand(); 196 | _this._sections.markBranchAsFailed(section); 197 | }); 198 | 199 | eventSource.addEventListener('endState', function(e) { 200 | var data = JSON.parse(e.data), 201 | section = _this._sections.findSection({ 202 | suite: data.suite, 203 | state: data.state 204 | }); 205 | 206 | _this._sections.markIfFinished(section); 207 | }); 208 | 209 | eventSource.addEventListener('endSuite', function(e) { 210 | var data = JSON.parse(e.data), 211 | section = _this._sections.findSection({ 212 | suite: data.suite, 213 | state: data.state, 214 | browserId: data.browserId 215 | }); 216 | 217 | _this._sections.markIfFinished(section); 218 | }); 219 | 220 | eventSource.addEventListener('end', function() { 221 | _this._toggleButtons(true); 222 | _this.state = RunStates.PENDING; 223 | }); 224 | } 225 | }; 226 | 227 | module.exports = Controller; 228 | -------------------------------------------------------------------------------- /lib/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const Promise = require('bluebird'); 5 | const fs = Promise.promisifyAll(require('fs-extra')); 6 | const temp = require('temp'); 7 | const _ = require('lodash'); 8 | const chalk = require('chalk'); 9 | 10 | const findGemini = require('./find-gemini'); 11 | const loadReuseData = require('./reuse-loader'); 12 | const reporter = require('./reporter'); 13 | const Tests = require('./tests-model'); 14 | const Index = require('./common/tests-index'); 15 | const EventSource = require('./event-source'); 16 | const Runner = require('./runner'); 17 | 18 | const filterBrowsers = _.intersection; 19 | 20 | const checkUnknownBrowsers = (browsersFromConfig, browsersFromCli) => { 21 | const unknownBrowsers = _.difference(browsersFromCli, browsersFromConfig); 22 | 23 | if (!_.isEmpty(unknownBrowsers)) { 24 | console.warn('%s Unknown browser ids: %s. Use one of the browser ids specified in the config file: %s', 25 | chalk.yellow('WARNING:'), unknownBrowsers.join(', '), browsersFromConfig.join(', ')); 26 | } 27 | }; 28 | 29 | module.exports = class App { 30 | constructor(options) { 31 | this._options = options; 32 | this.diffDir = temp.path('gemini-gui-diff'); 33 | this.currentDir = temp.path('gemini-gui-curr'); 34 | this._failedTests = new Index(); 35 | 36 | this._eventSource = new EventSource(); 37 | 38 | const Gemini = findGemini(); 39 | this._gemini = new Gemini(this._options.config, {cli: true, env: true}); 40 | _.set(this._gemini.config, 'system.tempDir', this.currentDir); 41 | 42 | this._gemini.on('startRunner', (runner) => this._runner = runner); 43 | 44 | const browserIds = this._gemini.browserIds; 45 | checkUnknownBrowsers(browserIds, this._options.browser); 46 | 47 | this.referenceDirs = _.zipObject( 48 | browserIds, 49 | browserIds.map((id) => this._gemini.config.forBrowser(id).screenshotsDir) 50 | ); 51 | } 52 | 53 | initialize() { 54 | return this._recreateTmpDirs() 55 | .then(() => loadReuseData(this._options.reuse)) 56 | .then((reuseDataAndPath) => { 57 | if (reuseDataAndPath) { 58 | this._reusePath = reuseDataAndPath.report; 59 | return reuseDataAndPath.data; 60 | } 61 | }) 62 | .then((reuseData) => this._readTests(reuseData)); 63 | } 64 | 65 | addClient(connection) { 66 | this._eventSource.addConnection(connection); 67 | } 68 | 69 | sendClientEvent(event, data) { 70 | this._eventSource.emit(event, data); 71 | } 72 | 73 | getTests() { 74 | return Promise.resolve(this._tests.data); 75 | } 76 | 77 | getTestsStatus() { 78 | return Promise.resolve(this._tests.status); 79 | } 80 | 81 | _readTests(reuseData) { 82 | const opts = this._options; 83 | return Promise.resolve( 84 | this._gemini.readTests(opts.testFiles, { 85 | grep: opts.grep, 86 | sets: opts.set 87 | }) 88 | ) 89 | .then((collection) => { 90 | this._collection = collection; 91 | return collection.topLevelSuites(); 92 | }) 93 | .then((suites) => { 94 | if (!this._options.browser) { 95 | return; 96 | } 97 | 98 | suites.map((suite) => { 99 | suite.browsers = filterBrowsers(suite.browsers, this._options.browser); 100 | }); 101 | }) 102 | .then(() => { 103 | const suites = (reuseData || {}).suites; 104 | this._tests = new Tests(this); 105 | return this._tests.initialize(this._collection, suites); 106 | }); 107 | } 108 | 109 | run(specificTests) { 110 | return Runner.create(this._collection, specificTests) 111 | .run((collection) => this._gemini.test(collection, { 112 | reporters: [reporter(this), 'flat'] 113 | })); 114 | } 115 | 116 | _recreateTmpDirs() { 117 | return Promise.all([fs.removeAsync(this.currentDir), fs.removeAsync(this.diffDir)]) 118 | .then(() => Promise.all([ 119 | fs.mkdirpAsync(this.currentDir), 120 | fs.mkdirpAsync(this.diffDir) 121 | ])); 122 | } 123 | 124 | buildDiff(failureReport) { 125 | return this._buildDiffFile(failureReport) 126 | .then((diffPath) => this.diffPathToURL(diffPath)); 127 | } 128 | 129 | _buildDiffFile(failureReport) { 130 | const diffPath = temp.path({dir: this.diffDir, suffix: '.png'}); 131 | return Promise.resolve(failureReport.saveDiffTo(diffPath)) 132 | .thenReturn(diffPath); 133 | } 134 | 135 | addNoReferenceTest(test) { 136 | //adding ref path here because gemini requires real suite to calc ref path 137 | //and on client we have just stringified path 138 | test.referencePath = this.getScreenshotPath(test.suite, test.state.name, test.browserId); 139 | this.addFailedTest(test); 140 | } 141 | 142 | addFailedTest(test) { 143 | this._failedTests.add(test); 144 | } 145 | 146 | updateReferenceImage(testData) { 147 | const test = this._failedTests.find(testData); 148 | 149 | if (!test) { 150 | return Promise.reject(new Error('No such test failed')); 151 | } 152 | 153 | const result = { 154 | imagePath: test.referencePath, 155 | updated: true, 156 | suite: test.state.suite, 157 | state: test.state, 158 | browserId: test.browserId 159 | }; 160 | 161 | return fs.mkdirpAsync(path.dirname(test.referencePath)) 162 | .then(() => fs.copyAsync(test.currentPath, test.referencePath)) 163 | .then(() => { 164 | // if haven't run tests before updating screenshot then no runner present 165 | // this happens when we reuse result 166 | if (this._runner) { 167 | this._runner.emit('updateResult', result); 168 | } 169 | return this.refPathToURL(test.referencePath, test.browserId); 170 | }); 171 | } 172 | 173 | getScreenshotPath(suite, stateName, browserId) { 174 | return this._gemini.getScreenshotPath(suite, stateName, browserId); 175 | } 176 | 177 | getBrowserCapabilites(browserId) { 178 | return this._gemini.getBrowserCapabilites(browserId); 179 | } 180 | 181 | getRootUrlforBrowser(browserId) { 182 | return this._gemini.config.forBrowser(browserId).rootUrl; 183 | } 184 | 185 | createCurrentPathFor() { 186 | return temp.path({dir: this.currentDir, suffix: '.png'}); 187 | } 188 | 189 | createDiffPathFor() { 190 | return temp.path({dir: this.diffDir, suffix: '.png'}); 191 | } 192 | 193 | refPathToURL(fullPath, browserId) { 194 | return this._appendTimestampToURL( 195 | this._pathToURL(this.referenceDirs[browserId], fullPath, App.refPrefix + '/' + browserId) 196 | ); 197 | } 198 | 199 | currentPathToURL(fullPath) { 200 | return this._appendTimestampToURL( 201 | this._pathToURL(this.currentDir, fullPath, App.currentPrefix) 202 | ); 203 | } 204 | 205 | // add query string timestamp to avoid caching on client 206 | _appendTimestampToURL(URL) { 207 | return URL + '?t=' + new Date().valueOf(); 208 | } 209 | 210 | diffPathToURL(fullPath) { 211 | return this._pathToURL(this.diffDir, fullPath, App.diffPrefix); 212 | } 213 | 214 | _pathToURL(rootDir, fullPath, prefix) { 215 | const relPath = path.relative(rootDir, fullPath); 216 | return prefix + '/' + encodeURI(relPath); 217 | } 218 | 219 | copyImage(srcRelPath, dstPath) { 220 | const srcPath = path.resolve(this._reusePath, srcRelPath); 221 | // it is ok that image can be defunct 222 | return fs.copyAsync(srcPath, dstPath).catch(() => {}); 223 | } 224 | 225 | static get refPrefix() { 226 | return '/ref'; 227 | } 228 | 229 | static get currentPrefix() { 230 | return '/curr'; 231 | } 232 | 233 | static get diffPrefix() { 234 | return '/diff'; 235 | } 236 | }; 237 | -------------------------------------------------------------------------------- /lib/client/section.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var SuiteControls = require('./suite-controls'), 3 | utils = require('../client-utils'), 4 | successTemplate = require('../views/partials/success-result.hbs'), 5 | failTemplate = require('../views/partials/fail-result.hbs'), 6 | errorTemplate = require('../views/partials/error-result.hbs'), 7 | skipTemplate = require('../views/partials/skip-result.hbs'), 8 | noReferenceTemplate = require('../views/partials/no-reference-result.hbs'), 9 | xhr = require('./xhr'), 10 | url = require('url'), 11 | _ = require('lodash'), 12 | Clipboard = require('clipboard'); 13 | 14 | var statusList = [ 15 | 'idle', 16 | 'queued', 17 | 'running', 18 | 'success', 19 | 'fail', 20 | 'skipped', 21 | 'error' 22 | ]; 23 | 24 | function statusClass(status) { 25 | return 'section_status_' + status; 26 | } 27 | 28 | function getInitialStatus(sectionNode) { 29 | var status; 30 | for (var i = 0; i < statusList.length; i++) { 31 | status = statusList[i]; 32 | if (sectionNode.classList.contains(statusClass(status))) { 33 | return status; 34 | } 35 | } 36 | return null; 37 | } 38 | 39 | function Section(node, parent, runStateHandler) { 40 | this.suite = {path: node.getAttribute('data-suite-path')}; 41 | this.state = {name: node.getAttribute('data-state-name')}; 42 | this.browserId = node.getAttribute('data-browser-id'); 43 | this.domNode = node; 44 | this._status = getInitialStatus(node); 45 | this._titleNode = node.querySelector('.section__title'); 46 | this._bodyNode = node.querySelector('.section__body'); 47 | this._titleNode.addEventListener('click', this.toggle.bind(this)); 48 | this._parent = parent; 49 | this._runStateHandler = runStateHandler; 50 | this._suiteControls = null; 51 | this._viewLink = node.querySelector('[data-suite-view-link]'); 52 | this._clipboardBtn = node.querySelector('.button[data-clipboard-text]'); 53 | 54 | if (this._clipboardBtn) { 55 | /* eslint-disable no-new */ 56 | new Clipboard(this._clipboardBtn); 57 | } 58 | 59 | // turning off event bubbling when click button 60 | Array.prototype.forEach.call(node.querySelectorAll('.button'), function(elem) { 61 | elem.addEventListener('click', function(e) { 62 | e.stopPropagation(); 63 | }); 64 | }); 65 | 66 | // for state: change suite controls state according to state initial status 67 | if (this.browserId) { 68 | var retry = this.retry.bind(this); 69 | var suiteControls = new SuiteControls(this._bodyNode); 70 | 71 | switch (this._status) { 72 | case 'success': { 73 | this._suiteControls = suiteControls; 74 | this._suiteControls.setAsSuccess(retry); 75 | break; 76 | } 77 | case 'fail': { 78 | this._suiteControls = suiteControls; 79 | this._suiteControls.setAsFailure(retry, this.updateReference.bind(this)); 80 | break; 81 | } 82 | case 'error': { 83 | this.status = 'fail'; 84 | this._suiteControls = suiteControls; 85 | this._suiteControls.setAsError(retry); 86 | break; 87 | } 88 | } 89 | } 90 | } 91 | 92 | Section.prototype = { 93 | constructor: Section, 94 | expand: function() { 95 | this.domNode.classList.remove('section_collapsed'); 96 | 97 | var lazyImageNodes = this.domNode.querySelectorAll('.image-box__image-lazy'); 98 | if (lazyImageNodes.length) { 99 | lazyImageNodes.forEach(function(imageNode) { 100 | imageNode.setAttribute('src', imageNode.getAttribute('data-src')); 101 | imageNode.classList.remove('.image-box__image-lazy'); 102 | }); 103 | } 104 | }, 105 | 106 | collapse: function() { 107 | this.domNode.classList.add('section_collapsed'); 108 | }, 109 | 110 | toggle: function() { 111 | if (this.domNode.classList.contains('section_collapsed')) { 112 | return this.expand(); 113 | } 114 | this.collapse(); 115 | }, 116 | 117 | get status() { 118 | return this._status; 119 | }, 120 | 121 | set status(value) { 122 | if (this._status) { 123 | this.domNode.classList.remove(statusClass(this._status)); 124 | } 125 | 126 | this._status = value; 127 | this.domNode.classList.add(statusClass(this._status)); 128 | }, 129 | 130 | expandIfError: function() { 131 | if (this.status === 'fail') { 132 | this.expand(); 133 | } else { 134 | this.collapse(); 135 | } 136 | }, 137 | 138 | setAsQueued: function() { 139 | this.status = 'queued'; 140 | while (this._bodyNode.hasChildNodes()) { 141 | this._bodyNode.removeChild(this._bodyNode.firstChild); 142 | } 143 | }, 144 | 145 | setAsFailure: function(results) { 146 | this.status = 'fail'; 147 | this._bodyNode.innerHTML = failTemplate(results); 148 | 149 | this._suiteControls = new SuiteControls(this._bodyNode); 150 | this._suiteControls.setAsFailure(this.retry.bind(this), this.updateReference.bind(this)); 151 | }, 152 | 153 | setAsSuccess: function(results) { 154 | var failedChild = this.domNode.querySelector('.section.' + statusClass('fail')); 155 | if (failedChild) { 156 | return; 157 | } 158 | 159 | this.status = 'success'; 160 | if (results) { 161 | this._bodyNode.innerHTML = successTemplate(results); 162 | 163 | this._suiteControls = new SuiteControls(this._bodyNode); 164 | this._suiteControls.setAsSuccess(this.retry.bind(this)); 165 | } 166 | 167 | if (this._parent && this._parent.status === 'fail') { 168 | this._parent.setAsSuccess(); 169 | } 170 | }, 171 | 172 | setAsSkipped: function(skipped) { 173 | this.status = 'skipped'; 174 | 175 | skipped.suite.skipComment && (skipped.suite.skipComment = utils.wrapLinkByTag(skipped.suite.skipComment)); 176 | 177 | this._bodyNode.innerHTML = skipTemplate(skipped); 178 | }, 179 | 180 | setAsError: function(error) { 181 | this.status = 'fail'; 182 | this._bodyNode.innerHTML = errorTemplate(error); 183 | 184 | this._suiteControls = new SuiteControls(this._bodyNode); 185 | this._suiteControls.setAsError(this.retry.bind(this)); 186 | }, 187 | 188 | setAsNewReference: function(result) { 189 | this.status = 'fail'; 190 | this._bodyNode.innerHTML = noReferenceTemplate(result); 191 | 192 | this._suiteControls = new SuiteControls(this._bodyNode); 193 | this._suiteControls.setAsNewReference(this.retry.bind(this), this.updateReference.bind(this)); 194 | }, 195 | 196 | isFinished: function() { 197 | return this.status !== 'queued' && 198 | this.status !== 'running'; 199 | }, 200 | 201 | updateReference: function() { 202 | var _this = this, 203 | postData = this._collectStateData(); 204 | xhr.post('/update-ref', postData, function(error, response) { 205 | if (!error) { 206 | _this.setAsSuccess(response); 207 | } 208 | }); 209 | }, 210 | 211 | retry: function() { 212 | this._runStateHandler(this._collectStateData()); 213 | }, 214 | 215 | toggleRetry: function(isEnabled) { 216 | if (this._suiteControls) { 217 | this._suiteControls.toggleRetry(isEnabled); 218 | } 219 | }, 220 | 221 | setViewHost: function(host) { 222 | if (!this._viewLink) { 223 | return; 224 | } 225 | var href = this._viewLink.dataset.suiteViewLink, 226 | parsedHost; 227 | 228 | if (host) { 229 | parsedHost = url.parse(host, false, true); 230 | // extending current url from entered host 231 | href = url.format(_.assign( 232 | url.parse(href), 233 | { 234 | host: parsedHost.slashes ? parsedHost.host : host, 235 | protocol: parsedHost.slashes ? parsedHost.protocol : null, 236 | hostname: null, 237 | port: null 238 | } 239 | )); 240 | } 241 | this._viewLink.setAttribute('href', href); 242 | }, 243 | 244 | _collectStateData: function() { 245 | return { 246 | suite: this.suite, 247 | state: this.state, 248 | browserId: this.browserId 249 | }; 250 | } 251 | }; 252 | 253 | module.exports = Section; 254 | -------------------------------------------------------------------------------- /lib/static/main.css: -------------------------------------------------------------------------------- 1 | @-webkit-keyframes blink { 2 | from { opacity: 1;} 3 | to {opacity: 0.3;} 4 | } 5 | 6 | @-moz-keyframes blink { 7 | from { opacity: 1;} 8 | to {opacity: 0.3;} 9 | } 10 | 11 | @-o-keyframes blink { 12 | from { opacity: 1;} 13 | to {opacity: 0.3;} 14 | } 15 | 16 | @keyframes blink { 17 | from { opacity: 1;} 18 | to {opacity: 0.3;} 19 | } 20 | 21 | html, body { 22 | font: 14px Helvetica Neue, Arial, sans-serif; 23 | } 24 | 25 | .summary__key { 26 | font-weight: bold; 27 | display: inline; 28 | } 29 | 30 | .summary__key::after { 31 | content: ':'; 32 | } 33 | 34 | .summary__key_has-fails { 35 | color: #c00; 36 | } 37 | 38 | .summary__value { 39 | margin-left: 5px; 40 | margin-right: 20px; 41 | display: inline; 42 | } 43 | 44 | .button { 45 | background: #fff; 46 | border: 1px solid #ccc; 47 | border-radius: 2px; 48 | line-height: 10px; 49 | padding: 5px; 50 | outline: 0; 51 | box-sizing: content-box; 52 | } 53 | 54 | .button:disabled { 55 | background: #ccc; 56 | } 57 | 58 | .button:hover:enabled { 59 | border-color: #555; 60 | } 61 | 62 | .button:active:enabled { 63 | background: #eee; 64 | } 65 | 66 | .button_checked { 67 | background: #ffeba0; 68 | border-color: #cebe7d; 69 | } 70 | 71 | .button_type_action { 72 | background: #ffde5a; 73 | } 74 | 75 | .text-input { 76 | padding: 3px 5px; 77 | border: 1px solid #ccc; 78 | border-radius: 2px; 79 | } 80 | .image-box { 81 | display: flex; 82 | flex-flow: row wrap; 83 | } 84 | 85 | .image-box, .error-box{ 86 | background: #c9c9c9; 87 | padding: 5px; 88 | border: 1px solid #ccc; 89 | vertical-align: middle; 90 | } 91 | 92 | .image-box__image { 93 | padding: 0 5px; 94 | display: block; 95 | vertical-align: top; 96 | flex-basis: 33.333%; 97 | box-sizing: border-box; 98 | flex-grow: 1; 99 | } 100 | 101 | .images_scaled .image-box__image img { 102 | max-width: 100%; 103 | height: auto; 104 | } 105 | 106 | .image-box__replace { 107 | width: 40px; 108 | } 109 | 110 | .section__title { 111 | font-weight: bold; 112 | cursor: pointer; 113 | display: block; 114 | 115 | -moz-user-select: none; 116 | -webkit-user-select: none; 117 | -ms-user-select: none; 118 | user-select: none; 119 | } 120 | 121 | .section__title:before { 122 | display: inline-block; 123 | margin-right: 2px; 124 | vertical-align: middle; 125 | content: '\25cf'; 126 | color: #ccc; 127 | } 128 | 129 | .meta-info { 130 | margin: 5px; 131 | } 132 | 133 | .meta-info__switcher { 134 | display: inline-block; 135 | cursor: pointer; 136 | } 137 | 138 | .meta-info__content { 139 | margin: 5px 0; 140 | background: #f6f5f3; 141 | padding: 5px; 142 | } 143 | 144 | .meta-info_collapsed .meta-info__content { 145 | display: none; 146 | } 147 | 148 | .meta-info__switcher:before { 149 | display: inline-block; 150 | margin-right: 2px; 151 | vertical-align: middle; 152 | content: '\25bc'; 153 | color: black; 154 | } 155 | 156 | .meta-info_collapsed .meta-info__switcher:before { 157 | transform: rotate(-90deg); 158 | } 159 | 160 | .section_status_queued > .section__title::before { 161 | color: #e7d700; 162 | } 163 | 164 | .section_status_running > .section__title::before { 165 | -webkit-animation: blink 0.5s linear 0s infinite alternate; 166 | -moz-animation: blink 0.5s linear 0s infinite alternate; 167 | -o-animation: blink 0.5s linear 0s infinite alternate; 168 | animation: blink 0.5s linear 0s infinite alternate; 169 | color: #e7d700; 170 | } 171 | 172 | .section_status_success > .section__title, 173 | .section_status_success > .section__title::before { 174 | color: #038035; 175 | } 176 | 177 | .section_status_fail > .section__title, 178 | .section_status_fail > .section__title::before { 179 | color: #c00; 180 | } 181 | 182 | .section_status_skipped > .section__title, 183 | .section_status_skipped > .section__title::before { 184 | color: #ccc; 185 | } 186 | 187 | .report_showOnlyErrors .section { 188 | display: none; 189 | } 190 | 191 | .report_showOnlyErrors .section_status_fail { 192 | display: block; 193 | } 194 | 195 | .section__title:hover { 196 | color: #2D3E50; 197 | } 198 | 199 | .section__body { 200 | padding-left: 15px; 201 | } 202 | 203 | .section__body_guided { 204 | border-left: 1px dotted #ccc; 205 | } 206 | 207 | .section_collapsed .section__body { 208 | display: none; 209 | } 210 | 211 | .stacktrace { 212 | background: #f6f5f3; 213 | margin: 5px; 214 | padding: 5px; 215 | font: 12px Consolas, Monaco, monospace; 216 | overflow-x: scroll; 217 | } 218 | 219 | .controls { 220 | background: #C9C9C9; 221 | position: -webkit-sticky; 222 | position: sticky; 223 | width: 100%; 224 | top: 0; 225 | } 226 | 227 | .controls__item { 228 | padding: 5px; 229 | display: inline-block; 230 | vertical-align: top; 231 | } 232 | 233 | .cswitcher__item { 234 | width: 20px; 235 | height: 20px; 236 | display: inline-block; 237 | box-shadow: 0 0 1px #000; 238 | border: 1px solid #fff; 239 | } 240 | 241 | .cswitcher__item_selected.cswitcher__item::before { 242 | content: ''; 243 | position: absolute; 244 | background: rgba(4, 4, 4, 0.3) no-repeat 3px 2px url('data:image/svg+xml;utf8,'); 245 | height: 20px; 246 | width: 20px; 247 | } 248 | 249 | .cswitcher__item:hover { 250 | cursor: pointer; 251 | } 252 | 253 | .cswitcher_color_1 { 254 | background: #c9c9c9; 255 | } 256 | 257 | .cswitcher_color_2 { 258 | background: #d5ff09; 259 | } 260 | 261 | .cswitcher_color_3 { 262 | background-image: url('data:image/svg+xml;utf8,'); 263 | } 264 | 265 | .suite-controls__item { 266 | box-shadow: 0 0 1px #000; 267 | border: 1px solid #fff; 268 | display: inline-block; 269 | font-size: 14px; 270 | height: 22px; 271 | padding: 0 4px; 272 | } 273 | 274 | .suite-controls__item::before { 275 | padding-right: 4px; 276 | } 277 | 278 | .suite-controls__item:hover { 279 | cursor: pointer; 280 | } 281 | 282 | .suite-controls__item_disabled { 283 | opacity: 0.3; 284 | } 285 | 286 | .suite-controls__item_disabled:hover { 287 | cursor: not-allowed; 288 | } 289 | 290 | .suite-controls__item_hidden { 291 | display: none; 292 | } 293 | 294 | .suite-controls__accept::before { 295 | content: '✔'; 296 | } 297 | 298 | .suite-controls__retry::before { 299 | content: '↻'; 300 | } 301 | 302 | .section__icon { 303 | display: inline-block; 304 | width: 19px; 305 | height: 19px; 306 | vertical-align: top; 307 | padding: 0 3px; 308 | border: none; 309 | opacity: 0.15; 310 | cursor: pointer; 311 | } 312 | 313 | .section__icon:hover { 314 | opacity: 1; 315 | } 316 | 317 | .section__icon:before { 318 | display: block; 319 | width: 100%; 320 | height: 100%; 321 | content: ''; 322 | background-repeat: no-repeat; 323 | background-size: 100%; 324 | } 325 | 326 | .section__icon_view-local:before { 327 | background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/PjwhRE9DVFlQRSBzdmcgIFBVQkxJQyAnLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4nICAnaHR0cDovL3d3dy53My5vcmcvR3JhcGhpY3MvU1ZHLzEuMS9EVEQvc3ZnMTEuZHRkJz48c3ZnIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgLTAuNzA5IC0zMi4wODEgMTQxLjczMiAxNDEuNzMyIiBoZWlnaHQ9IjE0MS43MzJweCIgaWQ9IkxpdmVsbG9fMSIgdmVyc2lvbj0iMS4xIiB2aWV3Qm94PSItMC43MDkgLTMyLjA4MSAxNDEuNzMyIDE0MS43MzIiIHdpZHRoPSIxNDEuNzMycHgiIHhtbDpzcGFjZT0icHJlc2VydmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiPjxnIGlkPSJMaXZlbGxvXzgwIj48cGF0aCBkPSJNODkuNjY4LDM4Ljc4NmMwLTEwLjc3My04LjczMS0xOS41MTItMTkuNTEtMTkuNTEyUzUwLjY0NiwyOC4wMSw1MC42NDYsMzguNzg2YzAsMTAuNzc0LDguNzMyLDE5LjUxMSwxOS41MTIsMTkuNTExICAgQzgwLjkzNCw1OC4yOTcsODkuNjY4LDQ5LjU2MSw4OS42NjgsMzguNzg2IE0xMjguMzUyLDM4LjcyN2MtMTMuMzE1LDE3LjU5OS0zNC40MjYsMjguOTcyLTU4LjE5MywyOC45NzIgICBjLTIzLjc3LDAtNDQuODc5LTExLjM3My01OC4xOTQtMjguOTcyQzI1LjI3OSwyMS4xMjksNDYuMzg5LDkuNzU2LDcwLjE1OCw5Ljc1NkM5My45MjcsOS43NTYsMTE1LjAzNiwyMS4xMjksMTI4LjM1MiwzOC43MjcgICAgTTE0MC4zMTQsMzguNzZDMTI1LjY2NiwxNS40NzgsOTkuNzI1LDAsNzAuMTU4LDBTMTQuNjQ4LDE1LjQ3OCwwLDM4Ljc2YzE0LjY0OCwyMy4zMTIsNDAuNTkxLDM4LjgxLDcwLjE1OCwzOC44MSAgIFMxMjUuNjY2LDYyLjA3MiwxNDAuMzE0LDM4Ljc2Ii8+PC9nPjxnIGlkPSJMaXZlbGxvXzFfMV8iLz48L3N2Zz4=); 328 | } 329 | 330 | .section__icon_copy-to-clipboard:before { 331 | background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/PjwhRE9DVFlQRSBzdmcgIFBVQkxJQyAnLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4nICAnaHR0cDovL3d3dy53My5vcmcvR3JhcGhpY3MvU1ZHLzEuMS9EVEQvc3ZnMTEuZHRkJz48c3ZnIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDUxMiA1MTIiIGhlaWdodD0iNTEycHgiIGlkPSJMYXllcl8xIiB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCA1MTIgNTEyIiB3aWR0aD0iNTEycHgiIHhtbDpzcGFjZT0icHJlc2VydmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiPjxwYXRoIGQ9Ik00NjguNDkzLDEwMS42MzdMMzcxLjk1NSw1LjA5OEgxNTkuNTd2NzcuMjMxSDQzLjcyNHY0MjQuNzY5aDMwOC45MjN2LTc3LjIzMWgxMTUuODQ2VjEwMS42Mzd6ICAgTTM3MS45NTUsMzIuNDAxbDY5LjIzNiw2OS4yMzVoLTY5LjIzNlYzMi40MDF6IE02My4wMzEsNDg3Ljc5VjEwMS42MzdoMTczLjc2OXY5Ni41MzhoOTYuNTM4VjQ4Ny43OUg2My4wMzF6IE0yNTYuMTA4LDEwOS42MzIgIGw2OS4yMzYsNjkuMjM1aC02OS4yMzZWMTA5LjYzMnogTTM1Mi42NDcsNDEwLjU2VjE3OC44NjdsLTk2LjUzOC05Ni41MzhoLTc3LjIzMVYyNC40MDZoMTczLjc2OXY5Ni41MzhoOTYuNTM4VjQxMC41NkgzNTIuNjQ3eiIgZmlsbD0iIzM3NDA0RCIvPjwvc3ZnPg==); 332 | background-position: center center; 333 | background-size: 90%; 334 | } 335 | -------------------------------------------------------------------------------- /test/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const proxyquire = require('proxyquire'); 5 | const Promise = require('bluebird'); 6 | const fs = Promise.promisifyAll(require('fs-extra')); 7 | const path = require('path'); 8 | 9 | const RunnerFactory = require('../lib/runner'); 10 | const AllSuitesRunner = require('../lib/runner/all-suites-runner'); 11 | const mkDummyCollection = require('./test-utils').mkDummyCollection; 12 | 13 | describe('lib/app', () => { 14 | const sandbox = sinon.sandbox.create(); 15 | 16 | let suiteCollection; 17 | let Gemini; 18 | let App; 19 | let app; 20 | let runner; 21 | 22 | const stubFs_ = () => { 23 | sandbox.stub(fs, 'removeAsync').returns(Promise.resolve()); 24 | sandbox.stub(fs, 'mkdirpAsync').returns(Promise.resolve()); 25 | sandbox.stub(fs, 'copyAsync').returns(Promise.resolve()); 26 | sandbox.stub(fs, 'readJsonAsync').returns(Promise.resolve({})); 27 | }; 28 | 29 | const mkApp_ = (config) => new App(config || {}); 30 | 31 | beforeEach(() => { 32 | suiteCollection = mkDummyCollection(); 33 | 34 | runner = {emit: sandbox.spy()}; 35 | 36 | Gemini = sandbox.stub(); 37 | Gemini.prototype.browserIds = []; 38 | Gemini.prototype.readTests = sandbox.stub().returns(Promise.resolve(suiteCollection)); 39 | Gemini.prototype.test = sandbox.stub().returns(Promise.resolve()); 40 | Gemini.prototype.on = sandbox.stub().yields(runner); 41 | 42 | App = proxyquire('../lib/app', { 43 | './find-gemini': sandbox.stub().returns(Gemini) 44 | }); 45 | 46 | app = mkApp_(); 47 | }); 48 | 49 | afterEach(() => sandbox.restore()); 50 | 51 | describe('initialize', () => { 52 | beforeEach(() => stubFs_()); 53 | 54 | it('should remove old fs tree for current images dir if it exists', () => { 55 | app.currentDir = 'current_dir'; 56 | 57 | return app.initialize() 58 | .then(() => assert.calledWith(fs.removeAsync, 'current_dir')); 59 | }); 60 | 61 | it('should remove old fs tree for diff images dir if it exists', () => { 62 | app.diffDir = 'diff_dir'; 63 | 64 | return app.initialize() 65 | .then(() => assert.calledWith(fs.removeAsync, 'diff_dir')); 66 | }); 67 | 68 | it('should create new tree for current images dir', () => { 69 | app.currentDir = 'current_dir'; 70 | 71 | return app.initialize() 72 | .then(() => assert.calledWith(fs.mkdirpAsync, 'current_dir')); 73 | }); 74 | 75 | it('should create new tree for diff images dir', () => { 76 | app.currentDir = 'diff_dir'; 77 | 78 | return app.initialize() 79 | .then(() => assert.calledWith(fs.mkdirpAsync, 'diff_dir')); 80 | }); 81 | 82 | it('should read tests', () => { 83 | const app = mkApp_({ 84 | testFiles: ['test_file', 'another_test_file'] 85 | }); 86 | 87 | return app.initialize() 88 | .then(() => { 89 | assert.calledWith(Gemini.prototype.readTests, 90 | ['test_file', 'another_test_file']); 91 | }); 92 | }); 93 | 94 | it('should pass options from cli to Gemini', () => { 95 | const app = mkApp_({ 96 | testFiles: ['test_file'], 97 | grep: 'grep', 98 | set: ['set'] 99 | }); 100 | 101 | return app.initialize() 102 | .then(() => { 103 | assert.calledWith(Gemini.prototype.readTests, 104 | ['test_file'], {grep: 'grep', sets: ['set']}); 105 | }); 106 | }); 107 | }); 108 | 109 | describe('run', () => { 110 | it('should create and execute runner', () => { 111 | const runnerInstance = sinon.createStubInstance(AllSuitesRunner); 112 | 113 | sandbox.stub(RunnerFactory, 'create').returns(runnerInstance); 114 | 115 | app.run(); 116 | 117 | assert.called(runnerInstance.run); 118 | }); 119 | 120 | it('should pass run handler to runner which will execute gemeni', () => { 121 | const runnerInstance = sinon.createStubInstance(AllSuitesRunner); 122 | 123 | runnerInstance.run.yields(); 124 | sandbox.stub(RunnerFactory, 'create').returns(runnerInstance); 125 | 126 | app.run(); 127 | 128 | assert.called(Gemini.prototype.test); 129 | }); 130 | }); 131 | 132 | describe('addNoReferenceTest', () => { 133 | beforeEach(() => sandbox.stub(app, 'addFailedTest')); 134 | 135 | it('should add to test reference image path', () => { 136 | const test = { 137 | suite: {id: 1}, 138 | state: {name: 'state'}, 139 | browserId: 'browser' 140 | }; 141 | 142 | sandbox.stub(app, 'getScreenshotPath').returns('some_screenshot_path'); 143 | app.addNoReferenceTest(test); 144 | 145 | assert.equal(test.referencePath, 'some_screenshot_path'); 146 | }); 147 | 148 | it('should add test with no reference error to failed tests', () => { 149 | const test = { 150 | suite: {id: 1}, 151 | state: {name: 'state'}, 152 | browserId: 'browser' 153 | }; 154 | 155 | sandbox.stub(app, 'getScreenshotPath'); 156 | app.addNoReferenceTest(test); 157 | 158 | assert.calledWith(app.addFailedTest, test); 159 | }); 160 | }); 161 | 162 | describe('updateReferenceImage', () => { 163 | const mkDummyTest_ = (params) => { 164 | return _.defaults(params || {}, { 165 | suite: {path: 'default_suite_path'}, 166 | state: 'default_state', 167 | browserId: 'default_browser', 168 | referencePath: 'default/reference/path', 169 | currentPath: 'default/current/path' 170 | }); 171 | }; 172 | 173 | beforeEach(() => { 174 | stubFs_(); 175 | sandbox.stub(app, 'refPathToURL'); 176 | }); 177 | 178 | it('should reject reference update if no failed test registered', () => { 179 | const test = mkDummyTest_(); 180 | 181 | return assert.isRejected(app.updateReferenceImage(test), 'No such test failed'); 182 | }); 183 | 184 | it('should create directory tree for reference image before saving', () => { 185 | const test = mkDummyTest_({referencePath: 'path/to/reference/image.png'}); 186 | 187 | app.addFailedTest(test); 188 | 189 | return app.updateReferenceImage(test) 190 | .then(() => assert.calledWith(fs.mkdirpAsync, 'path/to/reference')); 191 | }); 192 | 193 | it('should copy current image to reference folder', () => { 194 | const referencePath = 'path/to/reference/image.png'; 195 | const currentPath = 'path/to/current/image.png'; 196 | 197 | const test = mkDummyTest_({referencePath, currentPath}); 198 | 199 | app.addFailedTest(test); 200 | 201 | return app.updateReferenceImage(test) 202 | .then(() => assert.calledWith(fs.copyAsync, currentPath, referencePath)); 203 | }); 204 | 205 | it('should emit updateResult event with result argument by emit', () => { 206 | const test = mkDummyTest_({referencePath: 'path/to/reference.png'}); 207 | 208 | const result = { 209 | imagePath: 'path/to/reference.png', 210 | updated: true, 211 | suite: test.state.suite, 212 | state: test.state, 213 | browserId: test.browserId 214 | }; 215 | 216 | app.addFailedTest(test); 217 | 218 | return app.updateReferenceImage(test) 219 | .then(() => assert.calledWithExactly(runner.emit, 'updateResult', result)); 220 | }); 221 | 222 | it('should emit updateResult event only after copy current image to reference folder', () => { 223 | const test = mkDummyTest_(); 224 | 225 | app.addFailedTest(test); 226 | 227 | return app.updateReferenceImage(test) 228 | .then(() => assert.isTrue(runner.emit.calledAfter(fs.copyAsync))); 229 | }); 230 | 231 | it('should be resolved with URL to updated reference', () => { 232 | const test = mkDummyTest_(); 233 | 234 | app.refPathToURL.returns(Promise.resolve('http://dummy_ref.url')); 235 | app.addFailedTest(test); 236 | 237 | return app.updateReferenceImage(test) 238 | .then((result) => assert.equal(result, 'http://dummy_ref.url')); 239 | }); 240 | }); 241 | 242 | describe('refPathToURL', () => { 243 | beforeEach(() => { 244 | app.referenceDirs = { 245 | 'browser_id': 'browser_reference_dir' 246 | }; 247 | }); 248 | 249 | it('should append timestamp to resulting URL', () => { 250 | const result = app.refPathToURL('full_path', 'browser_id'); 251 | 252 | return assert.match(result, /\?t=\d+/); 253 | }); 254 | }); 255 | 256 | describe('currentPathToURL', () => { 257 | it('should append timestamp to resulting URL', () => { 258 | const result = app.currentPathToURL('full_path'); 259 | 260 | return assert.match(result, /\?t=\d+/); 261 | }); 262 | }); 263 | 264 | describe('copyImage', () => { 265 | beforeEach(() => { 266 | stubFs_(); 267 | sandbox.stub(path, 'resolve'); 268 | }); 269 | 270 | it('should resolve if image was copied successfully', () => { 271 | return app.initialize() 272 | .then(() => assert.isFulfilled(app.copyImage('src/rel/path', '/dst/abs/path'))); 273 | }); 274 | 275 | it('should still resolve if copying failed', () => { 276 | fs.copyAsync.returns(Promise.reject()); 277 | 278 | return app.initialize() 279 | .then(() => assert.isFulfilled(app.copyImage('src/rel/path', '/dst/abs/path'))); 280 | }); 281 | }); 282 | }); 283 | -------------------------------------------------------------------------------- /test/tests-model.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const App = require('../lib/app'); 4 | const Tests = require('../lib/tests-model'); 5 | 6 | describe('lib/tests-model', () => { 7 | const sandbox = sinon.sandbox.create(); 8 | 9 | let app; 10 | 11 | const mkReuseBrowserResult_ = (browser, status, data, retries) => { 12 | return {name: browser, result: Object.assign({status}, data), retries}; 13 | }; 14 | 15 | const mkReuseState_ = (suite, state, browsers) => { 16 | return { 17 | name: state, 18 | suitePath: [suite, state], 19 | browsers: browsers.map((b) => mkReuseBrowserResult_(b.browser, b.status, b.ext, b.retries)) 20 | }; 21 | }; 22 | 23 | const mkReuseSuite_ = (suite, states) => { 24 | return { 25 | name: suite, 26 | suitePath: [suite], 27 | children: states.map((state) => mkReuseState_(suite, state.name, state.browsers)) 28 | }; 29 | }; 30 | 31 | const mkCollectionState_ = (suite, state, skip, ext) => { 32 | return Object.assign({ 33 | name: state, 34 | suite: {name: suite, path: [suite]}, 35 | shouldSkip: skip || (() => false) 36 | }, ext); 37 | }; 38 | 39 | const mkCollectionSuite_ = (suite, states, browsers) => { 40 | return { 41 | name: suite, 42 | path: [suite], 43 | children: [], 44 | browsers, 45 | states: states.map((s) => mkCollectionState_(suite, s.name, s.skip, s.ext)) 46 | }; 47 | }; 48 | 49 | const mkSuiteCollection_ = (collection) => { 50 | return {topLevelSuites: () => collection}; 51 | }; 52 | 53 | const createTests = (app, rawSuites, rawReuse) => { 54 | const suites = rawSuites.map((s) => mkCollectionSuite_(s.suite, s.states, s.browsers)); 55 | const reuse = rawReuse && rawReuse.map((s) => mkReuseSuite_(s.suite, s.states)); 56 | 57 | const tests = new Tests(app); 58 | return tests.initialize(mkSuiteCollection_(suites), reuse) 59 | .then(() => tests); 60 | }; 61 | 62 | const assertIdle = (item) => { 63 | assert.isNotNull(item); 64 | assert.equal(item.status, 'idle'); 65 | }; 66 | 67 | const assertSkipped = (item) => { 68 | assert.isNotNull(item); 69 | assert.equal(item.status, 'skipped'); 70 | }; 71 | 72 | const assertSuccess = (item) => { 73 | assert.isNotNull(item); 74 | assert.equal(item.status, 'success'); 75 | }; 76 | 77 | const assertFail = (item) => { 78 | assert.isNotNull(item); 79 | assert.equal(item.status, 'fail'); 80 | }; 81 | 82 | beforeEach(() => { 83 | app = sinon.createStubInstance(App); 84 | }); 85 | 86 | describe('without reuse', () => { 87 | it('suite and state should be skipped if state is skipped in all browsers', () => { 88 | return createTests(app, [{ 89 | suite: 'suite1', 90 | states: [{name: 'state1', skip: () => true}], 91 | browsers: ['bro-success', 'bro-fail', 'bro-skip'] 92 | }]) 93 | .then((tests) => { 94 | assertSkipped(tests.find({ 95 | suite: {name: 'suite1', path: ['suite1']}, 96 | state: {name: 'state1'}, 97 | browserId: 'bro-success' 98 | })); 99 | 100 | assertSkipped(tests.find({ 101 | suite: {name: 'suite1', path: ['suite1']}, 102 | state: {name: 'state1'}, 103 | browserId: 'bro-fail' 104 | })); 105 | 106 | assertSkipped(tests.find({ 107 | suite: {name: 'suite1', path: ['suite1']}, 108 | state: {name: 'state1'}, 109 | browserId: 'bro-skip' 110 | })); 111 | 112 | assertSkipped(tests.find({ 113 | suite: {name: 'suite1', path: ['suite1']}, 114 | state: {name: 'state1'} 115 | })); 116 | 117 | assertSkipped(tests.find({ 118 | suite: {name: 'suite1', path: ['suite1']} 119 | })); 120 | }); 121 | }); 122 | 123 | it('suite and state should be idle if state is not skipped in all browsers', () => { 124 | const shouldSkip = sandbox.stub().returns(false); 125 | shouldSkip.withArgs('bro1').returns(true); 126 | 127 | return createTests(app, [{ 128 | suite: 'suite1', 129 | states: [{name: 'state1', skip: shouldSkip}], 130 | browsers: ['bro1', 'bro2'] 131 | }]) 132 | .then((tests) => { 133 | assertSkipped(tests.find({ 134 | suite: {name: 'suite1', path: ['suite1']}, 135 | state: {name: 'state1'}, 136 | browserId: 'bro1' 137 | })); 138 | 139 | assertIdle(tests.find({ 140 | suite: {name: 'suite1', path: ['suite1']}, 141 | state: {name: 'state1'}, 142 | browserId: 'bro2' 143 | })); 144 | 145 | assertIdle(tests.find({ 146 | suite: {name: 'suite1', path: ['suite1']}, 147 | state: {name: 'state1'} 148 | })); 149 | 150 | assertIdle(tests.find({ 151 | suite: {name: 'suite1', path: ['suite1']} 152 | })); 153 | }); 154 | }); 155 | 156 | it('should add metaInfo field from state', () => { 157 | return createTests(app, [{ 158 | suite: 'suite1', 159 | states: [{name: 'state1', ext: {metaInfo: {key: 'state-value'}}}], 160 | browsers: ['bro'] 161 | }]) 162 | .then((tests) => { 163 | const test = tests.find({ 164 | suite: {name: 'suite1', path: ['suite1']}, 165 | state: {name: 'state1'}, 166 | browserId: 'bro' 167 | }); 168 | 169 | assert.isNotNull(test); 170 | assert.isString(test.metaInfo); 171 | assert.equal(JSON.parse(test.metaInfo).key, 'state-value'); 172 | }); 173 | }); 174 | 175 | it('should create reference url', () => { 176 | app.getScreenshotPath.returns('ref path'); 177 | app.refPathToURL.withArgs('ref path', 'bro').returns('ref url'); 178 | 179 | return createTests(app, [{ 180 | suite: 'suite1', 181 | states: [{name: 'state1', ext: {metaInfo: {key: 'state-value'}}}], 182 | browsers: ['bro'] 183 | }]) 184 | .then((tests) => { 185 | const test = tests.find({ 186 | suite: {name: 'suite1', path: ['suite1']}, 187 | state: {name: 'state1'}, 188 | browserId: 'bro' 189 | }); 190 | 191 | assert.isNotNull(test); 192 | assert.equal(test.referenceURL, 'ref url'); 193 | }); 194 | }); 195 | }); 196 | 197 | describe('with reuse', () => { 198 | it('suite and state should be skipped if state is skipped in all browsers', () => { 199 | return createTests(app, [{ 200 | suite: 'suite1', 201 | states: [{name: 'state1', skip: () => true}], 202 | browsers: ['bro-success', 'bro-fail', 'bro-skip'] 203 | }], [{suite: 'suite1', states: [ 204 | {name: 'state1', browsers: [ 205 | {browser: 'bro-success', status: 'success'}, 206 | {browser: 'bro-fail', status: 'fail'}, 207 | {browser: 'bro-skip', status: 'skipped'} 208 | ]} 209 | ]}]) 210 | .then((tests) => { 211 | assertSkipped(tests.find({ 212 | suite: {name: 'suite1', path: ['suite1']}, 213 | state: {name: 'state1'}, 214 | browserId: 'bro-success' 215 | })); 216 | 217 | assertSkipped(tests.find({ 218 | suite: {name: 'suite1', path: ['suite1']}, 219 | state: {name: 'state1'}, 220 | browserId: 'bro-fail' 221 | })); 222 | 223 | assertSkipped(tests.find({ 224 | suite: {name: 'suite1', path: ['suite1']}, 225 | state: {name: 'state1'}, 226 | browserId: 'bro-skip' 227 | })); 228 | 229 | assertSkipped(tests.find({ 230 | suite: {name: 'suite1', path: ['suite1']}, 231 | state: {name: 'state1'} 232 | })); 233 | 234 | assertSkipped(tests.find({ 235 | suite: {name: 'suite1', path: ['suite1']} 236 | })); 237 | }); 238 | }); 239 | 240 | it('suite and state should be succeed if state is skipped or succeed in all browsers', () => { 241 | const shouldSkip = sandbox.stub().returns(false); 242 | shouldSkip.withArgs('bro-skip').returns(true); 243 | 244 | return createTests(app, [{ 245 | suite: 'suite1', 246 | states: [{name: 'state1', skip: shouldSkip}], 247 | browsers: ['bro-success', 'bro-skip'] 248 | }], [{suite: 'suite1', states: [ 249 | {name: 'state1', browsers: [ 250 | {browser: 'bro-success', status: 'success'}, 251 | {browser: 'bro-skip', status: 'skipped'} 252 | ]} 253 | ]}]) 254 | .then((tests) => { 255 | assertSuccess(tests.find({ 256 | suite: {name: 'suite1', path: ['suite1']}, 257 | state: {name: 'state1'}, 258 | browserId: 'bro-success' 259 | })); 260 | 261 | assertSkipped(tests.find({ 262 | suite: {name: 'suite1', path: ['suite1']}, 263 | state: {name: 'state1'}, 264 | browserId: 'bro-skip' 265 | })); 266 | 267 | assertSuccess(tests.find({ 268 | suite: {name: 'suite1', path: ['suite1']}, 269 | state: {name: 'state1'} 270 | })); 271 | 272 | assertSuccess(tests.find({ 273 | suite: {name: 'suite1', path: ['suite1']} 274 | })); 275 | }); 276 | }); 277 | 278 | it('suite and state should be failed if state is failed in any browser', () => { 279 | const shouldSkip = sandbox.stub().returns(false); 280 | shouldSkip.withArgs('bro-skip').returns(true); 281 | 282 | return createTests(app, [{ 283 | suite: 'suite1', 284 | states: [{name: 'state1', skip: shouldSkip}], 285 | browsers: ['bro-success', 'bro-fail', 'bro-skip'] 286 | }], [{suite: 'suite1', states: [ 287 | {name: 'state1', browsers: [ 288 | {browser: 'bro-success', status: 'success'}, 289 | {browser: 'bro-fail', status: 'fail'}, 290 | {browser: 'bro-skip', status: 'skipped'} 291 | ]} 292 | ]}]) 293 | .then((tests) => { 294 | assertSuccess(tests.find({ 295 | suite: {name: 'suite1', path: ['suite1']}, 296 | state: {name: 'state1'}, 297 | browserId: 'bro-success' 298 | })); 299 | 300 | assertFail(tests.find({ 301 | suite: {name: 'suite1', path: ['suite1']}, 302 | state: {name: 'state1'}, 303 | browserId: 'bro-fail' 304 | })); 305 | 306 | assertSkipped(tests.find({ 307 | suite: {name: 'suite1', path: ['suite1']}, 308 | state: {name: 'state1'}, 309 | browserId: 'bro-skip' 310 | })); 311 | 312 | assertFail(tests.find({ 313 | suite: {name: 'suite1', path: ['suite1']}, 314 | state: {name: 'state1'} 315 | })); 316 | 317 | assertFail(tests.find({ 318 | suite: {name: 'suite1', path: ['suite1']} 319 | })); 320 | }); 321 | }); 322 | 323 | it('should add metaInfo field from reuse data', () => { 324 | return createTests(app, [{ 325 | suite: 'suite1', 326 | states: [{name: 'state1', ext: {metaInfo: {key: 'state-value'}}}], 327 | browsers: ['bro'] 328 | }], [{suite: 'suite1', states: [ 329 | {name: 'state1', browsers: [ 330 | {browser: 'bro', status: 'success', ext: { 331 | metaInfo: JSON.stringify({sessionId: 'session-id', key: 'reuse-value'}) 332 | }} 333 | ]} 334 | ]}]) 335 | .then((tests) => { 336 | const test = tests.find({ 337 | suite: {name: 'suite1', path: ['suite1']}, 338 | state: {name: 'state1'}, 339 | browserId: 'bro' 340 | }); 341 | 342 | assert.isNotNull(test); 343 | assert.isString(test.metaInfo); 344 | assert.equal(JSON.parse(test.metaInfo).sessionId, 'session-id'); 345 | assert.equal(JSON.parse(test.metaInfo).key, 'reuse-value'); 346 | }); 347 | }); 348 | 349 | it('should add metaInfo field from state', () => { 350 | return createTests(app, [{ 351 | suite: 'suite1', 352 | states: [{name: 'state1', ext: {metaInfo: {key: 'state-value'}}}], 353 | browsers: ['bro'] 354 | }], [{suite: 'suite1', states: [ 355 | {name: 'state1', browsers: [ 356 | {browser: 'bro', status: 'fail', ext: { 357 | metaInfo: JSON.stringify({sessionId: 'session-id'}) 358 | }} 359 | ]} 360 | ]}]) 361 | .then((tests) => { 362 | const test = tests.find({ 363 | suite: {name: 'suite1', path: ['suite1']}, 364 | state: {name: 'state1'}, 365 | browserId: 'bro' 366 | }); 367 | 368 | assert.isNotNull(test); 369 | assert.isString(test.metaInfo); 370 | assert.equal(JSON.parse(test.metaInfo).key, 'state-value'); 371 | }); 372 | }); 373 | 374 | it('should create reference url', () => { 375 | app.getScreenshotPath.returns('ref path'); 376 | app.refPathToURL.withArgs('ref path', 'bro').returns('ref url'); 377 | 378 | return createTests(app, [{ 379 | suite: 'suite1', 380 | states: [{name: 'state1', ext: {metaInfo: {key: 'state-value'}}}], 381 | browsers: ['bro'] 382 | }], [{suite: 'suite1', states: [ 383 | {name: 'state1', browsers: [ 384 | {browser: 'bro', status: 'fail', ext: { 385 | metaInfo: JSON.stringify({sessionId: 'session-id'}) 386 | }} 387 | ]} 388 | ]}]) 389 | .then((tests) => { 390 | const test = tests.find({ 391 | suite: {name: 'suite1', path: ['suite1']}, 392 | state: {name: 'state1'}, 393 | browserId: 'bro' 394 | }); 395 | 396 | assert.isNotNull(test); 397 | assert.equal(test.referenceURL, 'ref url'); 398 | }); 399 | }); 400 | 401 | it('should create current url', () => { 402 | app.currentDir = 'current_dir'; 403 | app.createCurrentPathFor.withArgs().returns('/reused/image/path'); 404 | app.currentPathToURL.withArgs('/reused/image/path').returns('curr url'); 405 | 406 | return createTests(app, [{ 407 | suite: 'suite1', 408 | states: [{name: 'state1', ext: {metaInfo: {key: 'state-value'}}}], 409 | browsers: ['bro'] 410 | }], [{suite: 'suite1', states: [ 411 | {name: 'state1', browsers: [ 412 | {browser: 'bro', status: 'fail', ext: { 413 | metaInfo: JSON.stringify({sessionId: 'session-id'}), 414 | actualPath: 'actual/rel' 415 | }} 416 | ]} 417 | ]}]) 418 | .then((tests) => { 419 | const test = tests.find({ 420 | suite: {name: 'suite1', path: ['suite1']}, 421 | state: {name: 'state1'}, 422 | browserId: 'bro' 423 | }); 424 | 425 | assert.isNotNull(test); 426 | assert.equal(test.currentURL, 'curr url'); 427 | }); 428 | }); 429 | 430 | it('should create diff url', () => { 431 | app.diffDir = 'diff_dir'; 432 | app.createDiffPathFor.withArgs().returns('/reused/image/path'); 433 | app.diffPathToURL.withArgs('/reused/image/path').returns('diff url'); 434 | 435 | return createTests(app, [{ 436 | suite: 'suite1', 437 | states: [{name: 'state1', ext: {metaInfo: {key: 'state-value'}}}], 438 | browsers: ['bro'] 439 | }], [{suite: 'suite1', states: [ 440 | {name: 'state1', browsers: [ 441 | {browser: 'bro', status: 'fail', ext: { 442 | metaInfo: JSON.stringify({sessionId: 'session-id'}), 443 | diffPath: 'diff/rel' 444 | }} 445 | ]} 446 | ]}]) 447 | .then((tests) => { 448 | const test = tests.find({ 449 | suite: {name: 'suite1', path: ['suite1']}, 450 | state: {name: 'state1'}, 451 | browserId: 'bro' 452 | }); 453 | 454 | assert.isNotNull(test); 455 | assert.equal(test.diffURL, 'diff url'); 456 | }); 457 | }); 458 | 459 | it('should use data from retries if no image in result', () => { 460 | app.diffDir = 'diff_dir'; 461 | app.createDiffPathFor.withArgs().returns('/reused/image/path'); 462 | app.diffPathToURL.withArgs('/reused/image/path').returns('diff url'); 463 | 464 | return createTests(app, [{ 465 | suite: 'suite1', 466 | states: [{name: 'state1', ext: {metaInfo: {key: 'state-value'}}}], 467 | browsers: ['bro'] 468 | }], [{suite: 'suite1', states: [ 469 | {name: 'state1', browsers: [ 470 | { 471 | browser: 'bro', 472 | status: 'fail', 473 | ext: { 474 | metaInfo: 'fiasco' 475 | }, 476 | retries: [ 477 | { 478 | status: 'fail', 479 | metaInfo: JSON.stringify({sessionId: 'session-id'}), 480 | diffPath: 'diff/rel' 481 | } 482 | ] 483 | } 484 | ]} 485 | ]}]) 486 | .then((tests) => { 487 | const test = tests.find({ 488 | suite: {name: 'suite1', path: ['suite1']}, 489 | state: {name: 'state1'}, 490 | browserId: 'bro' 491 | }); 492 | 493 | assert.isNotNull(test); 494 | assert.equal(test.diffURL, 'diff url'); 495 | }); 496 | }); 497 | }); 498 | }); 499 | --------------------------------------------------------------------------------