├── .editorconfig ├── .gitignore ├── .travis.yml ├── cli.js ├── lib ├── build.js ├── clean.js ├── get-output.js ├── get-versions.js ├── is-docker-running.js ├── main.js ├── parse-config.js ├── pull.js ├── states.js ├── test.js └── trevor-error.js ├── license.md ├── media ├── demo.gif └── logo.png ├── package.json └── readme.md /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [{package.json,*.yml}] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - "stable" 5 | - "lts/*" 6 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict'; 3 | 4 | const figures = require('figures'); 5 | const chalk = require('chalk'); 6 | const meow = require('meow'); 7 | const main = require('./lib/main'); 8 | const {STATE_ERROR} = require('./lib/states'); 9 | 10 | meow({ 11 | help: ` 12 | Usage: trevor [options] 13 | 14 | Options: 15 | 16 | -h, --help Show this help 17 | 18 | Required files (in the current directory): 19 | 20 | - package.json 21 | - .travis.yml 22 | ` 23 | }, { 24 | alias: {h: 'help'} 25 | }); 26 | 27 | const cwd = process.cwd(); 28 | 29 | main({cwd}) 30 | .then(state => { 31 | let hasErrors = false; 32 | 33 | for (const currentState of state.values()) { 34 | if (currentState === STATE_ERROR) { 35 | hasErrors = true; 36 | break; 37 | } 38 | } 39 | 40 | process.exit(hasErrors ? 1 : 0); 41 | }) 42 | .catch(err => { 43 | if (err.name === 'TrevorError') { 44 | console.log(`\n ${chalk.red(figures.cross)} ${err.message}\n`); 45 | } else { 46 | console.log(err.stack); 47 | } 48 | 49 | process.exit(1); 50 | }); 51 | -------------------------------------------------------------------------------- /lib/build.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const exec = require('execa'); 6 | const pify = require('pify'); 7 | 8 | const writeFile = pify(fs.writeFile); 9 | 10 | module.exports = context => { 11 | const dockerfile = [ 12 | `FROM node:${context.version}`, 13 | 'WORKDIR /usr/src/app', 14 | 'ARG NODE_ENV', 15 | 'ENV NODE_ENV $NODE_ENV', 16 | 'COPY package.json .', 17 | 'RUN npm install', 18 | 'COPY . .', 19 | 'CMD ["npm", "start"]' 20 | ].join('\n'); 21 | 22 | const tmpPath = path.join(context.cwd, `.${context.version}.dockerfile`); 23 | 24 | const image = `test-${context.name}-${context.version}`; 25 | const options = {cwd: context.cwd}; 26 | 27 | return writeFile(tmpPath, dockerfile, 'utf8') 28 | .then(() => exec('docker', ['build', '-t', image, '-f', tmpPath, '.'], options)) 29 | .then(() => context); 30 | }; 31 | -------------------------------------------------------------------------------- /lib/clean.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const exec = require('execa'); 6 | const pify = require('pify'); 7 | 8 | const removeFile = pify(fs.unlink); 9 | 10 | module.exports = context => { 11 | const image = `test-${context.name}-${context.version}`; 12 | const options = {cwd: context.cwd}; 13 | 14 | return removeFile(path.join(context.cwd, `.${context.version}.dockerfile`)) 15 | .then(() => exec('docker', ['rmi', '-f', image], options)) 16 | .then(() => context); 17 | }; 18 | -------------------------------------------------------------------------------- /lib/get-output.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const indentString = require('indent-string'); 4 | const figures = require('figures'); 5 | const table = require('text-table'); 6 | const chalk = require('chalk'); 7 | const { 8 | STATE_DOWNLOADING, 9 | STATE_BUILDING, 10 | STATE_CLEANING, 11 | STATE_RUNNING, 12 | STATE_SUCCESS, 13 | STATE_ERROR 14 | } = require('./states'); 15 | 16 | module.exports = state => { 17 | const items = []; 18 | 19 | for (const [version, currentState] of state) { 20 | let message; 21 | let icon; 22 | 23 | if (currentState === STATE_DOWNLOADING) { 24 | message = chalk.grey('downloading base image'); 25 | icon = chalk.grey(figures.circleDotted); 26 | } 27 | 28 | if (currentState === STATE_BUILDING) { 29 | message = chalk.grey('building environment'); 30 | icon = chalk.grey(figures.circleDotted); 31 | } 32 | 33 | if (currentState === STATE_CLEANING) { 34 | message = chalk.grey('cleaning up'); 35 | icon = chalk.grey(figures.circleDotted); 36 | } 37 | 38 | if (currentState === STATE_RUNNING) { 39 | message = chalk.grey('running'); 40 | icon = chalk.grey(figures.circleDotted); 41 | } 42 | 43 | if (currentState === STATE_SUCCESS) { 44 | message = chalk.green('success'); 45 | icon = chalk.green(figures.tick); 46 | } 47 | 48 | if (currentState === STATE_ERROR) { 49 | message = chalk.red('error'); 50 | icon = chalk.red(figures.cross); 51 | } 52 | 53 | items.push([icon, ` ${version}: `, message]); 54 | } 55 | 56 | return '\n' + indentString(table(items, {hsep: ''}), 1); 57 | }; 58 | -------------------------------------------------------------------------------- /lib/get-versions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fetchRelease = require('fetch-node-release'); 4 | const pMap = require('p-map'); 5 | 6 | const versionRegex = /^v(\d+\.)?(\d+\.)?(\*|\d+)$/; 7 | 8 | module.exports = config => { 9 | const versions = config.node_js || ['stable']; 10 | 11 | return pMap(versions, version => { 12 | if (fetchRelease.regexp.test(version)) { 13 | return fetchRelease(version); 14 | } 15 | 16 | if (versionRegex.test(version)) { 17 | return version.slice(1); 18 | } 19 | 20 | return version; 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /lib/is-docker-running.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const execa = require('execa'); 4 | 5 | module.exports = () => { 6 | return execa('docker', ['-v']) 7 | .then(() => true) 8 | .catch(() => false); 9 | }; 10 | -------------------------------------------------------------------------------- /lib/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const loadJsonFile = require('load-json-file'); 6 | const indentString = require('indent-string'); 7 | const kebabcase = require('lodash.kebabcase'); 8 | const logUpdate = require('log-update'); 9 | const copyFile = require('cp-file'); 10 | const figures = require('figures'); 11 | const chalk = require('chalk'); 12 | const pMap = require('p-map'); 13 | const pTap = require('p-tap'); 14 | const isDockerRunning = require('./is-docker-running'); 15 | const TrevorError = require('./trevor-error'); 16 | const parseConfig = require('./parse-config'); 17 | const getVersions = require('./get-versions'); 18 | const getOutput = require('./get-output'); 19 | const buildImage = require('./build'); 20 | const pullImage = require('./pull'); 21 | const clean = require('./clean'); 22 | const test = require('./test'); 23 | const { 24 | STATE_DOWNLOADING, 25 | STATE_BUILDING, 26 | STATE_CLEANING, 27 | STATE_RUNNING, 28 | STATE_SUCCESS, 29 | STATE_ERROR 30 | } = require('./states'); 31 | 32 | module.exports = ({cwd}) => { 33 | const filePath = name => path.join(cwd, name); 34 | 35 | if (!fs.existsSync(filePath('.travis.yml'))) { 36 | return Promise.reject(new TrevorError('.travis.yml doesn\'t exist')); 37 | } 38 | 39 | if (!fs.existsSync(filePath('package.json'))) { 40 | return Promise.reject(new TrevorError('package.json doesn\'t exist')); 41 | } 42 | 43 | const pkg = loadJsonFile.sync(filePath('package.json')); 44 | 45 | let originalDockerIgnore; 46 | if (fs.existsSync(filePath('.dockerignore'))) { 47 | originalDockerIgnore = fs.readFileSync(filePath('.dockerignore')); 48 | } else { 49 | copyFile.sync(filePath('.gitignore'), filePath('.dockerignore')); 50 | } 51 | 52 | fs.appendFileSync(filePath('.dockerignore'), '.*.dockerfile'); 53 | 54 | const config = parseConfig(filePath('.travis.yml')); 55 | const language = config.language || 'node_js'; 56 | 57 | if (language !== 'node_js') { 58 | return Promise.reject(new TrevorError(`Language ${language} isn't supported`)); 59 | } 60 | 61 | const state = new Map(); 62 | const errors = new Map(); 63 | const updateOutput = () => logUpdate(getOutput(state)); 64 | 65 | const dockerIgnoreCleanup = () => { 66 | fs.unlinkSync(filePath('.dockerignore')); 67 | if (originalDockerIgnore) { 68 | fs.writeFileSync(filePath('.dockerignore', originalDockerIgnore)); 69 | } 70 | }; 71 | 72 | return isDockerRunning() 73 | .then(isDockerUp => { 74 | if (!isDockerUp) { 75 | throw new TrevorError('Docker must be running to run the tests'); 76 | } 77 | 78 | return getVersions(config); 79 | }) 80 | .then(versions => { 81 | return pMap(versions, version => { 82 | const context = { 83 | name: kebabcase(pkg.name), 84 | config, 85 | cwd, 86 | version 87 | }; 88 | 89 | return Promise.resolve(context) 90 | .then(pTap(() => { 91 | state.set(version, STATE_DOWNLOADING); 92 | updateOutput(); 93 | })) 94 | .then(pullImage) 95 | .then(pTap(() => { 96 | state.set(version, STATE_BUILDING); 97 | updateOutput(); 98 | })) 99 | .then(buildImage) 100 | .then(pTap(() => { 101 | state.set(version, STATE_RUNNING); 102 | updateOutput(); 103 | })) 104 | .then(test) 105 | .then(pTap(() => { 106 | state.set(version, STATE_CLEANING); 107 | updateOutput(); 108 | })) 109 | .then(clean) 110 | .then(pTap(() => { 111 | state.set(version, STATE_SUCCESS); 112 | updateOutput(); 113 | })) 114 | .catch(err => { 115 | state.set(version, STATE_ERROR); 116 | errors.set(version, err.output); 117 | updateOutput(); 118 | 119 | return clean(context); 120 | }); 121 | }); 122 | }) 123 | .then(() => { 124 | logUpdate.done(); 125 | 126 | if (errors.size > 0) { 127 | for (const [version, output] of errors) { 128 | console.log(`\n ${chalk.red(figures.cross)} ${version}:`); 129 | console.log(indentString(output, 1)); 130 | } 131 | } 132 | 133 | fs.unlinkSync(filePath('.dockerignore')); 134 | if (originalDockerIgnore) { 135 | fs.writeFileSync(filePath('.dockerignore', originalDockerIgnore)); 136 | } 137 | 138 | return state; 139 | }) 140 | .catch(err => { 141 | dockerIgnoreCleanup(); 142 | 143 | throw err; 144 | }); 145 | }; 146 | -------------------------------------------------------------------------------- /lib/parse-config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const yaml = require('yamljs'); 5 | 6 | module.exports = path => yaml.parse(fs.readFileSync(path, 'utf8')); 7 | -------------------------------------------------------------------------------- /lib/pull.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const exec = require('execa'); 4 | 5 | module.exports = context => { 6 | const image = `node:${context.version}`; 7 | 8 | return exec('docker', ['pull', image]).then(() => context); 9 | }; 10 | -------------------------------------------------------------------------------- /lib/states.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.STATE_DOWNLOADING = 0; 4 | exports.STATE_BUILDING = 1; 5 | exports.STATE_CLEANING = 2; 6 | exports.STATE_RUNNING = 3; 7 | exports.STATE_SUCCESS = 4; 8 | exports.STATE_ERROR = 5; 9 | -------------------------------------------------------------------------------- /lib/test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const arrify = require('arrify'); 4 | const exec = require('execa'); 5 | 6 | module.exports = context => { 7 | const image = `test-${context.name}-${context.version}`; 8 | const env = { 9 | CONTINUOUS_INTEGRATION: true, 10 | TRAVIS: true, 11 | CI: true 12 | }; 13 | 14 | const args = [ 15 | 'run', 16 | '--rm' 17 | ]; 18 | 19 | Object.keys(env).forEach(key => { 20 | const value = env[key]; 21 | 22 | args.push('-e', `${key}=${value}`); 23 | }); 24 | 25 | const script = arrify(context.config.script || ['npm test']) 26 | .join(' && ') 27 | .split(' '); 28 | 29 | args.push(image); 30 | args.push(...script); 31 | 32 | let output = ''; 33 | const ps = exec('docker', args); 34 | ps.stdout.on('data', chunk => { 35 | output += chunk; 36 | }); 37 | 38 | ps.stderr.on('data', chunk => { 39 | output += chunk; 40 | }); 41 | 42 | return ps 43 | .then(() => context) 44 | .catch(() => { 45 | output = output 46 | .split('\n') 47 | .filter(line => !/^npm (info|err)/i.test(line)) 48 | .join('\n'); 49 | 50 | return Promise.reject({output}); 51 | }); 52 | }; 53 | -------------------------------------------------------------------------------- /lib/trevor-error.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | class TrevorError extends Error { 4 | constructor(message) { 5 | super(message); 6 | this.name = 'TrevorError'; 7 | } 8 | } 9 | 10 | module.exports = TrevorError; 11 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Vadim Demedes (https://vadimdemedes.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /media/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vadimdemedes/trevor/cd5f57b098cea61c5ebecb5a4f4d8d421daa89fb/media/demo.gif -------------------------------------------------------------------------------- /media/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vadimdemedes/trevor/cd5f57b098cea61c5ebecb5a4f4d8d421daa89fb/media/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "trevor", 3 | "version": "2.4.1", 4 | "description": "Your own Travis CI to run tests locally", 5 | "license": "MIT", 6 | "repository": "vadimdemedes/trevor", 7 | "author": "Vadim Demedes = 6" 10 | }, 11 | "scripts": { 12 | "test": "xo" 13 | }, 14 | "files": [ 15 | "cli.js", 16 | "lib" 17 | ], 18 | "bin": "cli.js", 19 | "dependencies": { 20 | "arrify": "^1.0.1", 21 | "chalk": "^1.1.3", 22 | "cp-file": "^4.1.1", 23 | "execa": "^0.6.0", 24 | "fetch-node-release": "^1.0.0", 25 | "figures": "^2.0.0", 26 | "indent-string": "^3.1.0", 27 | "load-json-file": "^2.0.0", 28 | "lodash.kebabcase": "^4.1.1", 29 | "log-update": "^1.0.2", 30 | "meow": "^3.7.0", 31 | "p-map": "^1.1.1", 32 | "p-tap": "^1.0.0", 33 | "pify": "^2.3.0", 34 | "text-table": "^0.2.0", 35 | "yamljs": "^0.2.8" 36 | }, 37 | "devDependencies": { 38 | "xo": "^0.17.1" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |

2 |
3 | 4 |
5 |
6 |

7 | 8 | [![Build Status](https://travis-ci.org/vadimdemedes/trevor.svg?branch=master)](https://travis-ci.org/vadimdemedes/trevor) 9 | 10 | > Your own Travis CI to run tests locally. 11 | 12 | 13 | ## Purpose 14 | 15 | I often need to run tests for multiple versions of Node.js. 16 | But I don't want to switch versions manually using `n`/`nvm` or push the code to Travis CI just to run the tests. 17 | 18 | That's why I created Trevor. It reads `.travis.yml` and runs tests in all versions you requested, just like Travis CI. 19 | Now, you can test before push and keep your git history clean. 20 | 21 | 22 | 23 | 24 | ## Requirements 25 | 26 | - [Docker](https://www.docker.com) 27 | 28 | 29 | ## Installation 30 | 31 | ``` 32 | $ npm install --global trevor 33 | ``` 34 | 35 | 36 | ## Usage 37 | 38 | Given the following `.travis.yml` file: 39 | 40 | ```yaml 41 | language: node_js 42 | node_js: 43 | - '7' 44 | - '6' 45 | - '4' 46 | ``` 47 | 48 | Run `trevor` in project's directory: 49 | 50 | ``` 51 | $ trevor 52 | ``` 53 | 54 | 55 | ## License 56 | 57 | MIT © [Vadim Demedes](https://vadimdemedes.com) 58 | --------------------------------------------------------------------------------