├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .travis.yml ├── README.md ├── bin └── run ├── package.json ├── src ├── assert.js ├── cli.js ├── dispatch.js ├── index.js ├── leadfoot-session.js ├── multi-runner.js ├── runner-utils.js └── utils.js └── test └── dispatch.test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"], 3 | "plugins": ["transform-runtime"] 4 | } 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | charset = utf-8 8 | indent_style = space 9 | indent_size = 4 10 | 11 | [{*.json,*.yml}] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true 4 | }, 5 | "ecmaFeatures": { 6 | "arrowFunctions": true, 7 | "blockBindings": true, 8 | "defaultParams": true, 9 | "destructuring": true, 10 | "modules": true, 11 | "objectLiteralComputedProperties": true, 12 | "objectLiteralShorthandMethods": true, 13 | "objectLiteralShorthandProperties": true, 14 | "spread": true, 15 | "templateStrings": true 16 | }, 17 | "parser": "babel-eslint", 18 | "rules": { 19 | "quotes": [2, "single"], 20 | "no-console": 0, 21 | "new-cap": 0, 22 | "comma-spacing": 1, 23 | "no-process-exit": 0, 24 | "no-multi-spaces": 0 25 | }, 26 | "globals": { 27 | "console": true 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | jspm_packages/ 3 | node_modules/ 4 | selenium-server-* 5 | out/ 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | cache: 4 | directories: 5 | - node_modules 6 | notifications: 7 | email: false 8 | node_js: 9 | - "4.1" 10 | before_install: 11 | - npm i -g npm@^3.0.0 12 | before_script: 13 | - npm prune 14 | - export DISPLAY=:99.0 15 | - sh -e /etc/init.d/xvfb start 16 | after_success: 17 | - npm run semantic-release 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # integrator 2 | 3 | [![Build Status](https://travis-ci.org/phuu/integrator.svg?branch=master)](https://travis-ci.org/phuu/integrator) 4 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 5 | 6 | An action-oriented approach to integration testing, simulating users to comprehensively test your app. 7 | 8 | Status: **Prototype**. TweetDeck uses it, but it's not ready for general use. 9 | 10 | ## Why does Integrator exist? 11 | 12 | There are numerous problems with integration tests today: 13 | 14 | - *They don't simulate users.* Only the user-flows that you thought to test are tested, and assertions are coupled to CSS selectors. That's not how a user sees your application and it results in [change-detector][change-detector] tests. 15 | 16 | - *They have implicit dependencies.* Current test frameworks encourage you to write tests that depend on the success of another, without an explicit notion of dependency. The result is that test order *might be* important, which is hard to debug and refactor. 17 | 18 | - *Tests are hard to write and debug.* This leads to flaky tests, false negatives or (worse) false positives, and untested but critical user flows. 19 | 20 | Fixing it requires taking some of the manual work out of creating and maintaining these tests, providing a framework that helps the test author to avoid writing bad tests. 21 | 22 | What does that mean specifically? 23 | 24 | - Explicit, reproducible setup & teardown 25 | - Real user simulation in a chaotic testing 26 | - Explicit dependencies where ordering is well-defined and deterministic 27 | 28 | **Integrator** is a test runner and authoring framework that tries to help. 29 | 30 | ### License 31 | 32 | MIT 33 | 34 | [change-detector]: http://googletesting.blogspot.co.uk/2015/01/testing-on-toilet-change-detector-tests.html 35 | [node]: https://nodejs.org/ 36 | [npm]: https://www.npmjs.com/ 37 | [todomvc-actions]: https://github.com/phuu/todomvc/blob/integrator/tests/integrator/actions.js 38 | [new-issue]: https://github.com/phuu/integrator/issues/new 39 | -------------------------------------------------------------------------------- /bin/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | require('../out/cli'); 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "integrator", 3 | "description": "An experiment in fixing integration testing.", 4 | "bin": "bin/run", 5 | "main": "out/index", 6 | "files": [ 7 | "out", 8 | "bin" 9 | ], 10 | "scripts": { 11 | "build": "babel src -d out", 12 | "watch": "babel --watch src -d out", 13 | "test": "npm run lint && npm run specs", 14 | "specs": "ava --require babel-register", 15 | "lint": "eslint src/**/*.js -c .eslintrc", 16 | "prepublish": "npm run build", 17 | "semantic-release": "semantic-release pre && npm publish && semantic-release post" 18 | }, 19 | "config": { 20 | "ghooks": { 21 | "pre-commit": "npm run lint", 22 | "pre-push": "npm test" 23 | } 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/phuu/integrator.git" 28 | }, 29 | "author": "", 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/phuu/integrator/issues" 33 | }, 34 | "homepage": "https://github.com/phuu/integrator", 35 | "devDependencies": { 36 | "ava": "^0.11.0", 37 | "babel-cli": "^6.4.5", 38 | "babel-core": "^6.4.5", 39 | "babel-eslint": "^4.1.6", 40 | "babel-plugin-transform-runtime": "^6.4.3", 41 | "babel-preset-es2015": "^6.3.13", 42 | "eslint": "^1.6.0", 43 | "ghooks": "^1.0.1", 44 | "nodemon": "^1.3.7", 45 | "semantic-release": "^4.3.5" 46 | }, 47 | "dependencies": { 48 | "action-graph": "^1.3.0", 49 | "chalk": "^1.0.0", 50 | "fnkit": "^1.0.1", 51 | "immutable": "^3.7.3", 52 | "leadfoot": "^1.6.5", 53 | "minimist": "^1.1.1", 54 | "typd": "^3.0.0" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/assert.js: -------------------------------------------------------------------------------- 1 | const assert = { 2 | // Throw with `msg` if `v` isn't truthy 3 | ok: (v, msg) => { 4 | if (!v) { 5 | throw new Error(msg); 6 | } 7 | } 8 | }; 9 | 10 | export default assert; 11 | -------------------------------------------------------------------------------- /src/cli.js: -------------------------------------------------------------------------------- 1 | /** 2 | * CLI converts Integrator command line input into actual runs, using the mutiRunner. 3 | */ 4 | 5 | import path from 'path'; 6 | 7 | import parseArgs from 'minimist'; 8 | import { fromJS, Map, List } from 'immutable'; 9 | 10 | import multiRunner from './multi-runner'; 11 | import runnerUtils from './runner-utils'; 12 | import utils from './utils'; 13 | 14 | // TODO fromJS this 15 | const args = parseArgs(process.argv); 16 | 17 | const configFileArg = args._[2]; 18 | 19 | // Load config 20 | if (!utils.is('string', configFileArg)) { 21 | runnerUtils.gameOver( 22 | 'No configuration file supplied.', 23 | '\nUse: integrator path/to/configuration.js' 24 | ); 25 | } 26 | 27 | const configPath = path.resolve(process.cwd(), configFileArg); 28 | 29 | // Get the raw configuration data 30 | try { 31 | var rawConfigurationFile = require(configPath); 32 | } catch (why) { 33 | runnerUtils.gameOver( 34 | `Failed to load configuration file at ${configPath}`, 35 | `\n${why.stack}` 36 | ); 37 | } 38 | 39 | // Produce a config for the given arguments 40 | const integratorConfig = fromJS(rawConfigurationFile) 41 | // Choose some configuration targets 42 | .update('environments', Map(), environments => { 43 | if (!utils.is('string', args.environment)) { 44 | return environments; 45 | } 46 | // Only use configurations with the supplied name 47 | return environments.filter((_, name) => name === args.environment); 48 | }) 49 | .update('environments', Map(), environments => { 50 | // Save the environment's name for easy access later 51 | return environments 52 | .map((environment, name) => { 53 | return environment.set('envName', name); 54 | }) 55 | .valueSeq(); 56 | }); 57 | 58 | // The path to the suite should be supplied in the config file. 59 | if (!integratorConfig.has('suite')) { 60 | runnerUtils.gameOver( 61 | `No suite specified.`, 62 | `\nAdd the key 'suite' and a path to ${configFileArg}` 63 | ); 64 | } 65 | 66 | const suitePath = path.resolve(path.dirname(configPath), integratorConfig.get('suite')); 67 | 68 | // Grab the suite 69 | try { 70 | var initSuite = require(suitePath); 71 | } catch (why) { 72 | runnerUtils.gameOver( 73 | `Failed to load suite at ${suitePath}`, 74 | `\n${why.stack}` 75 | ); 76 | } 77 | 78 | if (!utils.is('function', initSuite)) { 79 | runnerUtils.gameOver( 80 | `The exported value from ${suitePath} must be a function` 81 | ); 82 | } 83 | 84 | multiRunner(initSuite, args, integratorConfig) 85 | .catch(e => { 86 | runnerUtils.gameOver( 87 | 'Failed to start integrator', 88 | `\n${e.stack}` 89 | ); 90 | }); 91 | -------------------------------------------------------------------------------- /src/dispatch.js: -------------------------------------------------------------------------------- 1 | import { Map } from 'immutable'; 2 | import { run } from 'action-graph'; 3 | import runnerUtils from './runner-utils'; 4 | import utils from './utils'; 5 | 6 | const defaultSession = { 7 | quit: () => {} 8 | }; 9 | 10 | const runner = ({ action, suite, target, session }) => { 11 | const context = { 12 | session: session 13 | }; 14 | return run(action, context, suite.initialState) 15 | .catch(runnerUtils.makeTestsFailedError) 16 | // We have to recover failures to collect and report on the results 17 | .then(runnerUtils.Pass, runnerUtils.Fail) 18 | .then(utils.makeEffect(() => (typeof session.quit === 'function') && session.quit())); 19 | }; 20 | 21 | const filterForArgs = (args, name) => { 22 | if (args.only && args.only !== name) { 23 | return false; 24 | } 25 | return true; 26 | }; 27 | 28 | const getActionsForArgs = (args, suite) => 29 | Object.keys(suite.actions) 30 | .filter(name => filterForArgs(args, name)) 31 | .map(name => suite.actions[name]); 32 | 33 | export default function dispatch(params = {}) { 34 | const { 35 | suite = {}, 36 | args = {}, 37 | getSession = () => Promise.resolve(defaultSession), 38 | target = Map() 39 | } = params; 40 | 41 | return Promise.resolve().then(() => { 42 | return getActionsForArgs(args, suite).reduce( 43 | (pPrev, action) => { 44 | return pPrev.then(() => { 45 | if (target.get('envName')) { 46 | runnerUtils.success( 47 | `\nRunning: ${action.getDescription()}`, 48 | `\n on ${target.get('envName')}`, 49 | `\n in ${target.get('targetName')}` 50 | ); 51 | } 52 | const config = target.mergeDeepIn(['capabilities'], Map({ 53 | name: action.getDescription() 54 | })); 55 | return Promise.resolve(getSession(config)) 56 | .then(session => runner({ suite, session, action, args, target })); 57 | }); 58 | }, 59 | Promise.resolve() 60 | ); 61 | }); 62 | }; 63 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { createClass, anonymousAction } from 'action-graph'; 2 | import utils from './utils'; 3 | 4 | module.exports = { 5 | createClass, 6 | anonymousAction, 7 | utils 8 | }; 9 | -------------------------------------------------------------------------------- /src/leadfoot-session.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Initialise the Selenium WebDriver session using Leadfoot. 3 | */ 4 | import Server from 'leadfoot/Server'; 5 | 6 | const makeLeadfootSession = config => 7 | new Server(config.get('hub'), { 8 | proxy: config.get('proxy') 9 | }).createSession(config.get('capabilities')); 10 | 11 | export default makeLeadfootSession; 12 | -------------------------------------------------------------------------------- /src/multi-runner.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Multi-runner takes a configuration file and explodes it out it to multiple runers, merging 3 | * configuration as it goes. 4 | */ 5 | 6 | import { List, Map, fromJS } from 'immutable'; 7 | 8 | import utils from './utils'; 9 | import runnerUtils from './runner-utils'; 10 | import dispatch from './dispatch'; 11 | import makeLeadfootSession from './leadfoot-session'; 12 | 13 | const getSession = config => { 14 | return makeLeadfootSession(config) 15 | .then(session => { 16 | // Quit the session when the process is killed 17 | process.on('SIGINT', utils.makeCall(session, 'quit')); 18 | return session; 19 | }); 20 | }; 21 | 22 | const defaultConfiguration = fromJS({ 23 | hub: 'http://localhost:4444/wd/hub' 24 | }); 25 | 26 | const logResult = result => { 27 | const runResult = result.get('runResult'); 28 | const type = runResult.get('type'); 29 | const value = runResult.get('value'); 30 | const envName = result.getIn(['target', 'envName']); 31 | const prettyName = result.getIn(['target', 'targetName']); 32 | if (type === 'fail') { 33 | runnerUtils.error( 34 | `\nFailed: ${value.action ? value.action.getDescription() : ''}`, 35 | `\n on ${envName}`, 36 | `\n in ${prettyName}`, 37 | `\n${value.stack}` 38 | ); 39 | } else { 40 | runnerUtils.success( 41 | `\nPassed:`, 42 | `\n on ${envName}`, 43 | `\n in ${prettyName}` 44 | ); 45 | } 46 | }; 47 | 48 | const handleFinished = results => { 49 | return fromJS(results) 50 | .map(utils.makeEffect(logResult)); 51 | }; 52 | 53 | const makeRunPlugins = (phase, integratorConfig) => () => { 54 | runnerUtils.section(`\nRunning plugins (${phase}):\n`); 55 | 56 | var pPlugins = integratorConfig 57 | .get('environments', List()) 58 | .flatMap(environment => environment.get('plugins', List())) 59 | .toJS() 60 | .map(plugin => { 61 | if (plugin.hasOwnProperty(phase) && !utils.is('function', plugin[phase])) { 62 | return runnerUtils.gameOver( 63 | `Plugin ${plugin.constructor.name} '${phase}' ` + 64 | `property is not a function` 65 | ); 66 | } 67 | return plugin[phase](integratorConfig); 68 | }); 69 | 70 | return Promise.all(pPlugins) 71 | .catch(why => { 72 | runnerUtils.gameOver( 73 | `Plugins failed to run successfully`, 74 | `\n${why ? why.stack : ''}` 75 | ); 76 | }); 77 | }; 78 | 79 | const getTargetsFromEnvironment = environment => 80 | environment 81 | .get('targets', List()) 82 | .map(targetConfiguration => { 83 | const target = 84 | defaultConfiguration 85 | .mergeDeep(environment.get('common', Map())) 86 | .mergeDeep(targetConfiguration); 87 | return target.merge(fromJS({ 88 | envName: environment.get('envName'), 89 | targetName: runnerUtils.generateTargetName(target) 90 | })); 91 | }) 92 | 93 | const runEnvironmentTargets = (initSuite, args, environment) => { 94 | return getTargetsFromEnvironment(environment) 95 | .map(target => { 96 | runnerUtils.info( 97 | ` ${target.get('targetName')}` 98 | ); 99 | return dispatch({ suite: initSuite(), args, target, getSession }) 100 | .then(runResult => fromJS({ 101 | runResult, 102 | target, 103 | environment 104 | })); 105 | }); 106 | }; 107 | 108 | const makeRunTargets = (initSuite, args, integratorConfig) => () => { 109 | var pTargets = integratorConfig 110 | .get('environments', List()) 111 | .flatMap(environment => { 112 | runnerUtils.info( 113 | `Running: ${environment.get('envName')}`, 114 | `\n in ${environment.get('targets', List()).count()} configurations:` 115 | ); 116 | return runEnvironmentTargets(initSuite, args, environment); 117 | }) 118 | .toJS() 119 | return Promise.all(pTargets); 120 | }; 121 | 122 | const multiRunner = (initSuite, args, integratorConfig) => { 123 | runnerUtils.success('integrator\n==========================='); 124 | return Promise.resolve() 125 | .then(utils.makeEffect(makeRunPlugins('before', integratorConfig))) 126 | .then(makeRunTargets(initSuite, args, integratorConfig)) 127 | .then(utils.makeEffect(makeRunPlugins('after', integratorConfig))) 128 | .then(handleFinished) 129 | .catch((why) => { 130 | runnerUtils.gameOver( 131 | '\nSomething went wrong.', 132 | `\n${why.stack}` 133 | ); 134 | }); 135 | }; 136 | 137 | export default multiRunner; 138 | -------------------------------------------------------------------------------- /src/runner-utils.js: -------------------------------------------------------------------------------- 1 | import { inspect } from 'util'; 2 | 3 | import chalk from 'chalk'; 4 | import { fromJS } from 'immutable'; 5 | 6 | import utils from './utils'; 7 | 8 | // Errors 9 | 10 | class TestsFailedError { 11 | constructor({ message, action, stack }) { 12 | this.message = message; 13 | this.action = action; 14 | this.stack = stack; 15 | } 16 | } 17 | 18 | const runnerUtils = { 19 | // Logging 20 | // TODO multicast this to remember what was logged, for JSON output later 21 | gameOver: (...msgs) => { 22 | runnerUtils.error(...msgs); 23 | process.exit(1); 24 | }, 25 | 26 | error: (msg, ...msgs) => { 27 | console.error(chalk.red(msg), ...msgs); 28 | }, 29 | 30 | warning: (msg, ...msgs) => { 31 | console.error(chalk.yellow(msg), ...msgs); 32 | }, 33 | 34 | info: (...msgs) => { 35 | console.log(...msgs); 36 | }, 37 | 38 | section: (msg, ...msgs) => { 39 | console.log(chalk.blue(msg), ...msgs); 40 | }, 41 | 42 | success: (msg, ...msgs) => { 43 | console.log(chalk.green(msg), ...msgs); 44 | }, 45 | 46 | // Actions 47 | actionGraph: (args, suite) => { 48 | const nodeNodeNames = suite.get('actions').map(action => ({ 49 | action, 50 | name: action.get('name'), 51 | nodeName: action.get('name') 52 | .replace(/[^a-z0-9]/ig, '_') 53 | .replace(/\_{2,}/g, '_') 54 | .replace(/\_$/g, ''), 55 | deps: action.get('deps').map(dep => dep.replace(/\s/g, '_')) 56 | })); 57 | 58 | return [].concat( 59 | ['digraph G {'], 60 | nodeNodeNames.flatMap(({name, nodeName}) => 61 | [ ` node [] ${nodeName} {` 62 | , ` label = "${name}"` 63 | , ` }` 64 | ] 65 | ), 66 | nodeNodeNames.flatMap(({nodeName, deps}) => 67 | deps.map(dep => 68 | ` ${dep} -> ${nodeName} [];` 69 | ) 70 | ), 71 | ['}'] 72 | ); 73 | }, 74 | 75 | // Running actions 76 | handleSuccess: (/* args */) => () => { 77 | runnerUtils.success('\nPassed.'); 78 | }, 79 | 80 | TestsFailedError: TestsFailedError, 81 | 82 | makeTestsFailedError: why => { 83 | throw new TestsFailedError(why); 84 | }, 85 | 86 | logRan: data => { 87 | runnerUtils.info('\nRan:'); 88 | data.get('ran') 89 | .map(({action, phaseName, data, updatedData}) => { 90 | runnerUtils.info(` ${action.get('name')} (${phaseName})`); 91 | runnerUtils.info(' | model :', data.get('model')); 92 | runnerUtils.info(' | before :', data.get('model')); 93 | runnerUtils.info(' | after :', updatedData.get('model')); 94 | runnerUtils.info(' | fixtures :', data.get('fixtures')); 95 | }); 96 | // TODO: allow depth to be supplied in args 97 | runnerUtils.info('\nFinally:'); 98 | runnerUtils.info(' Model:'); 99 | runnerUtils.info(inspect(data.get('model').toJS(), { depth: 10, colors: true })); 100 | runnerUtils.info(' Fixtures:'); 101 | runnerUtils.info(inspect(data.get('fixtures').toJS(), { depth: 10, colors: true })); 102 | }, 103 | 104 | makeQuit: (session, { message, code }) => () => { 105 | try { 106 | if (message) { 107 | runnerUtils.info('\nQuit:', message); 108 | } 109 | return session.quit() 110 | .then(function () { 111 | process.exit(code); 112 | }); 113 | } catch (e) {} 114 | }, 115 | 116 | generateTargetName: (config) => { 117 | return [ 118 | config.getIn(['capabilities', 'platform']), 119 | config.getIn(['capabilities', 'os']), 120 | config.getIn(['capabilities', 'os_version']), 121 | config.getIn(['capabilities', 'browserName']), 122 | config.getIn(['capabilities', 'browser']), 123 | config.getIn(['capabilities', 'browser_version']), 124 | config.getIn(['capabilities', 'resolution']), 125 | (config.getIn(['capabilities', 'browserstack.debug']) ? '(debug)' : ''), 126 | (config.getIn(['capabilities', 'browserstack.local']) ? '(local)' : '') 127 | ].filter(Boolean).join(' '); 128 | }, 129 | 130 | // Results 131 | Pass: v => fromJS({ 132 | type: 'pass', 133 | value: v 134 | }), 135 | 136 | Fail: why => fromJS({ 137 | type: 'fail', 138 | value: why 139 | }) 140 | 141 | }; 142 | 143 | export default runnerUtils; 144 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | 3 | const utils = { 4 | identity: x => x, 5 | fallback: (f, v) => x => { 6 | let fv = f(x); 7 | return (utils.is('undefined', fv) ? v : fv); 8 | }, 9 | always: x => () => x, 10 | is: (type, x) => (typeof x === type), 11 | not: f => (...args) => !f.call(this, ...args), 12 | compose: (f, g) => (...args) => f(g(...args)), 13 | 14 | allByKeyPromise: o => { 15 | var keys = Object.keys(o); 16 | return Promise.all(keys.map(k => o[k])) 17 | .then(all => 18 | all.reduce((res, v, i) => { 19 | res[keys[i]] = v; 20 | return res; 21 | }, {}) 22 | ); 23 | }, 24 | 25 | timeoutPromise: t => () => new Promise(resolve => setTimeout(resolve, t)), 26 | 27 | /* 28 | * Makes a function that pauses the session for some amount of time. 29 | * 30 | * Takes a session and a time in milliseconds. 31 | * 32 | * It does this by ticking every second, then interacting with the session to keep it from 33 | * timing out. It recurses, so you might get a stack overflow here if you set the pause time 34 | * high enough. 35 | * 36 | * Returns a (effect) function. 37 | */ 38 | makePause: (session, t) => { 39 | if (typeof t !== 'number') { 40 | throw new Error('pause utility takes a session and a timeout in milliseconds'); 41 | } 42 | const time = Math.min(1000, t); 43 | return utils.makeEffect(() => 44 | Promise.resolve() 45 | .then(utils.timeoutPromise(time)) 46 | .then(utils.makeCall(session, 'getPageTitle')) 47 | .then(() => { 48 | if (t - time > 0) { 49 | throw new Error(); // Use an error to cause recursion 50 | } 51 | }) 52 | .catch(utils.makePause(session, t - time))); 53 | }, 54 | 55 | /** 56 | * Create side-makeEffect function that acts as identity of its argument, unless the argument is 57 | * mutable. 58 | * 59 | * Usage: 60 | * 61 | * fn = makeEffect(() => mutateAllTheThings()) 62 | * fn(a) // -> Promise(a) (mutateAllTheThings will have been called) 63 | * 64 | * Returns a function that calls the passed `fn` and returns a Promise for its argument. 65 | */ 66 | makeEffect: fn => { 67 | return function (x, ...args) { 68 | var ctx = this; 69 | return Promise.resolve(fn.call(ctx, x, ...args)) 70 | .then(() => x); 71 | }; 72 | }, 73 | 74 | randomBetween: (min, max) => ~~(min + Math.random() * max), 75 | 76 | randomFrom: iterable => iterable.get(utils.randomBetween(0, iterable.size)), 77 | 78 | /** 79 | * Combine the stacks from Error objects `e` and `f` to produce a stack with the message from 80 | * `e` but the trace from `f`. Useful if you want to rewrite an error message. 81 | * 82 | * Usage: 83 | * 84 | * fakeStack( 85 | * Error('Hello'), 86 | * Error('World') 87 | * ) -> "Error: Hello\n...stack from World..." 88 | * 89 | * Returns a string. 90 | */ 91 | fakeStack: (e, f) => 92 | e.stack 93 | // Just the first line 94 | .split('\n').slice(0, 1) 95 | .concat( 96 | // All but the first line 97 | f.stack.split('\n').slice(1) 98 | ) 99 | .join('\n'), 100 | 101 | /** 102 | * Pull value from key of object. 103 | * 104 | * Usage: 105 | * 106 | * pluck('name')(Immutable.fromJS({ name: 'Tom' })) 107 | * 108 | * Returns value at key. 109 | */ 110 | pluck: (k, d) => o => o.get(k, d), 111 | 112 | /** 113 | * Find keyed value in Immutable.List by key. 114 | * 115 | * Usage: 116 | * 117 | * findByKey('name')(users)('tom') 118 | * 119 | * Return a matching element, or undefined. 120 | */ 121 | findByKey: k => xs => v => xs.find(x => x.get(k) === v), 122 | 123 | makeFindWithTimeout: (session, fn, newTimeout) => () => { 124 | var originalTimeout = 0; 125 | return session.getFindTimeout() 126 | .then(t => { 127 | originalTimeout = t; 128 | }) 129 | .then(() => session.setFindTimeout(newTimeout)) 130 | .then(fn) 131 | .then(utils.makeEffect(() => session.setFindTimeout(originalTimeout))); 132 | }, 133 | 134 | /** 135 | * Makes a Promise-returning function auto-retry a certain number of times. 136 | * 137 | * Example: 138 | 139 | makeRetryable(5, doShadyAsyncThing); 140 | 141 | * If the passed function throws (the Promise rejects), it will be called again with the same 142 | * arguments. 143 | * 144 | * Returns a Promise. 145 | */ 146 | makeRetryable: (n, fn) => { 147 | return function retrying() { 148 | var ctx = this; 149 | var args = [].slice.call(arguments); 150 | return fn.apply(ctx, args) 151 | .catch(why => { 152 | if (n > 0) { 153 | return utils.makeRetryable(n - 1, fn).apply(ctx, args); 154 | } 155 | throw why; 156 | }); 157 | }; 158 | }, 159 | 160 | makeCall: (o, method, ...args) => () => 161 | o[method].apply(o, args), 162 | 163 | makeCallOnArg: (method, ...args) => (o) => 164 | o[method].apply(o, args), 165 | 166 | makeCallPartial: (o, method, ...args) => (...innerArgs) => 167 | o[method].apply(o, args.concat(innerArgs)), 168 | 169 | makeCallOnArgPartial: (method, ...args) => (o, ...innerArgs) => 170 | o[method].apply(o, args.concat(innerArgs)), 171 | 172 | makeArity: (n, f) => (...args) => f.apply(this, args.slice(0, n)), 173 | 174 | /** 175 | * Find and return the common prefix of two Iterables as a List. 176 | * 177 | * A = List(1, 2, 3); 178 | * B = List(1, 2, 4); 179 | * commonPrefix(A, B) === List(1, 2); 180 | * 181 | * Takes two Interables, returns a List. 182 | */ 183 | commonPrefix: (A, B) => 184 | A.toList().zip(B.toList()) 185 | .takeWhile(([left, right]) => Immutable.is(left, right)) 186 | .map(([left]) => left) 187 | }; 188 | 189 | utils.defaultTo = utils.fallback.bind(null, utils.identity); 190 | utils.noDefault = utils.identity; 191 | 192 | /** 193 | * Find keyed value in Immutable iterable by key 'name'. 194 | * 195 | * Usage: 196 | * 197 | * findByName(users)('tom') 198 | * 199 | * Return a matching element, or undefined. 200 | */ 201 | utils.findByName = utils.findByKey('name'); 202 | 203 | export default utils; 204 | -------------------------------------------------------------------------------- /test/dispatch.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { fromJS } from 'immutable'; 3 | import { createClass } from 'action-graph'; 4 | 5 | import runnerUtils from '../src/runner-utils'; 6 | import dispatch from '../src/dispatch'; 7 | 8 | test('importable', t => { 9 | t.ok(dispatch); 10 | }); 11 | 12 | test('by default, runs all tests', t => { 13 | t.plan(2); 14 | const Example = createClass({ 15 | run(state) { 16 | t.pass(); 17 | return state; 18 | } 19 | }); 20 | const suite = { 21 | actions: { 22 | 'example test': new Example(), 23 | 'another example test': new Example() 24 | } 25 | }; 26 | return dispatch({ suite }) 27 | .catch(err => { 28 | console.log(err); 29 | throw err; 30 | }); 31 | }); 32 | 33 | test('passes session in context', t => { 34 | t.plan(1); 35 | const session = {}; 36 | const Example = createClass({ 37 | run(state) { 38 | const { context } = this; 39 | t.same(context.session, session); 40 | return state; 41 | } 42 | }); 43 | const suite = { 44 | actions: { 45 | 'example test': new Example() 46 | } 47 | }; 48 | return dispatch({ suite, getSession: () => session }); 49 | }); 50 | 51 | test('passes initialState', t => { 52 | t.plan(1); 53 | const initialState = fromJS({}); 54 | const Example = createClass({ 55 | run(state) { 56 | t.same(state, initialState); 57 | return state; 58 | } 59 | }); 60 | const suite = { 61 | actions: { 62 | 'example test': new Example() 63 | }, 64 | initialState: initialState 65 | }; 66 | return dispatch({ suite }); 67 | }); 68 | 69 | test('runs only the selected action if one is passed', t => { 70 | t.plan(1); 71 | const session = {}; 72 | const Example1 = createClass({ 73 | run(state) { 74 | t.pass(); 75 | return state; 76 | } 77 | }); 78 | const Example2 = createClass({ 79 | run() { 80 | t.fail(); 81 | } 82 | }); 83 | const suite = { 84 | actions: { 85 | 'example test': new Example1(), 86 | 'another example test': new Example2() 87 | } 88 | }; 89 | const args = { 90 | only: 'example test' 91 | }; 92 | return dispatch({ suite, getSession: () => session, args }); 93 | }); 94 | 95 | test('converts throws into TestsFailedErrors and bundles in Fail object', t => { 96 | t.plan(4); 97 | const Example = createClass({ 98 | displayName: 'thrower example', 99 | run() { 100 | throw new Error('Nope.'); 101 | } 102 | }); 103 | const suite = { 104 | actions: { 105 | example: new Example() 106 | } 107 | }; 108 | return dispatch({ suite }) 109 | .then( 110 | (res) => { 111 | const err = res.get('value'); 112 | t.same(res.get('type'), 'fail'); 113 | t.same(err.action, suite.actions.example); 114 | t.same(err.constructor, runnerUtils.TestsFailedError); 115 | t.same(err.message, 'Nope.'); 116 | } 117 | ); 118 | }); 119 | 120 | test('propagates action-graph run path errors', t => { 121 | t.plan(4); 122 | const Dep = createClass({ displayName: 'dep' }); 123 | const Example = createClass({ 124 | displayName: 'example', 125 | getDependencies() { 126 | return [ Dep ]; 127 | } 128 | }); 129 | const suite = { 130 | actions: { 131 | example: new Example() 132 | } 133 | }; 134 | return dispatch({ suite }) 135 | .then( 136 | (res) => { 137 | const err = res.get('value'); 138 | t.same(res.get('type'), 'fail'); 139 | t.same(err.action, suite.actions.example); 140 | t.same(err.constructor, runnerUtils.TestsFailedError); 141 | t.same(err.message, 'Action "example" depends on "dep" but matching instances are missing'); 142 | } 143 | ); 144 | }); 145 | --------------------------------------------------------------------------------