├── .eslintignore ├── test ├── mocha.opts ├── .eslintrc ├── bootstrap.js ├── html-template.js ├── router.js ├── server-suite-wrapper.js ├── client-wrapper.js └── route-builder.test.js ├── .gitignore ├── .eslintrc ├── .travis.yml ├── bin └── gemini-react-server ├── index.js ├── lib ├── html-template.js ├── webpack-bundler.js ├── setup.js ├── router.js ├── tmp-js-storage.js ├── server-suite-wrapper.js ├── client-wrapper.js ├── route-builder.js └── server.js ├── LICENSE ├── package.json ├── CHANGELOG.md └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage 2 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require ./test/bootstrap 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | npm-debug.log 4 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "gemini-testing/tests" 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "gemini-testing", 3 | "root": true 4 | } 5 | -------------------------------------------------------------------------------- /test/bootstrap.js: -------------------------------------------------------------------------------- 1 | const chai = require('chai'); 2 | const sinon = require('sinon'); 3 | 4 | sinon.assert.expose(chai.assert, {prefix: ''}); 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '4' 4 | - '6' 5 | script: 6 | - npm run lint 7 | - npm test --coverage 8 | 9 | after_success: 10 | - bash <(curl -s https://codecov.io/bash) 11 | 12 | env: 13 | - CXX=g++-4.8 14 | addons: 15 | apt: 16 | sources: 17 | - ubuntu-toolchain-r-test 18 | packages: 19 | - g++-4.8 20 | -------------------------------------------------------------------------------- /bin/gemini-react-server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict'; 3 | 4 | const program = require('commander'); 5 | const Gemini = require('gemini'); 6 | const pgk = require('../package.json'); 7 | const setup = require('../lib/setup'); 8 | 9 | program 10 | .version(pgk.version) 11 | .option('-c, --config ', 'Path to .gemini.yml') 12 | .arguments('[paths...]') 13 | .parse(process.argv); 14 | 15 | const gemini = new Gemini(program.config); 16 | const server = setup(gemini); 17 | gemini.readTests(program.args) 18 | .then(() => server.start()) 19 | .then(url => console.log(`Server is running on ${url}`)); 20 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const _ = require('lodash'); 3 | const setup = require('./lib/setup'); 4 | 5 | module.exports = function(gemini, options) { 6 | const server = setup(gemini); 7 | const replaceRootUrl = _.get(options, 'replaceRootUrl', true); 8 | 9 | gemini.on('startRunner', () => { 10 | return server.start() 11 | .then(url => { 12 | if (replaceRootUrl) { 13 | setRootUrl(gemini.config, url); 14 | } 15 | }); 16 | }); 17 | 18 | gemini.on('endRunner', () => server.stop()); 19 | }; 20 | 21 | function setRootUrl(config, url) { 22 | config.getBrowserIds().forEach(browserId => { 23 | config.forBrowser(browserId).rootUrl = url; 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /lib/html-template.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * @param {String[]} fileList 5 | */ 6 | function renderCss(fileList) { 7 | return fileList.reduce( 8 | (html, url) => html + `\n`, 9 | '' 10 | ); 11 | } 12 | 13 | /** 14 | * @param {String[]} fileList 15 | */ 16 | function renderJs(fileList) { 17 | return fileList.reduce( 18 | (html, url) => html + `\n`, 19 | '' 20 | ); 21 | } 22 | 23 | function render(templateData) { 24 | return ( 25 | ` 26 | 27 | ${templateData.title} 28 | ${renderCss(templateData.cssList)} 29 | 30 | 31 |
32 |
33 | ${renderJs(templateData.jsList)} 34 | ` 35 | ); 36 | } 37 | 38 | exports.render = render; 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Sergey Tatarintsev 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 | -------------------------------------------------------------------------------- /lib/webpack-bundler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const _ = require('lodash'); 3 | const webpack = require('webpack'); 4 | const webpackMiddleware = require('webpack-dev-middleware'); 5 | 6 | function readWebpackConfig(filepath) { 7 | let config = require(filepath); 8 | if (typeof config === 'function') { 9 | config = config(); 10 | } 11 | return config; 12 | } 13 | 14 | class WebpackBundler { 15 | constructor(options) { 16 | this._webpackConfig = _.assign({}, readWebpackConfig(options.webpackConfig), { 17 | entry: {}, 18 | output: { 19 | path: '/', 20 | filename: '[name]' 21 | } 22 | }); 23 | this._lazy = options.webpackLazyMode; 24 | } 25 | 26 | bundle(chunkName, jsFilePath) { 27 | this._webpackConfig.entry[chunkName] = [jsFilePath]; 28 | } 29 | 30 | /** 31 | * @param {String} mountUrl 32 | */ 33 | buildMiddleware(mountUrl) { 34 | this._webpackConfig.output.publicPath = mountUrl; 35 | return webpackMiddleware(webpack(this._webpackConfig), { 36 | serverSideRender: true, 37 | publicPath: mountUrl, 38 | noInfo: true, 39 | quiet: true, 40 | lazy: this._lazy 41 | }); 42 | } 43 | } 44 | 45 | module.exports = WebpackBundler; 46 | -------------------------------------------------------------------------------- /lib/setup.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const _ = require('lodash'); 3 | const wrap = require('../lib/server-suite-wrapper'); 4 | const Server = require('./server'); 5 | 6 | require('ignore-styles'); 7 | 8 | /** 9 | * @return {Server} 10 | */ 11 | function setup(gemini) { 12 | const projectRoot = gemini.config.system.projectRoot; 13 | const rootRelative = relPath => path.resolve(projectRoot, relPath); 14 | const plugins = gemini.config.system.plugins; 15 | 16 | const options = plugins['react'] || plugins['gemini-react']; 17 | 18 | if (_.size(options.cssFiles) > 0 && !options.staticRoot) { 19 | throw new Error('staticRoot option is required when using cssFiles'); 20 | } 21 | 22 | const server = new Server({ 23 | projectRoot: projectRoot, 24 | port: options.port || 5432, 25 | listenHost: options.listenHost || '127.0.0.1', 26 | staticRoot: options.staticRoot ? rootRelative(options.staticRoot) : null, 27 | cssFiles: options.cssFiles || [], 28 | jsModules: options.jsModules ? options.jsModules.map(rootRelative) : [], 29 | webpackConfig: rootRelative(options.webpackConfig), 30 | webpackLazyMode: options.webpackLazyMode || false, 31 | customizeServer: options.customizeServer 32 | ? require(rootRelative(options.customizeServer)) 33 | : _.noop 34 | }); 35 | 36 | gemini.on('beforeFileRead', (fileName) => { 37 | global.geminiReact = wrap(global.gemini, server); 38 | server.bundleTestFileForBrowser(fileName); 39 | }); 40 | 41 | gemini.on('afterFileRead', () => { 42 | global.geminiReact = wrap(global.gemini, server); 43 | delete global.geminiReact; 44 | }); 45 | 46 | return server; 47 | } 48 | 49 | module.exports = setup; 50 | -------------------------------------------------------------------------------- /test/html-template.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const htmlTemplate = require('../lib/html-template'); 4 | const jsdom = require('jsdom'); 5 | const assert = require('chai').assert; 6 | const _ = require('lodash'); 7 | 8 | describe('html template', () => { 9 | function renderTemplateAsDom(templateData) { 10 | templateData = templateData || {}; 11 | _.defaults(templateData, { 12 | name: '', 13 | jsList: [], 14 | cssList: [] 15 | }); 16 | return jsdom.jsdom(htmlTemplate.render(templateData)); 17 | } 18 | 19 | it('should have a test name as a title', () => { 20 | const document = renderTemplateAsDom({ 21 | title: 'some title' 22 | }); 23 | 24 | assert.equal(document.title, 'some title'); 25 | }); 26 | 27 | it('should have a mount point', () => { 28 | const document = renderTemplateAsDom(); 29 | 30 | assert.isOk( 31 | document.querySelector('[data-gemini-react]'), 32 | 'Expected to have element with data-gemini-react attribute' 33 | ); 34 | }); 35 | 36 | it('should include js from js list', () => { 37 | const path = '/some/file.js'; 38 | 39 | const document = renderTemplateAsDom({ 40 | jsList: [path] 41 | }); 42 | 43 | assert.isOk( 44 | document.querySelector(`script[src='${path}']`), 45 | 'Expected to render script tag' 46 | ); 47 | }); 48 | 49 | it('should include css from css list', () => { 50 | const path = '/some/file.css'; 51 | 52 | const document = renderTemplateAsDom({ 53 | cssList: [path] 54 | }); 55 | 56 | assert.isOk( 57 | document.querySelector(`link[rel=stylesheet][href='${path}']`), 58 | 'Expected to render link[rel=stylesheet] tag' 59 | ); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /lib/router.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const _ = require('lodash'); 3 | const htmlTemplate = require('./html-template'); 4 | const log = require('debug')('gemini:react'); 5 | 6 | function assetsRoot() { 7 | return '/assets/'; 8 | } 9 | 10 | /** 11 | * @param {String} testPath 12 | * @return {String} 13 | */ 14 | function testPathToChunkName(testPath) { 15 | const name = testPath 16 | .replace(/\//g, '-') 17 | .replace(/\.jsx?$/, '.bundle.js'); 18 | return encodeURI(name); 19 | } 20 | 21 | function testPathToChunkUrl(testPath) { 22 | return assetsRoot() + testPathToChunkName(testPath); 23 | } 24 | 25 | /** 26 | * @param {RouteBuilder} routeBuilder 27 | */ 28 | function middleware(routeBuilder) { 29 | return function middleware(req, res, next) { 30 | const commonAssets = readCommonAssets(res.locals.webpackStats); 31 | log(`request to ${req.path}`); 32 | if (routeBuilder.isUrlRegistered(req.path)) { 33 | log('this is a test page url'); 34 | const templateData = routeBuilder.getTemplateDataFromUrl(req.path, commonAssets); 35 | log('template data', templateData); 36 | res.send(htmlTemplate.render(templateData)); 37 | } 38 | 39 | next(); 40 | }; 41 | } 42 | 43 | function readCommonAssets(webpackStats) { 44 | if (!webpackStats) { 45 | return []; 46 | } 47 | const commonAssetsObj = _.omitBy( 48 | webpackStats.toJson({chunks: false, modules: false}).assetsByChunkName, 49 | value => /\.bundle.js$/.test(value) 50 | ); 51 | 52 | return Object.keys(commonAssetsObj).reduce((result, item) => { 53 | result.push(assetsRoot() + commonAssetsObj[item]); 54 | return result; 55 | }, []); 56 | } 57 | 58 | exports.testPathToChunkName = testPathToChunkName; 59 | exports.testPathToChunkUrl = testPathToChunkUrl; 60 | exports.assetsRoot = assetsRoot; 61 | exports.middleware = middleware; 62 | -------------------------------------------------------------------------------- /lib/tmp-js-storage.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const promisify = require('promisify-node'); 5 | const mkdirp = require('mkdirp'); 6 | const rimraf = promisify(require('rimraf')); 7 | const router = require('./router'); 8 | const clientWrapperPath = require.resolve('./client-wrapper'); 9 | 10 | class TmpJsStorage { 11 | constructor(options) { 12 | this._projectRoot = options.projectRoot; 13 | this._tmpDir = path.join( 14 | options.projectRoot, 15 | '.gemini-react-tmp' 16 | ); 17 | this._jsModules = options.jsModules; 18 | } 19 | 20 | init() { 21 | // TODO: async execution 22 | mkdirp.sync(this._tmpDir); 23 | } 24 | 25 | buildFile(testPath) { 26 | const rootRelative = path.relative(this._projectRoot, testPath); 27 | const destPath = path.join( 28 | this._tmpDir, 29 | router.testPathToChunkName(rootRelative) 30 | ); 31 | 32 | // TODO: async write 33 | fs.writeFileSync( 34 | destPath, 35 | this._getTargetFileContent(destPath, this._jsModules.concat(testPath)), 36 | 'utf8' 37 | ); 38 | return destPath; 39 | } 40 | 41 | cleanup() { 42 | return rimraf(this._tmpDir); 43 | } 44 | 45 | _getTargetFileContent(destPath, modulesList) { 46 | const relWrapper = importPath(destPath, clientWrapperPath); 47 | const requires = buildRequires(destPath, modulesList); 48 | return ` 49 | window.geminiReact = require('${relWrapper}')(); 50 | ${requires} 51 | `; 52 | } 53 | } 54 | 55 | function buildRequires(destPath, fileList) { 56 | return fileList 57 | .map(filePath => importPath(destPath, filePath)) 58 | .map(importPath => `require('${importPath}')`) 59 | .join('\n'); 60 | } 61 | 62 | function importPath(fromPath, importPath) { 63 | const dir = path.dirname(fromPath); 64 | return path.relative(dir, importPath); 65 | } 66 | module.exports = TmpJsStorage; 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gemini-react", 3 | "version": "0.11.1", 4 | "description": "Wrapper, which simplifies writing gemini tests for react components", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "istanbul test --include-all-sources _mocha", 8 | "lint": "eslint .", 9 | "precommit": "npm run lint", 10 | "commitmsg": "commitlint -x @commitlint/config-angular --edit", 11 | "release": "standard-version", 12 | "github-release": "conventional-github-releaser" 13 | }, 14 | "author": "Sergey Tatarintsev (https://github.com/SevInf)", 15 | "license": "MIT", 16 | "bin": { 17 | "gemini-react-server": "bin/gemini-react-server" 18 | }, 19 | "peerDependecies": { 20 | "gemini": ">= 4.7.2", 21 | "react": "15.x", 22 | "react-dom": "15.x", 23 | "webpack": "1.x" 24 | }, 25 | "devDependencies": { 26 | "@commitlint/cli": "^4.2.2", 27 | "@commitlint/config-angular": "^4.2.1", 28 | "chai": "^4.0.0", 29 | "conventional-github-releaser": "^1.1.3", 30 | "eslint": "^3.2.2", 31 | "eslint-config-gemini-testing": "^2.2.0", 32 | "gemini": "^4.4.4", 33 | "husky": "^0.14.3", 34 | "istanbul": "^0.4.5", 35 | "jsdom": "^9.8.3", 36 | "jsdom-global": "^2.1.0", 37 | "mocha": "^3.1.2", 38 | "react": "^15.3.0", 39 | "react-dom": "^15.3.0", 40 | "sinon": "^2.0.0", 41 | "standard-version": "^4.0.0", 42 | "webpack": "^1.13.1" 43 | }, 44 | "dependencies": { 45 | "commander": "^2.9.0", 46 | "debug": "^3.0.0", 47 | "express": "^4.14.0", 48 | "ignore-styles": "^5.0.1", 49 | "lodash": "^4.16.4", 50 | "mkdirp": "^0.5.1", 51 | "promisify-node": "^0.4.0", 52 | "rimraf": "^2.5.4", 53 | "server-destroy": "^1.0.1", 54 | "webpack-dev-middleware": "^1.8.4" 55 | }, 56 | "repository": { 57 | "type": "git", 58 | "url": "git+https://github.com/researchgate/gemini-react.git" 59 | }, 60 | "keywords": [ 61 | "gemini-plugin", 62 | "gemini", 63 | "react", 64 | "test", 65 | "testing" 66 | ], 67 | "bugs": { 68 | "url": "https://github.com/researchgate/gemini-react/issues" 69 | }, 70 | "homepage": "https://github.com/researchgate/gemini-react#readme" 71 | } 72 | -------------------------------------------------------------------------------- /lib/server-suite-wrapper.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const RENDER_TARGET_SELECTOR = '[data-gemini-react] > *'; 3 | const _ = require('lodash'); 4 | class ServerSuiteWrapper { 5 | /** 6 | * @param {Suite} suite 7 | * @param {Server} server 8 | */ 9 | constructor(suite, server) { 10 | this._original = suite; 11 | this._server = server; 12 | this._captureElements = [RENDER_TARGET_SELECTOR]; 13 | 14 | let _this = this; // can't use arrow functions for forwarding the call 15 | Object.keys(suite).forEach((key) => { 16 | if (!(key in this)) { 17 | this[key] = function() { 18 | suite[key].apply(suite, arguments); 19 | return _this; 20 | }; 21 | } 22 | }); 23 | } 24 | 25 | render() { 26 | const url = this._server.registerRender(); 27 | this._original.setUrl(url); 28 | this._original.setCaptureElements(this._captureElements); 29 | this._original.before(function(actions, find) { 30 | this.renderedComponent = find(RENDER_TARGET_SELECTOR); 31 | }); 32 | return this; 33 | } 34 | 35 | includeCss(cssUrl) { 36 | this._server.includeCss(cssUrl); 37 | return this; 38 | } 39 | 40 | setUrl() { 41 | throw new Error('Do not call setUrl manually, use render() instead'); 42 | } 43 | 44 | setCaptureElements() { 45 | throw new Error('Do not call setCaptureElements manually, use render() instead'); 46 | } 47 | 48 | setExtraCaptureElements(elements) { 49 | if (!Array.isArray(elements) && !_.isString(elements)) { 50 | throw new Error('setExtraCaptureElements accepts an array or string'); 51 | } 52 | this._captureElements = this._captureElements.concat(elements); 53 | return this; 54 | } 55 | } 56 | 57 | function wrap(gemini, server) { 58 | return { 59 | suite(name, callback) { 60 | server.pushSuite(name); 61 | gemini.suite(name, function(suite) { 62 | callback(new ServerSuiteWrapper(suite, server)); 63 | }); 64 | server.popSuite(); 65 | } 66 | 67 | }; 68 | } 69 | 70 | module.exports = wrap; 71 | module.exports.RENDER_TARGET_SELECTOR = RENDER_TARGET_SELECTOR; 72 | -------------------------------------------------------------------------------- /lib/client-wrapper.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser */ 2 | 'use strict'; 3 | /** 4 | * This file is included in every test page. 5 | * Build configuration of the target project is unknown, 6 | * so we should not make any assumptions about babel availability. 7 | * This means, no ES2015 here 8 | */ 9 | 10 | var ReactDOM = require('react-dom'); 11 | 12 | function chainNoOp() { 13 | return this; 14 | } 15 | 16 | function SuiteBuilderStub(opts) { 17 | this.testFileName = ''; 18 | if (opts.noOpRender) { 19 | this.render = chainNoOp; 20 | } 21 | } 22 | 23 | SuiteBuilderStub.prototype.before = chainNoOp; 24 | SuiteBuilderStub.prototype.after = chainNoOp; 25 | SuiteBuilderStub.prototype.setTolerance = chainNoOp; 26 | SuiteBuilderStub.prototype.capture = chainNoOp; 27 | SuiteBuilderStub.prototype.ignoreElements = chainNoOp; 28 | SuiteBuilderStub.prototype.skip = chainNoOp; 29 | SuiteBuilderStub.prototype.browsers = chainNoOp; 30 | SuiteBuilderStub.prototype.includeCss = chainNoOp; 31 | SuiteBuilderStub.prototype.setExtraCaptureElements = chainNoOp; 32 | 33 | SuiteBuilderStub.prototype.render = function(element) { 34 | ReactDOM.render( 35 | element, 36 | document.querySelector('[data-gemini-react]') 37 | ); 38 | return this; 39 | }; 40 | 41 | function suiteNamesFromUrl() { 42 | var path = window.location.pathname; 43 | path = path.replace(/^\//, ''); 44 | return path.split('/').map(decodeURIComponent); 45 | } 46 | 47 | function buildClientStub() { 48 | let expectedSuiteNames = suiteNamesFromUrl(); 49 | 50 | return { 51 | suite: function(name, cb) { 52 | var expectedName = expectedSuiteNames[0]; 53 | if (name === expectedName) { 54 | expectedSuiteNames.shift(); 55 | cb(new SuiteBuilderStub({ 56 | noOpRender: expectedSuiteNames.length > 0 57 | })); 58 | } 59 | } 60 | }; 61 | } 62 | 63 | window.addEventListener('error', function(e) { 64 | const fragment = document.createDocumentFragment(); 65 | var header = document.createElement('h1'); 66 | header.textContent = 'Javascript error on a page:'; 67 | fragment.appendChild(header); 68 | 69 | var errorElement = document.createElement('pre'); 70 | errorElement.className = 'stack'; 71 | errorElement.textContent = e.error.stack || e.error.message; 72 | errorElement.style.margin = '20px'; 73 | errorElement.style.fontSize = '14px'; 74 | 75 | fragment.appendChild(errorElement); 76 | 77 | document.body.appendChild(fragment); 78 | }); 79 | 80 | module.exports = buildClientStub; 81 | -------------------------------------------------------------------------------- /lib/route-builder.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const _ = require('lodash'); 3 | 4 | /** 5 | * @param {String} parentName 6 | * @param {String} suiteName 7 | * @return {String} 8 | */ 9 | function getFullName(parentName, suiteName) { 10 | if (!parentName) { 11 | return suiteName; 12 | } 13 | return `${parentName} ${suiteName}`; 14 | } 15 | 16 | class RouteBuilder { 17 | constructor() { 18 | this._existingRoutes = Object.create(null); 19 | this._currentJsUrl = null; 20 | this._suiteStack = [{ 21 | name: '', 22 | url: '', 23 | js: [], 24 | css: [] 25 | }]; 26 | } 27 | 28 | /** 29 | * @param {String} currentJsUrl 30 | */ 31 | setCurrentPageJSUrl(jsUrl) { 32 | this._currentJsUrl = jsUrl; 33 | } 34 | 35 | /** 36 | * @param {String} cssUrl 37 | */ 38 | includeCss(cssUrl) { 39 | this._getStackTop().css.push(cssUrl); 40 | } 41 | 42 | /** 43 | * @param {String} name 44 | */ 45 | pushSuite(name) { 46 | this._suiteStack.push( 47 | this._nextSuite(name) 48 | ); 49 | } 50 | 51 | popSuite() { 52 | this._suiteStack.pop(); 53 | } 54 | 55 | /** 56 | * @param {String} name 57 | */ 58 | _nextSuite(name) { 59 | const top = this._getStackTop(); 60 | 61 | return { 62 | fullName: getFullName(top.fullName, name), 63 | url: `${top.url}/${encodeURIComponent(name)}`, 64 | js: _.clone(top.js), 65 | css: _.clone(top.css) 66 | }; 67 | } 68 | 69 | _getStackTop() { 70 | return _.last(this._suiteStack); 71 | } 72 | 73 | /** 74 | * @return {String} 75 | */ 76 | buildRoute() { 77 | const top = this._getStackTop(); 78 | 79 | this._existingRoutes[top.url] = { 80 | title: top.fullName, 81 | jsUrl: this._currentJsUrl, 82 | jsList: top.js.concat(this._currentJsUrl), 83 | cssList: top.css 84 | }; 85 | 86 | return top.url; 87 | } 88 | 89 | /** 90 | * @param {String} url 91 | * @return {Boolean} 92 | */ 93 | isUrlRegistered(url) { 94 | return _.has(this._existingRoutes, url); 95 | } 96 | 97 | /** 98 | * @param {String} url 99 | * @param {Object} assets 100 | * @return {Object} 101 | */ 102 | getTemplateDataFromUrl(url, commonAssets) { 103 | this._existingRoutes[url].jsList = _.union(commonAssets, this._existingRoutes[url].jsList); 104 | 105 | return this._existingRoutes[url]; 106 | } 107 | } 108 | 109 | module.exports = RouteBuilder; 110 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | 6 | ## [0.11.1](https://github.com/researchgate/gemini-react/compare/v0.11.0...v0.11.1) (2018-07-23) 7 | 8 | 9 | ### Performance Improvements 10 | 11 | * **router.js:** make webpackStats.toJson faster ([#64](https://github.com/researchgate/gemini-react/issues/64)) ([6eee4e9](https://github.com/researchgate/gemini-react/commit/6eee4e9)), closes [#63](https://github.com/researchgate/gemini-react/issues/63) 12 | 13 | 14 | 15 | 16 | # [0.11.0](https://github.com/researchgate/gemini-react/compare/v0.10.2...v0.11.0) (2017-02-13) 17 | 18 | 19 | ### Bug Fixes 20 | 21 | * remove mocha from deps ([73e1572](https://github.com/researchgate/gemini-react/commit/73e1572)) 22 | * throw exception on invalid input to setExtraCaptureElement ([49081ec](https://github.com/researchgate/gemini-react/commit/49081ec)) 23 | 24 | ### Features 25 | 26 | * add ability to use .jsx extensions for tests ([b4a90b2](https://github.com/researchgate/gemini-react/commit/b4a90b2)) 27 | 28 | 29 | 30 | 31 | ## [0.10.2](https://github.com/researchgate/gemini-react/compare/v0.10.1...v0.10.2) (2016-11-15) 32 | 33 | 34 | ### Bug Fixes 35 | 36 | * remove display: inline-block from mount node ([35bf226](https://github.com/researchgate/gemini-react/commit/35bf226)) 37 | 38 | 39 | 40 | 41 | ## [0.10.1](https://github.com/researchgate/gemini-react/compare/v0.10.0...v0.10.1) (2016-11-15) 42 | 43 | 44 | ### Bug Fixes 45 | 46 | * crash in lazy mode ([4a7b36d](https://github.com/researchgate/gemini-react/commit/4a7b36d)) 47 | 48 | 49 | 50 | 51 | # [0.10.0](https://github.com/researchgate/gemini-react/compare/v0.9.0...v0.10.0) 52 | (2016-11-11) 53 | 54 | 55 | ### Bug Fixes 56 | 57 | * use webpack config from options completely ([b733188](https://github.com/researchgate/gemini-react/commit/b733188)) 58 | * **deps:** remove unnecessary dependencies ([10f7020](https://github.com/researchgate/gemini-react/commit/10f7020)) 59 | 60 | 61 | ### BREAKING CHANGES 62 | 63 | * `webpackLazyMode` option is now incompatible with webpack plugins, 64 | which split one chunk into multiple. Either switch it off or exclude 65 | such plugins from your config from gemini tests. 66 | 67 | 68 | 69 | 70 | # [0.9.0](https://github.com/researchgate/gemini-react/compare/v0.8.0...v0.9.0) 71 | (2016-10-18) 72 | 73 | 74 | ### Features 75 | 76 | * Add `replaceRootUrl` option ([da55f54](https://github.com/researchgate/gemini-react/commit/da55f54)) 77 | 78 | 79 | ### BREAKING CHANGES 80 | 81 | * `publicHost` option is removed. If you need `rootUrl` 82 | to have different value, set `replaceRootUrl` option to `false` and 83 | manually set `rootUrl`. 84 | 85 | 86 | 87 | 88 | # [0.8.0](https://github.com/researchgate/gemini-react/compare/v0.7.2...v0.8.0) (2016-10-17) 89 | 90 | 91 | ### Features 92 | 93 | * **webpack:** add `webpackLazyMode` option ([6ff0e25](https://github.com/researchgate/gemini-react/commit/6ff0e25)) 94 | 95 | 96 | 97 | 98 | ## [0.7.2](https://github.com/researchgate/gemini-react/compare/v0.7.1...v0.7.2) (2016-10-14) 99 | 100 | 101 | ### Bug Fixes 102 | 103 | * Ignore styles in node environment ([#20](https://github.com/researchgate/gemini-react/issues/20)) ([e080953](https://github.com/researchgate/gemini-react/commit/e080953)) 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gemini-react 2 | 3 | [![Build Status](https://travis-ci.org/researchgate/gemini-react.svg?branch=master)](https://travis-ci.org/researchgate/gemini-react) 4 | [![codecov](https://codecov.io/gh/researchgate/gemini-react/branch/master/graph/badge.svg)](https://codecov.io/gh/researchgate/gemini-react) 5 | 6 | [gemini](https://github.com/gemini-testing/gemini) plugin for simplifying visual 7 | regression testing on React + webpack stack. 8 | 9 | **WARNING**: Right now plugin is pretty much at the proof-of-concept stage, do 10 | not use in production. 11 | 12 | ## Configuring 13 | 14 | 1. Install plugin using `npm`: 15 | 16 | ``` 17 | npm install gemini-react 18 | ``` 19 | 20 | 2. Enable it in your gemini config file: 21 | 22 | ```yaml 23 | system: 24 | plugins: 25 | react: 26 | webpackConfig: 27 | hostname: 28 | port: ``` 29 | ``` 30 | 31 | ### Options 32 | 33 | * `webpackConfig` (required) – path to your webpack config. Plugin will use 34 | loaders from this file to build test pages. 35 | * `listenHost` (default: 127.0.0.1) - hostname to run reference test server on. 36 | * `port` (default: 5432) - port to run test server on. 37 | * `replaceRootUrl` (default: true) - automatically sets `rootUrl` of every 38 | browser to `http://:`. Set to `false` if `rootUrl` should be 39 | something else. 40 | * `staticRoot` - directory, which contains your static asset files. Will be 41 | mounted by your test server automatically. 42 | * `cssFiles` - list of CSS files to include in every test page. Requires 43 | `staticRoot` option to be set. 44 | * `jsModules` - list of additional js modules to include in the test pages. 45 | Relative to project root. This modules will be included into your client 46 | bundle before the rest files. 47 | * `customizeServer` - path to js file, used to customize the express server. 48 | The file should have a single export, which is function of `(app, express)`. 49 | 50 | Example: 51 | 52 | ```js 53 | module.exports = function(app, express) { 54 | app.use(function myMiddleware(req, res, next) { 55 | ... 56 | }); 57 | } 58 | ``` 59 | * `webpackLazyMode` - switches webpack dev middleware to lazy mode, which means 60 | javascript will be recompiled on each request. 61 | 62 | ## Writing the tests 63 | 64 | Use `geminiReact` variable instead of `gemini` and `render()` 65 | instead of `setUrl` and `setCaptureElements`. The rest is the same as vanilla 66 | `gemini`: 67 | 68 | ```jsx 69 | const MyComponent = require('./path/to/my/component'); 70 | geminiReact.suite('my react test', suite => { 71 | suite.render() 72 | .capture('initial'); 73 | }); 74 | ``` 75 | 76 | **TIP**: To use JSX in your tests, you might need [gemini-babel](https://github.com/researchgate/gemini-babel) plugin. 77 | 78 | You don't need to create the reference pages or run the server, plugin will do 79 | everything for you. 80 | 81 | If you want to interact with rendered component, use `this.renderedComponent` 82 | inside your test: 83 | 84 | ```javascript 85 | suite.capture('clicked', function(actions) { 86 | actions.click(this.renderedComponent); 87 | }); 88 | ``` 89 | 90 | If you have any test-specific stylesheets, you can include them into the test 91 | page by calling `suite.includeCss`: 92 | 93 | ```javscript 94 | suite.includeCss('/my-component.css'); 95 | ``` 96 | 97 | By default, `geminiReact` will capture rendered at mounting point element. 98 | If you want to add some extra elements, use `setExtraCaptureElements`: 99 | 100 | ```javascript 101 | suite.setExtraCaptureElements(['.popup']); 102 | ``` 103 | 104 | ## Viewing the example page 105 | 106 | If you want to view example pages without actually running the tests, you can use `gemini-react-server` binary, provided by this package: 107 | 108 | ``` 109 | ./node_modules/.bin/gemini-react-server 110 | ``` 111 | 112 | It will run the server on the host and port, specified in plugin configuration in `.gemini.yml`. 113 | 114 | The url of each example is a series of ulr-encoded suite names. For example, this suite: 115 | 116 | ```javascript 117 | geminiReact.suite('parent', () => { 118 | geminiReact.suite('child', () => { 119 | ... 120 | }); 121 | }) 122 | ``` 123 | 124 | will be served at `http://HOST:PORT/parent/child` url. 125 | -------------------------------------------------------------------------------- /test/router.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const router = require('../lib/router'); 4 | const sinon = require('sinon'); 5 | const assert = require('chai').assert; 6 | 7 | const RouteBuilder = require('../lib/route-builder'); 8 | const htmlTemplate = require('../lib/html-template'); 9 | 10 | describe('router', () => { 11 | const sandbox = sinon.sandbox.create(); 12 | 13 | afterEach(() => { 14 | sandbox.restore(); 15 | }); 16 | 17 | describe('testPathToChunkName', () => { 18 | it('should replace extension with .bundle.js', () => { 19 | assert.equal( 20 | router.testPathToChunkName('file.js'), 21 | 'file.bundle.js' 22 | ); 23 | }); 24 | 25 | it('should replace path delimeters with dashes', () => { 26 | assert.equal( 27 | router.testPathToChunkName('path/to/file.js'), 28 | 'path-to-file.bundle.js' 29 | ); 30 | }); 31 | 32 | it('should urlencode the value', () => { 33 | assert.equal( 34 | router.testPathToChunkName('file with spaces.js'), 35 | 'file%20with%20spaces.bundle.js' 36 | ); 37 | }); 38 | }); 39 | 40 | describe('testPathToChunkUrl', () => { 41 | it('should return root-relative chunk url', () => { 42 | assert.equal( 43 | router.testPathToChunkUrl('file.js'), 44 | '/assets/file.bundle.js' 45 | ); 46 | }); 47 | }); 48 | 49 | describe('middleware', () => { 50 | let routeBuilder; 51 | 52 | function requestFor(path) { 53 | return { 54 | path: path 55 | }; 56 | } 57 | 58 | function response() { 59 | return { 60 | locals: {}, 61 | send: () => {} 62 | }; 63 | } 64 | 65 | function callMiddleware(params) { 66 | params = params || {}; 67 | const req = params.req || requestFor('/'); 68 | const res = params.res || response(); 69 | const next = params.next || (() => {}); 70 | 71 | const middleware = router.middleware(routeBuilder); 72 | middleware(req, res, next); 73 | } 74 | 75 | beforeEach(() => { 76 | routeBuilder = sinon.createStubInstance(RouteBuilder); 77 | }); 78 | 79 | it('should call `next` if url is unknown', () => { 80 | const url = '/some/path'; 81 | routeBuilder.isUrlRegistered.withArgs(url).returns(false); 82 | const next = sandbox.spy().named('next'); 83 | 84 | callMiddleware({ 85 | req: requestFor(url), 86 | next: next 87 | }); 88 | 89 | assert.calledOnce(next); 90 | }); 91 | 92 | function setupSuccessResponse(url, html) { 93 | html = html || ''; 94 | 95 | const data = {}; 96 | 97 | routeBuilder.isUrlRegistered 98 | .withArgs(url) 99 | .returns(true); 100 | routeBuilder.getTemplateDataFromUrl 101 | .withArgs(url) 102 | .returns(data); 103 | sandbox.stub(htmlTemplate, 'render') 104 | .withArgs(data) 105 | .returns(html); 106 | } 107 | 108 | it('should render html template if url matches', () => { 109 | const url = '/some/path'; 110 | const html = '

It works!

'; 111 | const res = response(); 112 | sandbox.spy(res, 'send'); 113 | setupSuccessResponse(url, html); 114 | 115 | callMiddleware({ 116 | req: requestFor(url), 117 | res: res 118 | }); 119 | 120 | assert.calledWith(res.send, html); 121 | }); 122 | 123 | it('should pick webpacks assets', () => { 124 | const url = '/some/path'; 125 | const res = response(); 126 | 127 | setupSuccessResponse(url); 128 | res.locals.webpackStats = { 129 | toJson: () => ({ 130 | assetsByChunkName: { 131 | 'test.bundle.js': 'path/to/test.bundle.js', 132 | 'common.js': 'path/to/common.js' 133 | } 134 | }) 135 | }; 136 | 137 | callMiddleware({ 138 | req: requestFor(url), 139 | res: res 140 | }); 141 | 142 | assert.calledWith( 143 | routeBuilder.getTemplateDataFromUrl, 144 | sinon.match.any, 145 | sinon.match(['/assets/path/to/common.js']) 146 | ); 147 | }); 148 | }); 149 | }); 150 | -------------------------------------------------------------------------------- /lib/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const path = require('path'), 3 | url = require('url'), 4 | http = require('http'), 5 | log = require('debug')('gemini:react'), 6 | RouteBuilder = require('./route-builder'), 7 | WebpackBundler = require('./webpack-bundler'), 8 | express = require('express'), 9 | enableDestroy = require('server-destroy'), 10 | TmpJsStorage = require('./tmp-js-storage'), 11 | router = require('./router'); 12 | 13 | function promisifyCall(fn, context, args) { 14 | args = args || []; 15 | return new Promise((resolve, reject) => { 16 | const callback = (error, result) => { 17 | if (error) { 18 | return reject(error); 19 | } 20 | 21 | resolve(result); 22 | }; 23 | const argsWithCallback = args.concat(callback); 24 | fn.apply(context, argsWithCallback); 25 | }); 26 | } 27 | 28 | class Server { 29 | /** 30 | * @param {Object} options 31 | * @param {String} options.projectRoot 32 | * @param {String} options.webpackConfig 33 | * @param {String} options.hostname 34 | * @param {String} options.port 35 | * @param {String?} options.staticRoot 36 | */ 37 | constructor(options) { 38 | this._app = express(); 39 | this._lastId = 0; 40 | this._bundler = new WebpackBundler(options); 41 | this._tmpJsStorage = new TmpJsStorage(options); 42 | this._routeBuilder = new RouteBuilder(); 43 | this._tmpJsStorage.init(); 44 | this._projectRoot = options.projectRoot; 45 | this._listenHost = options.listenHost; 46 | this._staticRoot = options.staticRoot; 47 | this._port = options.port; 48 | this._httpServer = null; 49 | this._customizeServer = options.customizeServer; 50 | options.cssFiles.forEach(file => this.includeCss(file)); 51 | } 52 | 53 | /** 54 | * @param {string} name 55 | */ 56 | pushSuite(name) { 57 | this._routeBuilder.pushSuite(name); 58 | } 59 | 60 | popSuite() { 61 | this._routeBuilder.popSuite(); 62 | } 63 | 64 | registerRender() { 65 | return this._routeBuilder.buildRoute(); 66 | } 67 | 68 | /** 69 | * @param {String} relativeUrl 70 | */ 71 | includeCss(relativeUrl) { 72 | if (!this._staticRoot) { 73 | throw new Error('You must set staticRoot option to be able to include CSS files'); 74 | } 75 | this._routeBuilder.includeCss(relativeUrl); 76 | } 77 | 78 | /** 79 | * @param {string} suitePath 80 | */ 81 | bundleTestFileForBrowser(suitePath) { 82 | const fileToBundle = this._tmpJsStorage.buildFile(suitePath); 83 | const rootRelativePath = path.relative(this._projectRoot, suitePath); 84 | 85 | this._bundler.bundle( 86 | router.testPathToChunkName(rootRelativePath), 87 | fileToBundle 88 | ); 89 | 90 | this._routeBuilder.setCurrentPageJSUrl(router.testPathToChunkUrl(rootRelativePath)); 91 | } 92 | 93 | /** 94 | * @return {Promise.} 95 | */ 96 | start() { 97 | this._setupMiddleware(); 98 | this._httpServer = http.createServer(this._app); 99 | enableDestroy(this._httpServer); 100 | return promisifyCall( 101 | this._httpServer.listen, 102 | this._httpServer, 103 | [this._port, this._listenHost] 104 | ) 105 | .then(() => url.format({ 106 | protocol: 'http', 107 | hostname: this._listenHost, 108 | port: this._port 109 | })); 110 | } 111 | 112 | _setupMiddleware() { 113 | this._bundlerMiddleware = this._bundler.buildMiddleware(router.assetsRoot()); 114 | this._app.use(this._bundlerMiddleware); 115 | this._app.use(router.middleware(this._routeBuilder)); 116 | this._customizeServer(this._app, express); 117 | if (this._staticRoot) { 118 | this._app.use(express.static(this._staticRoot)); 119 | } 120 | } 121 | 122 | /** 123 | * @return {Promise} 124 | */ 125 | stop() { 126 | return this._tmpJsStorage.cleanup() 127 | .then(() => this._stopWatch()) 128 | .then(() => this._closeServer()) 129 | .then(() => log('stopped server')); 130 | } 131 | 132 | _stopWatch() { 133 | if (!this._bundlerMiddleware) { 134 | return Promise.resolve(); 135 | } 136 | return promisifyCall( 137 | this._bundlerMiddleware.close, 138 | this._bundlerMiddleware 139 | ); 140 | } 141 | 142 | _closeServer() { 143 | if (!this._httpServer) { 144 | return Promise.resolve(); 145 | } 146 | return promisifyCall( 147 | this._httpServer.destroy, 148 | this._httpServer 149 | ); 150 | } 151 | } 152 | 153 | module.exports = Server; 154 | -------------------------------------------------------------------------------- /test/server-suite-wrapper.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const wrap = require('../lib/server-suite-wrapper'); 3 | const assert = require('chai').assert; 4 | const sinon = require('sinon'); 5 | const Server = require('../lib/server'); 6 | const noop = require('lodash').noop; 7 | const RENDER_TARGET_SELECTOR = wrap.RENDER_TARGET_SELECTOR; 8 | 9 | describe('server-suite-wrapper', () => { 10 | const sandbox = sinon.sandbox.create(); 11 | afterEach(() => { 12 | sandbox.restore(); 13 | }); 14 | 15 | describe('.suite', () => { 16 | let server, original, geminiReact; 17 | beforeEach(() => { 18 | server = sinon.createStubInstance(Server); 19 | original = { 20 | suite: sandbox.spy().named('original .suite') 21 | }; 22 | 23 | geminiReact = wrap(original, server); 24 | }); 25 | 26 | it('calls original method', () => { 27 | geminiReact.suite('example', noop); 28 | assert.calledWith(original.suite, 'example'); 29 | }); 30 | 31 | it('pushes suite name to the server', () => { 32 | geminiReact.suite('example', noop); 33 | assert.calledWithExactly(server.pushSuite, 'example'); 34 | }); 35 | 36 | it('pops the suite after setup is done', () => { 37 | geminiReact.suite('example', noop); 38 | assert.callOrder(original.suite, server.popSuite); 39 | }); 40 | }); 41 | 42 | describe('ServerSuiteWrapper', () => { 43 | const forwardMethods = [ 44 | 'before', 45 | 'after', 46 | 'setTolerance', 47 | 'capture', 48 | 'ignoreElements', 49 | 'skip', 50 | 'browsers' 51 | ]; 52 | let original, wrapped, server; 53 | 54 | beforeEach(() => { 55 | original = forwardMethods.reduce((obj, method) => { 56 | obj[method] = sandbox.spy().named(method); 57 | return obj; 58 | }, {}); 59 | original.setUrl = sandbox.spy(); 60 | original.setCaptureElements = sandbox.spy(); 61 | 62 | const gemini = { 63 | suite: sandbox.stub().callsArgWith(1, original) 64 | }; 65 | 66 | server = sinon.createStubInstance(Server); 67 | wrap(gemini, server).suite('example', wrappedSuite => { 68 | wrapped = wrappedSuite; 69 | }); 70 | }); 71 | 72 | forwardMethods.forEach((method) => { 73 | it(`forwards ${method} to original`, () => { 74 | wrapped[method]('arg'); 75 | assert.calledWithExactly(original[method], 'arg'); 76 | }); 77 | }); 78 | 79 | it('disallows calling `setCaptureElements`', () => { 80 | assert.throws(() => wrapped.setCaptureElements(['.test'])); 81 | }); 82 | 83 | it('disallows calling `setUrl`', () => { 84 | assert.throws(() => wrapped.setUrl('/some/path')); 85 | }); 86 | 87 | describe('render', () => { 88 | it('sets url to server route', () => { 89 | const route = '/some/path'; 90 | server.registerRender.returns(route); 91 | wrapped.render(); 92 | 93 | assert.calledWithExactly(original.setUrl, route); 94 | }); 95 | 96 | it('sets capture element render target selector', () => { 97 | wrapped.render(); 98 | 99 | assert.calledWithExactly(original.setCaptureElements, [RENDER_TARGET_SELECTOR]); 100 | }); 101 | }); 102 | 103 | describe('includeCss', () => { 104 | it('should include css on server', () => { 105 | const stylesheet = 'style.css'; 106 | wrapped.includeCss(stylesheet); 107 | assert.calledWithExactly(server.includeCss, stylesheet); 108 | }); 109 | }); 110 | 111 | describe('setExtraCaptureElements', () => { 112 | it('should work with a string', () => { 113 | const selector = '.extra-selector'; 114 | 115 | wrapped.setExtraCaptureElements(selector); 116 | wrapped.render(); 117 | 118 | assert.calledWithExactly(original.setCaptureElements, [RENDER_TARGET_SELECTOR, selector]); 119 | }); 120 | 121 | it('should work with an array', () => { 122 | const selector1 = '.selector1'; 123 | const selector2 = '.selector2'; 124 | 125 | wrapped.setExtraCaptureElements([selector1, selector2]); 126 | wrapped.render(); 127 | 128 | assert.calledWithExactly( 129 | original.setCaptureElements, 130 | [RENDER_TARGET_SELECTOR, selector1, selector2] 131 | ); 132 | }); 133 | 134 | it('should throw on non array and non string argument', () => { 135 | assert.throws(() => { 136 | wrapped.setExtraCaptureElements(1234); 137 | }, 'setExtraCaptureElements accepts an array or string'); 138 | }); 139 | }); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /test/client-wrapper.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser */ 2 | 'use strict'; 3 | 4 | const React = require('react'); 5 | const path = require('path'); 6 | const ReactDOM = require('react-dom'); 7 | const sinon = require('sinon'); 8 | const assert = require('chai').assert; 9 | const injectDom = require('jsdom-global'); 10 | 11 | describe('client wrapper', () => { 12 | const sandbox = sinon.sandbox.create(); 13 | let cleanupDom; 14 | 15 | function setupDom(urlPathName) { 16 | urlPathName = urlPathName || ''; 17 | cleanupDom = injectDom(null, { 18 | url: `http://example.com/${urlPathName}` 19 | }); 20 | } 21 | 22 | function loadWrapper() { 23 | const absPath = path.resolve(__dirname, '..', 'lib', 'client-wrapper.js'); 24 | delete require.cache[absPath]; 25 | return require(absPath); 26 | } 27 | 28 | afterEach(() => { 29 | sandbox.restore(); 30 | cleanupDom(); 31 | }); 32 | 33 | describe('render', () => { 34 | let wrapped; 35 | 36 | function setupDomAndSuites(options) { 37 | setupDom(options.urlPathName); 38 | 39 | const createWrapper = loadWrapper(); 40 | const geminiReact = createWrapper(); 41 | 42 | geminiReact.suite(options.parentSuite, () => { 43 | geminiReact.suite(options.childSuite, (suite) => { 44 | wrapped = suite; 45 | }); 46 | }); 47 | } 48 | 49 | describe('when suite path matches the URL', () => { 50 | function checkIfRendersTheElement() { 51 | const element = React.createElement('span', null, 'Example'); 52 | sandbox.stub(ReactDOM, 'render'); 53 | 54 | wrapped.render(element); 55 | 56 | assert.calledWith(ReactDOM.render, element); 57 | } 58 | 59 | it('should render react element', () => { 60 | setupDomAndSuites({ 61 | urlPathName: 'some/suite', 62 | parentSuite: 'some', 63 | childSuite: 'suite' 64 | }); 65 | checkIfRendersTheElement(); 66 | }); 67 | 68 | it('should account for url encoding', () => { 69 | setupDomAndSuites({ 70 | urlPathName: 'some%20suite%20with%20spaces/suite', 71 | parentSuite: 'some suite with spaces', 72 | childSuite: 'suite' 73 | }); 74 | 75 | checkIfRendersTheElement(); 76 | }); 77 | }); 78 | 79 | describe('when suite path matches URL incomletely', () => { 80 | it('should not render element', () => { 81 | setupDomAndSuites({ 82 | urlPathName: 'some/suite/name', 83 | parentSuite: 'some', 84 | childSuite: 'suite' 85 | }); 86 | 87 | const element = React.createElement('span', null, 'Example'); 88 | sandbox.stub(ReactDOM, 'render'); 89 | 90 | wrapped.render(element); 91 | 92 | assert.notCalled(ReactDOM.render); 93 | }); 94 | }); 95 | 96 | describe('when suite path does not matches the URL', () => { 97 | it('should not call children callback', () => { 98 | setupDom('some/path'); 99 | const spy = sandbox.spy().named('suite callback'); 100 | 101 | const geminiReact = require('../lib/client-wrapper')(); 102 | geminiReact.suite('other name', spy); 103 | 104 | assert.notCalled(spy); 105 | }); 106 | }); 107 | }); 108 | 109 | describe('error hanlder', () => { 110 | function triggerFakeError(options) { 111 | const message = 'Something bad happened'; 112 | options = options || {stacktrace: false}; 113 | const fakeError = { 114 | message: message 115 | }; 116 | if (options.stacktrace) { 117 | fakeError.stack = [ 118 | `Error: ${message}`, 119 | ' at some-file.js:12:34' 120 | ].join('\n'); 121 | } 122 | const errorEvent = new ErrorEvent('error', {message: message, error: fakeError}); 123 | 124 | window.dispatchEvent(errorEvent); 125 | return fakeError; 126 | } 127 | 128 | beforeEach(() => { 129 | setupDom(); 130 | loadWrapper(); 131 | }); 132 | 133 | it('should create DOM element', () => { 134 | triggerFakeError(); 135 | 136 | assert.isOk(document.querySelector('.stack'), 'Expected stacktrace element to exist'); 137 | }); 138 | 139 | it('should display a stacktrace', () => { 140 | const error = triggerFakeError({stacktrace: true}); 141 | const element = document.querySelector('.stack'); 142 | 143 | assert.equal(element.textContent, error.stack); 144 | }); 145 | 146 | it('should display a message if stacktrace is unavailable', () => { 147 | const error = triggerFakeError({stacktrace: false}); 148 | const element = document.querySelector('.stack'); 149 | 150 | assert.equal(element.textContent, error.message); 151 | }); 152 | }); 153 | }); 154 | -------------------------------------------------------------------------------- /test/route-builder.test.js: -------------------------------------------------------------------------------- 1 | const RouteBuilder = require('../lib/route-builder'); 2 | const assert = require('chai').assert; 3 | 4 | describe('RouteBuilder', () => { 5 | it('should return a route url, based on a suite name', () => { 6 | const routeBuilder = new RouteBuilder(); 7 | 8 | routeBuilder.pushSuite('example'); 9 | 10 | assert.equal( 11 | routeBuilder.buildRoute(), 12 | '/example' 13 | ); 14 | }); 15 | 16 | it('should combine multiple suites into single url', () => { 17 | const routeBuilder = new RouteBuilder(); 18 | 19 | routeBuilder.pushSuite('first'); 20 | routeBuilder.pushSuite('second'); 21 | 22 | assert.equal( 23 | routeBuilder.buildRoute(), 24 | '/first/second' 25 | ); 26 | }); 27 | 28 | it('should URL-encode the suite names', () => { 29 | const routeBuilder = new RouteBuilder(); 30 | 31 | routeBuilder.pushSuite('suite with space'); 32 | 33 | assert.equal( 34 | routeBuilder.buildRoute(), 35 | '/suite%20with%20space' 36 | ); 37 | }); 38 | 39 | it('should go back to previous suite after popSuite', () => { 40 | const routeBuilder = new RouteBuilder(); 41 | 42 | routeBuilder.pushSuite('first'); 43 | routeBuilder.pushSuite('second'); 44 | routeBuilder.popSuite(); 45 | 46 | assert.equal( 47 | routeBuilder.buildRoute(), 48 | '/first' 49 | ); 50 | }); 51 | 52 | describe('isUrlRegistered', () => { 53 | it('should return false for non-existing route', () => { 54 | const routeBuilder = new RouteBuilder(); 55 | 56 | assert.isFalse( 57 | routeBuilder.isUrlRegistered('non existing') 58 | ); 59 | }); 60 | 61 | it('should return false for non-built route', () => { 62 | const routeBuilder = new RouteBuilder(); 63 | 64 | routeBuilder.pushSuite('incomplete'); 65 | 66 | assert.isFalse( 67 | routeBuilder.isUrlRegistered('/incomplete') 68 | ); 69 | }); 70 | 71 | it('should return true for build route', () => { 72 | const routeBuilder = new RouteBuilder(); 73 | 74 | routeBuilder.pushSuite('example'); 75 | 76 | const url = routeBuilder.buildRoute(); 77 | 78 | assert.isTrue( 79 | routeBuilder.isUrlRegistered(url) 80 | ); 81 | }); 82 | }); 83 | 84 | describe('template data', () => { 85 | const getTemplateData = (setupBuilder, commonJs) => { 86 | const routeBuilder = new RouteBuilder(); 87 | setupBuilder(routeBuilder); 88 | const url = routeBuilder.buildRoute(); 89 | return routeBuilder.getTemplateDataFromUrl(url, commonJs); 90 | }; 91 | 92 | it('should have a title based on suite name', () => { 93 | const data = getTemplateData((routeBuilder) => { 94 | routeBuilder.pushSuite('example'); 95 | }); 96 | 97 | assert.equal( 98 | data.title, 99 | 'example' 100 | ); 101 | }); 102 | 103 | it('should have a title based on full name of nested suites', () => { 104 | const data = getTemplateData((routeBuilder) => { 105 | routeBuilder.pushSuite('first'); 106 | routeBuilder.pushSuite('second'); 107 | }); 108 | 109 | assert.equal( 110 | data.title, 111 | 'first second' 112 | ); 113 | }); 114 | 115 | it('should contain included stylesheet', () => { 116 | const data = getTemplateData((routeBuilder) => { 117 | routeBuilder.pushSuite('example'); 118 | routeBuilder.includeCss('/stylesheet.css'); 119 | }); 120 | 121 | assert.include(data.cssList, '/stylesheet.css'); 122 | }); 123 | 124 | it('should contain multiple included stylesheets', () => { 125 | const data = getTemplateData((routeBuilder) => { 126 | routeBuilder.pushSuite('example'); 127 | routeBuilder.includeCss('/first.css'); 128 | routeBuilder.includeCss('/second.css'); 129 | }); 130 | 131 | assert.include(data.cssList, '/first.css'); 132 | assert.include(data.cssList, '/second.css'); 133 | }); 134 | 135 | it('should inherit stylesheets from parent', () => { 136 | const data = getTemplateData((routeBuilder) => { 137 | routeBuilder.pushSuite('parent'); 138 | routeBuilder.includeCss('/parent.css'); 139 | routeBuilder.pushSuite('child'); 140 | routeBuilder.includeCss('/child.css'); 141 | }); 142 | 143 | assert.include(data.cssList, '/parent.css'); 144 | }); 145 | 146 | it('should include top-level stylesheets', () => { 147 | const data = getTemplateData((routeBuilder) => { 148 | routeBuilder.includeCss('/top-level.css'); 149 | routeBuilder.pushSuite('example'); 150 | routeBuilder.includeCss('/example.css'); 151 | }); 152 | 153 | assert.include(data.cssList, '/top-level.css'); 154 | }); 155 | 156 | it('should include current js file name', () => { 157 | const data = getTemplateData((routeBuilder) => { 158 | routeBuilder.setCurrentPageJSUrl('/example.js'); 159 | }); 160 | 161 | assert.include(data.jsList, '/example.js'); 162 | }); 163 | 164 | it('should include common scripts', () => { 165 | const data = getTemplateData( 166 | (routeBuilder) => routeBuilder.setCurrentPageJSUrl('/test.js'), 167 | ['/common.js'] 168 | ); 169 | 170 | assert.deepEqual(data.jsList, ['/common.js', '/test.js']); 171 | }); 172 | }); 173 | }); 174 | --------------------------------------------------------------------------------