├── .gitignore ├── .npmignore ├── Dockerfile ├── LICENSE.md ├── README.md ├── bin ├── ops.js └── test-npm-dependants.js ├── images ├── express-alpha.png └── sandbox.png ├── index.js ├── lib ├── render.js └── run.js ├── ops.yml ├── package-lock.json └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | images -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ############################ 2 | # Build container 3 | ############################ 4 | FROM node:12-alpine AS dep 5 | 6 | WORKDIR /ops 7 | 8 | RUN apk add python make git openssh 9 | ADD package.json . 10 | RUN npm install 11 | 12 | ADD . . 13 | 14 | ############################ 15 | # Final container 16 | ############################ 17 | FROM registry.cto.ai/official_images/node:latest 18 | 19 | WORKDIR /ops 20 | 21 | COPY --from=dep /ops . -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ## License 2 | 3 | (MIT) 4 | 5 | Copyright (c) 2020 Julian Gruber <julian@juliangruber.com> 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of 8 | this software and associated documentation files (the "Software"), to deal in 9 | the Software without restriction, including without limitation the rights to 10 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 11 | of the Software, and to permit persons to whom the Software is furnished to do 12 | so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # test-npm-dependants 2 | 3 | Run the test suites of all modules depending on a given module. 4 | 5 | [**Read the blog post**](https://cto.ai/blog/ops-by-example-test-npm-dependants/). 6 | 7 | express-alpha 8 | 9 | ## Usage 10 | 11 | Use the CLI: 12 | 13 | ```bash 14 | $ test-npm-dependants 15 | 16 | test-npm-dependants NAME STABLEVERSION [NEXTVERSION] 17 | 18 | Options: 19 | 20 | --help, -h Print help text 21 | --version, -v Print program version 22 | --filter, -f Filter dependant names by this regexp 23 | --concurrency, -c Test concurrency [Default: 4] 24 | --timeout, -t Time out processes after x seconds [Default: 300] 25 | --output, -o Output mode [terminal, verbose] [Default: terminal] 26 | 27 | $ test-npm-dependants express 4.17.1 5.0.0-alpha.7 28 | 29 | test express dependants 30 | 31 | stable: 4.17.1 32 | next: 5.0.0-alpha.7 33 | time: 3m 34 | 35 | ⠼ ⠼ loopback Running test suite 36 | ✓ × hubot Breaks 37 | ⠼ ⠼ @theia/core Installing dependencies 38 | ✓ × probot Breaks 39 | ✓ ✓ @frctl/fractal Passes 40 | ⠼ ⠼ node-red Installing dependencies 41 | ✓ ✓ ember-cli Passes 42 | ⠼ ⠼ firebase-tools Running test suite 43 | ⠼ ⠼ appium-base-driver Running test suite 44 | 45 | ``` 46 | 47 | Use as an [Op](https://cto.ai/): 48 | 49 | ```bash 50 | $ npm install -g @cto.ai/ops && ops account:signup 51 | $ ops run @juliangruber/test-npm-dependants 52 | ``` 53 | 54 | ## Installation 55 | 56 | ```bash 57 | $ npm install -g test-npm-dependants 58 | ``` 59 | 60 | ## Security 61 | 62 | Running untrusted code on your computer is dangerous. This is why you should use 63 | this project via [Ops](https://cto.ai/) instead, which will sandbox everything 64 | inside a Docker container: 65 | 66 | ![Why Sandbox](images/sandbox.png) 67 | 68 | ## Caveats 69 | 70 | Tests will be run as child processes, so don't have a `TTY` attached. Any tests 71 | relying on it, for example those reading `process.stdout.columns`, are likely 72 | not going to work. 73 | 74 | If you want to debug why a test isn't passing, pass `--verbose` and test output 75 | will be printed out. 76 | 77 | ## Sponsors 78 | 79 | This project is [sponsored](https://github.com/sponsors/juliangruber) by [CTO.ai](https://cto.ai/), making it easy for development teams to create and share workflow automations without leaving the command line. 80 | 81 | [![](https://apex-software.imgix.net/github/sponsors/cto.png)](https://cto.ai/) 82 | -------------------------------------------------------------------------------- /bin/ops.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('..') 4 | const { sdk, ux } = require('@cto.ai/sdk') 5 | const fetch = require('node-fetch') 6 | const semver = require('semver') 7 | 8 | const main = async () => { 9 | const { name } = await ux.prompt([ 10 | { 11 | type: 'input', 12 | name: 'name', 13 | message: 'Name of the module to test', 14 | default: 'express' 15 | } 16 | ]) 17 | 18 | const res = await fetch(`https://registry.npmjs.org/${name}`) 19 | if (!res.ok) { 20 | console.error('Module not found!') 21 | process.exit(1) 22 | } 23 | const { 24 | 'dist-tags': { latest, next } 25 | } = await res.json() 26 | 27 | const { version, nextVersion, filter, timeout } = await ux.prompt([ 28 | { 29 | type: 'input', 30 | name: 'version', 31 | message: 'Stable version of the module', 32 | default: latest 33 | }, 34 | { 35 | type: 'input', 36 | name: 'nextVersion', 37 | message: 'Next version of the module', 38 | default: next && semver.gt(next, latest) ? next : undefined, 39 | allowEmpty: true 40 | }, 41 | { 42 | type: 'input', 43 | name: 'filter', 44 | message: 'Filter dependants by module name regular expression?', 45 | allowEmpty: true, 46 | flag: 'f' 47 | }, 48 | { 49 | type: 'input', 50 | name: 'timeout', 51 | message: 'Time out processes after x seconds?', 52 | allowEmpty: true, 53 | flag: 't', 54 | default: '300' 55 | } 56 | ]) 57 | 58 | let output = sdk.getInterfaceType() 59 | 60 | if (output === 'terminal') { 61 | const { verbose } = await ux.prompt([ 62 | { 63 | type: 'confirm', 64 | name: 'verbose', 65 | message: 'Run in verbose mode?', 66 | default: false 67 | } 68 | ]) 69 | if (verbose) output = 'verbose' 70 | } 71 | 72 | let concurrency 73 | if (output !== 'verbose') { 74 | ;({ concurrency } = await ux.prompt([ 75 | { 76 | type: 'number', 77 | name: 'concurrency', 78 | message: 'How many modules do you want to test at once?', 79 | default: 4, 80 | flag: 'c' 81 | } 82 | ])) 83 | } 84 | 85 | await test({ 86 | name, 87 | version, 88 | nextVersion, 89 | filter: filter && new RegExp(filter), 90 | timeout: timeout * 1000, 91 | output, 92 | concurrency 93 | }) 94 | } 95 | 96 | main().catch(err => { 97 | console.error(err) 98 | process.exit(1) 99 | }) 100 | -------------------------------------------------------------------------------- /bin/test-npm-dependants.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict' 3 | process.title = 'test-npm-dependants' 4 | 5 | const test = require('..') 6 | const minimist = require('minimist') 7 | const pkg = require('../package') 8 | 9 | const defaults = { 10 | concurrency: 4, 11 | timeout: 300 12 | } 13 | 14 | const argv = minimist(process.argv.slice(2), { 15 | boolean: ['version', 'help'], 16 | alias: { 17 | filter: 'f', 18 | output: 'o', 19 | concurrency: 'c', 20 | timeout: 't', 21 | version: 'v', 22 | help: 'h' 23 | }, 24 | default: { 25 | concurrency: defaults.concurrency, 26 | timeout: defaults.timeout, 27 | output: 'terminal' 28 | } 29 | }) 30 | 31 | if (argv.version) { 32 | console.log(pkg.version) 33 | process.exit() 34 | } 35 | 36 | const args = { 37 | name: argv._[0], 38 | version: argv._[1], 39 | nextVersion: argv._[2], 40 | filter: argv.filter && new RegExp(argv.filter), 41 | output: argv.output, 42 | concurrency: Number(argv.concurrency), 43 | timeout: 1000 * Number(argv.timeout) 44 | } 45 | 46 | if (!args.name || !args.version || argv.help) { 47 | console.log() 48 | console.log(' test-npm-dependants NAME STABLEVERSION [NEXTVERSION]') 49 | console.log() 50 | console.log(' Options:') 51 | console.log() 52 | console.log(' --help, -h Print help text') 53 | console.log(' --version, -v Print program version') 54 | console.log(' --filter, -f Filter dependant names by this regexp') 55 | console.log( 56 | ` --concurrency, -c Test concurrency [Default: ${defaults.concurrency}]` 57 | ) 58 | console.log( 59 | ` --timeout, -t Time out processes after x seconds [Default: ${defaults.timeout}]` 60 | ) 61 | console.log( 62 | ' --output, -o Output mode [terminal, verbose] [Default: terminal]' 63 | ) 64 | console.log() 65 | process.exit(Number(!argv.help)) 66 | } 67 | 68 | test(args).catch(err => { 69 | console.error(err) 70 | process.exit(1) 71 | }) 72 | -------------------------------------------------------------------------------- /images/express-alpha.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juliangruber/test-npm-dependants/b5631663ac88ed2ea5eacd4947338977aef879d8/images/express-alpha.png -------------------------------------------------------------------------------- /images/sandbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juliangruber/test-npm-dependants/b5631663ac88ed2ea5eacd4947338977aef879d8/images/sandbox.png -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const dependants = require('npm-dependants') 4 | const fetch = require('node-fetch') 5 | const semver = require('semver') 6 | const { tmpdir } = require('os') 7 | const { promises: fs } = require('fs') 8 | const { join } = require('path') 9 | const fetchPackageSource = require('fetch-package-source') 10 | const createRender = require('./lib/render') 11 | const differ = require('ansi-diff-stream') 12 | const run = require('./lib/run') 13 | const { ux } = require('@cto.ai/sdk') 14 | 15 | const removeDelay = 3000 16 | const cancel = (state, dependantState, text) => { 17 | dependantState.status = text 18 | setTimeout(() => { 19 | const idx = state.dependants.indexOf(dependantState) 20 | state.dependants.splice(idx, 1) 21 | }, removeDelay) 22 | } 23 | 24 | const test = async ({ 25 | name, 26 | version, 27 | nextVersion, 28 | filter, 29 | timeout, 30 | output, 31 | concurrency 32 | }) => { 33 | const root = { name, version } 34 | const iterator = dependants(root.name) 35 | 36 | const state = { 37 | ...root, 38 | nextVersion, 39 | dependants: [], 40 | start: new Date() 41 | } 42 | 43 | let iv, render, diff 44 | if (output === 'terminal') { 45 | render = createRender() 46 | diff = differ() 47 | diff.pipe(process.stdout) 48 | iv = setInterval(() => { 49 | diff.reset() 50 | diff.write(render(state)) 51 | }, 100) 52 | } else if (output === 'verbose') { 53 | concurrency = 1 54 | } 55 | const seen = new Set() 56 | 57 | await Promise.all( 58 | Array(concurrency) 59 | .fill() 60 | .map(async () => { 61 | for await (const dependant of iterator) { 62 | if (filter && !filter.test(dependant)) continue 63 | if (seen.has(dependant)) continue 64 | seen.add(dependant) 65 | const dependantState = { 66 | name: dependant, 67 | status: 'Loading package information', 68 | version: { loading: true, pass: false }, 69 | nextVersion: { loading: true, pass: false } 70 | } 71 | state.dependants.push(dependantState) 72 | 73 | const res = await fetch(`https://registry.npmjs.org/${dependant}`) 74 | const body = await res.json() 75 | const pkg = body.versions[body['dist-tags'].latest] 76 | if (!pkg.repository) { 77 | cancel(state, dependantState, 'No repository set') 78 | continue 79 | } 80 | const allDependencies = { 81 | ...pkg.devDependencies, 82 | ...pkg.dependencies 83 | } 84 | const range = allDependencies[root.name] 85 | if (!range || !semver.satisfies(root.version, range)) { 86 | cancel( 87 | state, 88 | dependantState, 89 | "Package not found in dependant's latest version" 90 | ) 91 | continue 92 | } 93 | 94 | if (output === 'verbose') { 95 | console.log(`test ${pkg.name}@${pkg.version}`) 96 | } 97 | const dir = join( 98 | tmpdir(), 99 | [ 100 | pkg.name.replace('/', '-'), 101 | pkg.version, 102 | Date.now(), 103 | Math.random() 104 | ].join('-') 105 | ) 106 | dependantState.status = 'Downloading package' 107 | await fs.mkdir(dir) 108 | try { 109 | await fetchPackageSource(pkg.repository.url, pkg.version, dir) 110 | } catch (err) { 111 | cancel(state, dependantState, err.code || err.message) 112 | continue 113 | } 114 | dependantState.status = 'Installing dependencies' 115 | try { 116 | await run('npm install', { 117 | cwd: dir, 118 | verbose: output === 'verbose', 119 | timeout 120 | }) 121 | } catch (_) { 122 | cancel(state, dependantState, 'Installation failed') 123 | continue 124 | } 125 | dependantState.status = `Installing ${root.name}@${root.version}` 126 | await run(`npm install ${root.name}@${root.version}`, { 127 | cwd: dir, 128 | verbose: output === 'verbose' 129 | }) 130 | dependantState.status = 'Running test suite' 131 | try { 132 | await run('npm test', { 133 | cwd: dir, 134 | verbose: output === 'verbose', 135 | timeout 136 | }) 137 | dependantState.version.pass = true 138 | dependantState.status = '' 139 | } catch (err) { 140 | dependantState.status = '' 141 | } 142 | dependantState.version.loading = false 143 | 144 | if (nextVersion) { 145 | dependantState.status = `Installing ${root.name}@${nextVersion}` 146 | await run(`npm install ${root.name}@${nextVersion}`, { 147 | cwd: dir, 148 | verbose: output === 'verbose' 149 | }) 150 | dependantState.status = 'Running test suite' 151 | try { 152 | await run('npm test', { 153 | cwd: dir, 154 | verbose: output === 'verbose', 155 | timeout 156 | }) 157 | dependantState.nextVersion.pass = true 158 | } catch (_) { 159 | if (!dependantState.version.pass) { 160 | cancel(state, dependantState, '') 161 | } 162 | } 163 | dependantState.nextVersion.loading = false 164 | if (dependantState.version.pass) { 165 | if (dependantState.nextVersion.pass) { 166 | dependantState.status = 'Passes' 167 | if (output === 'slack') { 168 | ux.print(`${pkg.name}@${pkg.version} still passes`) 169 | } 170 | } else { 171 | dependantState.status = 'Breaks' 172 | if (output === 'slack') { 173 | ux.print(`${pkg.name}@${pkg.version} breaks`) 174 | } 175 | } 176 | } else { 177 | if (dependantState.nextVersion.pass) { 178 | dependantState.status = 'Fixed' 179 | if (output === 'slack') { 180 | ux.print(`${pkg.name}@${pkg.version} was fixed`) 181 | } 182 | } 183 | } 184 | } 185 | } 186 | }) 187 | ) 188 | 189 | if (!seen.size) { 190 | state.error = `${root.name} has no dependants` 191 | if (output === 'terminal') { 192 | diff.reset() 193 | diff.write(render(state)) 194 | } 195 | } 196 | if (output === 'terminal') clearInterval(iv) 197 | } 198 | 199 | module.exports = test 200 | -------------------------------------------------------------------------------- /lib/render.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const spinners = require('cli-spinners') 4 | const chalk = require('chalk') 5 | const ms = require('ms') 6 | 7 | const check = (spinner, version) => { 8 | if (version.loading) return chalk.gray(spinner) 9 | if (version.pass) return chalk.green('✓') 10 | return chalk.red('×') 11 | } 12 | 13 | module.exports = () => { 14 | let frameIdx = 0 15 | 16 | return state => { 17 | const spinner = spinners.dots.frames[frameIdx] 18 | frameIdx = (frameIdx + 1) % spinners.dots.frames.length 19 | 20 | // head 21 | let out = `\n ${chalk.bold.gray('test')}` 22 | out += ` ${chalk.bold.white(state.name)}` 23 | out += ` ${chalk.gray('dependants')}\n\n` 24 | 25 | // thead 26 | out += chalk.gray(` stable: ${chalk.white(state.version)}\n`) 27 | if (state.nextVersion) { 28 | out += chalk.gray(` next: ${chalk.white(state.nextVersion)}\n`) 29 | } 30 | out += chalk.gray(` time: ${chalk.white(ms(new Date() - state.start))}\n`) 31 | out += '\n' 32 | 33 | // tbody 34 | for (const dependant of state.dependants) { 35 | if (!state.nextVersion) out += ' ' 36 | out += ` ${check(spinner, dependant.version)}` 37 | if (state.nextVersion) out += ` ${check(spinner, dependant.nextVersion)}` 38 | out += ` ${dependant.name}` 39 | if (dependant.status) out += ` ${chalk.gray(dependant.status)}` 40 | out += '\n' 41 | } 42 | 43 | // tfoot 44 | if (state.error) { 45 | out += chalk.red(` ${state.error}\n`) 46 | } 47 | 48 | return out 49 | } 50 | } 51 | 52 | if (!module.parent) { 53 | process.stdout.write( 54 | module.exports()({ 55 | name: 'express', 56 | version: '4.0.0', 57 | nextVersion: '5.0.0', 58 | start: new Date() - 1000, 59 | dependants: [ 60 | { 61 | name: 'webpack-dev-server', 62 | status: 'Loading package information', 63 | version: { 64 | pass: true, 65 | loading: false 66 | }, 67 | nextVersion: { 68 | pass: false, 69 | loading: true 70 | } 71 | }, 72 | { 73 | name: 'hubot', 74 | status: 'Installing dependencies', 75 | version: { 76 | pass: false, 77 | loading: true 78 | }, 79 | nextVersion: { 80 | pass: false, 81 | loading: true 82 | } 83 | } 84 | ] 85 | }) 86 | ) 87 | } 88 | -------------------------------------------------------------------------------- /lib/run.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const execa = require('execa') 4 | 5 | const run = async (cmd, { cwd, verbose, timeout }) => { 6 | if (verbose) console.log(cmd) 7 | const [program, ...args] = cmd.split(' ') 8 | const ps = execa(program, args, { 9 | cwd, 10 | env: { NODE_ENV: 'development' } 11 | }) 12 | if (verbose) { 13 | ps.stdout.pipe(process.stdout) 14 | ps.stderr.pipe(process.stderr) 15 | } 16 | 17 | let handle 18 | if (timeout) { 19 | handle = setTimeout(() => { 20 | ps.kill('SIGTERM', { 21 | forceKillAfterTimeout: 2000 22 | }) 23 | }, timeout) 24 | } 25 | 26 | try { 27 | await ps 28 | } catch (err) { 29 | if (ps.killed) throw new Error('Timed out') 30 | throw new Error('Non-zero exit') 31 | } finally { 32 | if (handle) clearTimeout(handle) 33 | } 34 | } 35 | 36 | module.exports = run 37 | -------------------------------------------------------------------------------- /ops.yml: -------------------------------------------------------------------------------- 1 | # For more info see https://cto.ai/docs/ops-reference 2 | version: '1' 3 | commands: 4 | - name: test-npm-dependants:1.1.1 5 | description: Run the test suites of all modules depending on a given module. 6 | public: true 7 | sdk: '2' 8 | sourceCodeURL: 'https://github.com/juliangruber/test-npm-dependants' 9 | run: node /ops/bin/ops.js 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-npm-dependants", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "repository": "juliangruber/test-npm-dependants", 6 | "description": "Run the test suites of all modules depending on a given module", 7 | "bin": { 8 | "test-npm-dependants": "bin/test-npm-dependants.js" 9 | }, 10 | "scripts": { 11 | "release": "np", 12 | "test": "prettier-standard && standard" 13 | }, 14 | "dependencies": { 15 | "@cto.ai/sdk": "^2.0.3", 16 | "ansi-diff-stream": "^1.2.1", 17 | "chalk": "^4.0.0", 18 | "cli-spinners": "^2.2.0", 19 | "execa": "^4.0.0", 20 | "fetch-package-source": "^1.0.0", 21 | "github-url-to-object": "^4.0.4", 22 | "minimist": "^1.2.0", 23 | "ms": "^2.1.2", 24 | "node-fetch": "^2.6.0", 25 | "npm-dependants": "^2.1.0", 26 | "semver": "^7.1.1" 27 | }, 28 | "devDependencies": { 29 | "np": "^6.1.0", 30 | "prettier-standard": "^16.4.1", 31 | "standard": "^14.3.1" 32 | } 33 | } 34 | --------------------------------------------------------------------------------