├── test ├── tap-parallel-ok ├── serial │ ├── tap-parallel-not-ok │ ├── run.test.js │ └── autocannon.test.js ├── j5.jpeg ├── targetProcess.js ├── format.test.js ├── debug.test.js ├── forever.test.js ├── cli.test.js ├── basic-auth.test.js ├── onPort.test.js ├── runRate.test.js ├── cert.pem ├── progressTracker.test.stub.js ├── key.pem ├── envPort.test.js ├── runAmount.test.js ├── cli-ipc.test.js ├── argumentParsing.test.js ├── httpRequestBuilder.test.js ├── helper.js ├── runMultipart.test.js ├── requestIterator.test.js ├── httpClient.test.js └── run.test.js ├── demo.gif ├── .github ├── release-drafter.yml ├── tests_checker.yml └── workflows │ └── nodejs.yml ├── autocannon-banner.png ├── autocannon-logo-hire.png ├── for-zero-x.js ├── lib ├── format.js ├── httpMethods.js ├── preload │ └── autocannonDetectPort.js ├── defaultOptions.js ├── multipart.js ├── requestIterator.js ├── httpRequestBuilder.js ├── httpClient.js ├── progressTracker.js └── run.js ├── server.js ├── samples ├── track-run.js ├── customise-individual-connection.js ├── using-id-replacement.js ├── modifying-request.js └── requests-sample.js ├── .gitignore ├── .npmignore ├── LICENSE ├── package.json ├── help.txt ├── autocannon.js └── README.md /test/tap-parallel-ok: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/serial/tap-parallel-not-ok: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phonbopit/autocannon/master/demo.gif -------------------------------------------------------------------------------- /test/j5.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phonbopit/autocannon/master/test/j5.jpeg -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | template: | 2 | ## What’s Changed 3 | 4 | $CHANGES 5 | -------------------------------------------------------------------------------- /test/targetProcess.js: -------------------------------------------------------------------------------- 1 | const server = require('./helper').startServer() 2 | 3 | server.ref() 4 | -------------------------------------------------------------------------------- /autocannon-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phonbopit/autocannon/master/autocannon-banner.png -------------------------------------------------------------------------------- /autocannon-logo-hire.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phonbopit/autocannon/master/autocannon-logo-hire.png -------------------------------------------------------------------------------- /for-zero-x.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const autocannon = require('.') 4 | 5 | autocannon({ 6 | url: 'http://localhost:3000', 7 | connections: 10, 8 | duration: 10 9 | }, console.log) 10 | -------------------------------------------------------------------------------- /.github/tests_checker.yml: -------------------------------------------------------------------------------- 1 | comment: 'Could you please add tests to make sure this change works as expected?', 2 | fileExtensions: ['.php', '.ts', '.js', '.c', '.cs', '.cpp', '.rb', '.java'] 3 | testDir: 'test' 4 | -------------------------------------------------------------------------------- /lib/format.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | function format (num) { 4 | if (num < 1000) { 5 | return '' + num 6 | } else { 7 | return '' + Math.round(num / 1000) + 'k' 8 | } 9 | } 10 | 11 | module.exports = format 12 | -------------------------------------------------------------------------------- /lib/httpMethods.js: -------------------------------------------------------------------------------- 1 | // most http methods taken from RFC 7231 2 | // PATCH is defined in RFC 5789 3 | module.exports = [ 4 | 'GET', 5 | 'HEAD', 6 | 'POST', 7 | 'PUT', 8 | 'DELETE', 9 | 'CONNECT', 10 | 'OPTIONS', 11 | 'TRACE', 12 | 'PATCH' 13 | ] 14 | -------------------------------------------------------------------------------- /lib/preload/autocannonDetectPort.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const onListen = require('on-net-listen') 4 | const net = require('net') 5 | 6 | const socket = net.connect(process.env.AUTOCANNON_SOCKET) 7 | 8 | onListen(function (addr) { 9 | this.destroy() 10 | const port = Buffer.from(addr.port + '') 11 | socket.write(port) 12 | }) 13 | 14 | socket.unref() 15 | -------------------------------------------------------------------------------- /test/format.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('tap').test 4 | const format = require('../lib/format') 5 | 6 | const pairs = { 7 | 2: 2, 8 | '2k': 2000, 9 | '4k': 4042, 10 | '2300k': 2300000 11 | } 12 | 13 | Object.keys(pairs).forEach((expected) => { 14 | const original = pairs[expected] 15 | test(`format ${original} into ${expected}`, (t) => { 16 | t.equal(expected, format(original)) 17 | t.end() 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /test/serial/run.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const test = require('tap').test 3 | const run = require('../../lib/run') 4 | 5 | test('should log error on connection error', t => { 6 | t.plan(1) 7 | console.error = function (obj) { 8 | t.type(obj, Error) 9 | console.error = () => {} 10 | } 11 | run({ 12 | url: 'http://unknownhost', 13 | connections: 2, 14 | duration: 5, 15 | title: 'title321', 16 | debug: true 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /lib/defaultOptions.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const defaultOptions = { 4 | headers: {}, 5 | body: Buffer.alloc(0), 6 | method: 'GET', 7 | duration: 10, 8 | connections: 10, 9 | pipelining: 1, 10 | timeout: 10, 11 | maxConnectionRequests: 0, 12 | maxOverallRequests: 0, 13 | connectionRate: 0, 14 | overallRate: 0, 15 | amount: 0, 16 | reconnectRate: 0, 17 | forever: false, 18 | idReplacement: false, 19 | requests: [{}], 20 | servername: undefined, 21 | excludeErrorStats: false 22 | } 23 | 24 | module.exports = defaultOptions 25 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const http = require('http') 4 | const https = require('https') 5 | const fs = require('fs') 6 | const path = require('path') 7 | 8 | const options = { 9 | key: fs.readFileSync(path.join(__dirname, 'test', '/key.pem')), 10 | cert: fs.readFileSync(path.join(__dirname, 'test', '/cert.pem')), 11 | passphrase: 'test' 12 | } 13 | const server = http.createServer(handle) 14 | const server2 = https.createServer(options, handle) 15 | 16 | server.listen(3000) 17 | server2.listen(3001) 18 | 19 | function handle (req, res) { 20 | res.end('hello world') 21 | } 22 | -------------------------------------------------------------------------------- /test/debug.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('tap').test 4 | const Autocannon = require('../autocannon') 5 | 6 | test('debug works', (t) => { 7 | t.plan(5) 8 | 9 | var args = Autocannon.parseArguments([ 10 | '-H', 'X-Http-Method-Override=GET', 11 | '-m', 'POST', 12 | '-b', 'the body', 13 | '--debug', 14 | 'http://localhost/foo/bar' 15 | ]) 16 | 17 | t.equal(args.url, 'http://localhost/foo/bar') 18 | t.strictSame(args.headers, { 'X-Http-Method-Override': 'GET' }) 19 | t.equal(args.method, 'POST') 20 | t.equal(args.body, 'the body') 21 | t.equal(args.debug, true) 22 | }) 23 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: [ push, pull_request ] 4 | 5 | jobs: 6 | test: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | os: [ubuntu-latest, windows-latest, macos-latest] 11 | node-version: [8.x, 10.x, 11.x, 12.x, 13.x] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - run: node --version 20 | - run: npm --version 21 | - run: npm install 22 | - run: npm test 23 | env: 24 | CI: true 25 | -------------------------------------------------------------------------------- /samples/track-run.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const http = require('http') 4 | const autocannon = require('autocannon') 5 | 6 | const server = http.createServer(handle) 7 | 8 | server.listen(0, startBench) 9 | 10 | function handle (req, res) { 11 | res.end('hello world') 12 | } 13 | 14 | function startBench () { 15 | const instance = autocannon({ 16 | url: 'http://localhost:' + server.address().port 17 | }, finishedBench) 18 | 19 | autocannon.track(instance) 20 | 21 | // this is used to kill the instance on CTRL-C 22 | process.once('SIGINT', () => { 23 | instance.stop() 24 | }) 25 | 26 | function finishedBench (err, res) { 27 | console.log('finished bench', err, res) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Dependency lock 30 | package-lock.json 31 | 32 | # Optional npm cache directory 33 | .npm 34 | 35 | .idea 36 | 37 | # Optional REPL history 38 | .node_repl_history 39 | 40 | profile-* 41 | 42 | .nyc_output 43 | -------------------------------------------------------------------------------- /samples/customise-individual-connection.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const http = require('http') 4 | const autocannon = require('autocannon') 5 | 6 | const server = http.createServer(handle) 7 | 8 | server.listen(0, startBench) 9 | 10 | function handle (req, res) { 11 | res.end('hello world') 12 | } 13 | 14 | function startBench () { 15 | const url = 'http://localhost:' + server.address().port 16 | 17 | autocannon({ 18 | url: url, 19 | connections: 1000, 20 | duration: 10, 21 | setupClient: setupClient 22 | }, finishedBench) 23 | 24 | let connection = 0 25 | 26 | function setupClient (client) { 27 | client.setBody('connection number', connection++) 28 | } 29 | 30 | function finishedBench (err, res) { 31 | console.log('finished bench', err, res) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | 35 | profile-* 36 | 37 | demo.gif 38 | server.js 39 | *.png 40 | appveyor.yml 41 | .travis.yml 42 | .github 43 | .nyc_output 44 | -------------------------------------------------------------------------------- /samples/using-id-replacement.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const http = require('http') 4 | const autocannon = require('autocannon') 5 | 6 | const server = http.createServer(handle) 7 | 8 | server.listen(0, startBench) 9 | 10 | function handle (req, res) { 11 | res.end('hello world') 12 | } 13 | 14 | function startBench () { 15 | const url = 'http://localhost:' + server.address().port 16 | 17 | autocannon({ 18 | url: url, 19 | connections: 1000, 20 | duration: 10, 21 | requests: [ 22 | { 23 | method: 'POST', 24 | path: '/register', 25 | headers: { 26 | 'Content-type': 'application/json; charset=utf-8' 27 | }, 28 | body: JSON.stringify({ 29 | name: 'New User', 30 | email: 'new-[]@user.com' // [] will be replaced with generated HyperID at run time 31 | }) 32 | } 33 | ], 34 | idReplacement: true 35 | }, finishedBench) 36 | 37 | function finishedBench (err, res) { 38 | console.log('finished bench', err, res) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Matteo Collina 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 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, 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /samples/modifying-request.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const http = require('http') 4 | const autocannon = require('autocannon') 5 | 6 | const server = http.createServer(handle) 7 | 8 | server.listen(0, startBench) 9 | 10 | function handle (req, res) { 11 | res.end('hello world') 12 | } 13 | 14 | function startBench () { 15 | const url = 'http://localhost:' + server.address().port 16 | 17 | const instance = autocannon({ 18 | url: url, 19 | connections: 1000, 20 | duration: 10 21 | }, finishedBench) 22 | 23 | let message = 0 24 | // modify the body on future requests 25 | instance.on('response', function (client, statusCode, returnBytes, responseTime) { 26 | client.setBody('message ' + message++) 27 | }) 28 | 29 | let headers = 0 30 | // modify the headers on future requests 31 | // this wipes any existing headers out with the new ones 32 | instance.on('response', function (client, statusCode, returnBytes, responseTime) { 33 | var newHeaders = {} 34 | newHeaders[`header${headers++}`] = `headerValue${headers++}` 35 | client.setHeaders(newHeaders) 36 | }) 37 | 38 | function finishedBench (err, res) { 39 | console.log('finished bench', err, res) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test/forever.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('tap').test 4 | const run = require('../lib/run') 5 | const helper = require('./helper') 6 | const server = helper.startServer() 7 | 8 | test('running with forever set to true and passing in a callback should cause an error to be returned in the callback', (t) => { 9 | t.plan(2) 10 | 11 | run({ 12 | url: `http://localhost:${server.address().port}`, 13 | forever: true 14 | }, (err, res) => { 15 | t.ok(err, 'should be error when callback passed to run') 16 | t.notOk(res, 'should not exist') 17 | t.end() 18 | }) 19 | }) 20 | 21 | test('run forever should run until .stop() is called', (t) => { 22 | t.plan(3) 23 | let numRuns = 0 24 | 25 | const instance = run({ 26 | url: `http://localhost:${server.address().port}`, 27 | duration: 0.5, 28 | forever: true 29 | }) 30 | 31 | instance.on('done', (results) => { 32 | t.ok(results, 'should have gotten results') 33 | if (++numRuns === 2) { 34 | instance.stop() 35 | setTimeout(() => { 36 | t.ok(true, 'should have reached here without the callback being called again') 37 | t.end() 38 | }, 1000) 39 | } 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /test/cli.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const t = require('tap') 4 | const split = require('split2') 5 | const path = require('path') 6 | const childProcess = require('child_process') 7 | const helper = require('./helper') 8 | 9 | const lines = [ 10 | /Running 1s test @ .*$/, 11 | /10 connections.*$/, 12 | /$/, 13 | /.*/, 14 | /Stat.*2\.5%.*50%.*97\.5%.*99%.*Avg.*Stdev.*Max.*$/, 15 | /.*/, 16 | /Latency.*$/, 17 | /$/, 18 | /.*/, 19 | /Stat.*1%.*2\.5%.*50%.*97\.5%.*Avg.*Stdev.*Min.*$/, 20 | /.*/, 21 | /Req\/Sec.*$/, 22 | /.*/, 23 | /Bytes\/Sec.*$/, 24 | /.*/, 25 | /$/, 26 | /Req\/Bytes counts sampled once per second.*$/, 27 | /$/, 28 | /.* requests in ([0-9]|\.)+s, .* read/ 29 | ] 30 | 31 | t.plan(lines.length * 2) 32 | 33 | const server = helper.startServer() 34 | const url = 'http://localhost:' + server.address().port 35 | 36 | const child = childProcess.spawn(process.execPath, [path.join(__dirname, '..'), '-d', '1', url], { 37 | cwd: __dirname, 38 | env: process.env, 39 | stdio: ['ignore', 'pipe', 'pipe'], 40 | detached: false 41 | }) 42 | 43 | t.tearDown(() => { 44 | child.kill() 45 | }) 46 | 47 | child 48 | .stderr 49 | .pipe(split()) 50 | .on('data', (line) => { 51 | const regexp = lines.shift() 52 | t.ok(regexp, 'we are expecting this line') 53 | t.ok(regexp.test(line), 'line matches ' + regexp) 54 | }) 55 | -------------------------------------------------------------------------------- /test/basic-auth.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const t = require('tap') 4 | const split = require('split2') 5 | const path = require('path') 6 | const childProcess = require('child_process') 7 | const helper = require('./helper') 8 | 9 | const lines = [ 10 | /Running 1s test @ .*$/, 11 | /10 connections.*$/, 12 | /$/, 13 | /.*/, 14 | /Stat.*2\.5%.*50%.*97\.5%.*99%.*Avg.*Stdev.*Max.*$/, 15 | /.*/, 16 | /Latency.*$/, 17 | /$/, 18 | /.*/, 19 | /Stat.*1%.*2\.5%.*50%.*97\.5%.*Avg.*Stdev.*Min.*$/, 20 | /.*/, 21 | /Req\/Sec.*$/, 22 | /.*/, 23 | /Bytes\/Sec.*$/, 24 | /.*/, 25 | /$/, 26 | /Req\/Bytes counts sampled once per second.*$/, 27 | /$/, 28 | /.* requests in ([0-9]|\.)+s, .* read/ 29 | ] 30 | 31 | t.plan(lines.length * 2) 32 | 33 | const server = helper.startBasicAuthServer() 34 | const url = 'http://foo:bar@localhost:' + server.address().port 35 | 36 | const child = childProcess.spawn(process.execPath, [path.join(__dirname, '..'), '-d', '1', url], { 37 | cwd: __dirname, 38 | env: process.env, 39 | stdio: ['ignore', 'pipe', 'pipe'], 40 | detached: false 41 | }) 42 | 43 | t.tearDown(() => { 44 | child.kill() 45 | }) 46 | 47 | child 48 | .stderr 49 | .pipe(split()) 50 | .on('data', (line) => { 51 | const regexp = lines.shift() 52 | t.ok(regexp, 'we are expecting this line') 53 | t.ok(regexp.test(line), 'line matches ' + regexp) 54 | }) 55 | -------------------------------------------------------------------------------- /test/onPort.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | const { test } = require('tap') 5 | const spawn = require('child_process').spawn 6 | const split = require('split2') 7 | const hasAsyncHooks = require('has-async-hooks') 8 | 9 | test('--on-port flag', { skip: !hasAsyncHooks() }, (t) => { 10 | const lines = [ 11 | /Running 1s test @ .*$/, 12 | /10 connections.*$/, 13 | /$/, 14 | /.*/, 15 | /Stat.*2\.5%.*50%.*97\.5%.*99%.*Avg.*Stdev.*Max.*$/, 16 | /.*/, 17 | /Latency.*$/, 18 | /$/, 19 | /.*/, 20 | /Stat.*1%.*2\.5%.*50%.*97\.5%.*Avg.*Stdev.*Min.*$/, 21 | /.*/, 22 | /Req\/Sec.*$/, 23 | /$/, 24 | /Bytes\/Sec.*$/, 25 | /.*/, 26 | /$/, 27 | /Req\/Bytes counts sampled once per second.*$/, 28 | /$/, 29 | /.* requests in ([0-9]|\.)+s, .* read/ 30 | ] 31 | 32 | t.plan(lines.length * 2) 33 | 34 | const child = spawn(process.execPath, [ 35 | path.join(__dirname, '..'), 36 | '-c', '10', 37 | '-d', '1', 38 | '--on-port', '/', 39 | '--', 'node', path.join(__dirname, './targetProcess') 40 | ], { 41 | cwd: __dirname, 42 | env: process.env, 43 | stdio: ['ignore', 'pipe', 'pipe'], 44 | detached: false 45 | }) 46 | 47 | t.tearDown(() => { 48 | child.kill() 49 | }) 50 | 51 | child 52 | .stderr 53 | .pipe(split()) 54 | .on('data', (line) => { 55 | const regexp = lines.shift() 56 | t.ok(regexp, 'we are expecting this line') 57 | t.ok(regexp.test(line), 'line matches ' + regexp) 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /test/runRate.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('tap').test 4 | const run = require('../lib/run') 5 | const helper = require('./helper') 6 | const server = helper.startServer() 7 | 8 | test('run should only send the expected number of requests per second', (t) => { 9 | t.plan(9) 10 | 11 | run({ 12 | url: `http://localhost:${server.address().port}`, 13 | connections: 2, 14 | overallRate: 10, 15 | amount: 40 16 | }, (err, res) => { 17 | t.error(err) 18 | t.equal(Math.round(res.duration), 4, 'should have take 4 seconds to send 10 requests per seconds') 19 | t.equal(res.requests.average, 10, 'should have sent 10 requests per second on average') 20 | }) 21 | 22 | run({ 23 | url: `http://localhost:${server.address().port}`, 24 | connections: 2, 25 | connectionRate: 10, 26 | amount: 40 27 | }, (err, res) => { 28 | t.error(err) 29 | t.equal(Math.round(res.duration), 2, 'should have taken 2 seconds to send 10 requests per connection with 2 connections') 30 | t.equal(res.requests.average, 20, 'should have sent 20 requests per second on average with two connections') 31 | }) 32 | 33 | run({ 34 | url: `http://localhost:${server.address().port}`, 35 | connections: 15, 36 | overallRate: 10, 37 | amount: 40 38 | }, (err, res) => { 39 | t.error(err) 40 | t.equal(Math.round(res.duration), 4, 'should have take 4 seconds to send 10 requests per seconds') 41 | t.equal(res.requests.average, 10, 'should have sent 10 requests per second on average') 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /test/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIEVzCCAz+gAwIBAgIJAJn02lrTTFjxMA0GCSqGSIb3DQEBBQUAMHoxCzAJBgNV 3 | BAYTAklFMRIwEAYDVQQIEwl3YXRlcmZvcmQxDTALBgNVBAcTBHRlc3QxDTALBgNV 4 | BAoTBHRlc3QxDTALBgNVBAsTBHRlc3QxDTALBgNVBAMTBHRlc3QxGzAZBgkqhkiG 5 | 9w0BCQEWDHRlc0B0ZXN0LmNvbTAeFw0xNjA2MjcxNDE4MDdaFw0xOTAzMjMxNDE4 6 | MDdaMHoxCzAJBgNVBAYTAklFMRIwEAYDVQQIEwl3YXRlcmZvcmQxDTALBgNVBAcT 7 | BHRlc3QxDTALBgNVBAoTBHRlc3QxDTALBgNVBAsTBHRlc3QxDTALBgNVBAMTBHRl 8 | c3QxGzAZBgkqhkiG9w0BCQEWDHRlc0B0ZXN0LmNvbTCCASIwDQYJKoZIhvcNAQEB 9 | BQADggEPADCCAQoCggEBAK0PhkEuPPEQNc1/96rapuEazVaa5p74QAn4PNOPIKaz 10 | XWyLheBF78N320w6jB4eqAe3o6XMtt28iK+q+HejLZt7v+m6c7lHDtfcLSG8CEJ3 11 | dfwR/iOfCLRlDeZyWvxouf9/s3FSAM5VqKb9kmc/Pt2+opWlX1cZvdfkg/lzSHUu 12 | FwmuxOAONKt2dPiEvDSiSs99Kv0+jSgMmy+4D8LGyvxFCQu67bh6a2zGEEYAcAib 13 | Rpw+Fb/AK8VYPW528SaWHRT7CcDgzdXaMfos3EWOQ/Cc0Q+MgqVfSmqTEUPXAc41 14 | Y4Lvvl5GSHQ4lve3jIR05xenxcMIZ8BP7fJ3BfjXCxsCAwEAAaOB3zCB3DAdBgNV 15 | HQ4EFgQUYtl9YCe7XZ4F0MvA627f+BOJoVYwgawGA1UdIwSBpDCBoYAUYtl9YCe7 16 | XZ4F0MvA627f+BOJoVahfqR8MHoxCzAJBgNVBAYTAklFMRIwEAYDVQQIEwl3YXRl 17 | cmZvcmQxDTALBgNVBAcTBHRlc3QxDTALBgNVBAoTBHRlc3QxDTALBgNVBAsTBHRl 18 | c3QxDTALBgNVBAMTBHRlc3QxGzAZBgkqhkiG9w0BCQEWDHRlc0B0ZXN0LmNvbYIJ 19 | AJn02lrTTFjxMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAJN7pnlv 20 | SascD2V0I+9wirpuuNnfUP5YCFaAjky4DREV+DRPXEL2tzTQqsEeFts8BHBH+Jz3 21 | ytYP5NZSjuToF9czu8v3+mPCSqzdOFKruJbl/lAokLJWan8Z3qfXWZQL79C2I7Ih 22 | hSBnH/O+jZz9FPRJ2ydR8DB0LdGVKkQFvZynPZOh7D4NKvrEgFad4p6EBFshO+8N 23 | 1ALfR/2mrJOkBHfHPVWMmy6DoXWyVijPuLaa+l2TzdQJycl6CAJw6F7tPoO75qKY 24 | MAcIKOW5F9Zv7I3aqmoLDOaOh43FeT2JLvODe2TIaytWckoFesGadEgvAzCAXC4r 25 | ArqQX2nVUdasOnQ= 26 | -----END CERTIFICATE----- 27 | -------------------------------------------------------------------------------- /lib/multipart.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { resolve, basename } = require('path') 4 | const { readFileSync } = require('fs') 5 | const FormData = require('form-data') 6 | 7 | function getFormData (string) { 8 | try { 9 | return JSON.parse(string) 10 | } catch (error) { 11 | try { 12 | const path = resolve(string) 13 | const data = readFileSync(path, 'utf8') 14 | return JSON.parse(data) 15 | } catch (error) { 16 | throw new Error('Invalid JSON or file where to get form data') 17 | } 18 | } 19 | } 20 | 21 | module.exports = (options) => { 22 | const obj = typeof options === 'string' ? getFormData(options) : options 23 | const form = new FormData() 24 | for (const key in obj) { 25 | const type = obj[key] && obj[key].type 26 | switch (type) { 27 | case 'file': { 28 | const path = obj[key] && obj[key].path 29 | if (!path) { 30 | throw new Error(`Missing key 'path' in form object for key '${key}'`) 31 | } 32 | const opts = obj[key] && obj[key].options 33 | const buffer = readFileSync(path) 34 | form.append(key, buffer, Object.assign({}, { 35 | filename: basename(path) 36 | }, opts)) 37 | break 38 | } 39 | case 'text': { 40 | const value = obj[key] && obj[key].value 41 | if (!value) { 42 | throw new Error(`Missing key 'value' in form object for key '${key}'`) 43 | } 44 | form.append(key, value) 45 | break 46 | } 47 | default: 48 | throw new Error('A \'type\' key with value \'text\' or \'file\' should be specified') 49 | } 50 | } 51 | return form 52 | } 53 | -------------------------------------------------------------------------------- /samples/requests-sample.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const http = require('http') 4 | const autocannon = require('autocannon') 5 | 6 | const server = http.createServer(handle) 7 | 8 | server.listen(0, startBench) 9 | 10 | function handle (req, res) { 11 | res.end('hello world') 12 | } 13 | 14 | function startBench () { 15 | const url = 'http://localhost:' + server.address().port 16 | 17 | autocannon({ 18 | url: url, 19 | connections: 1000, 20 | duration: 10, 21 | headers: { 22 | // by default we add an auth token to all requests 23 | auth: 'A Pregenerated auth token' 24 | }, 25 | requests: [ 26 | { 27 | method: 'POST', // this should be a post for logging in 28 | path: '/login', 29 | body: 'valid login details', 30 | // overwrite our default headers, 31 | // so we don't add an auth token 32 | // for this request 33 | headers: {} 34 | }, 35 | { 36 | path: '/mySecretDetails' 37 | // this will automatically add the pregenerated auth token 38 | }, 39 | { 40 | method: 'GET', // this should be a put for modifying secret details 41 | path: '/mySecretDetails', 42 | headers: { // let submit some json? 43 | 'Content-type': 'application/json; charset=utf-8' 44 | }, 45 | // we need to stringify the json first 46 | body: JSON.stringify({ 47 | name: 'my new name' 48 | }), 49 | setupRequest: reqData => { 50 | reqData.method = 'PUT' // we are overriding the method 'GET' to 'PUT' here 51 | return reqData 52 | } 53 | } 54 | ] 55 | }, finishedBench) 56 | 57 | function finishedBench (err, res) { 58 | console.log('finished bench', err, res) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /test/progressTracker.test.stub.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const helper = require('./helper') 4 | const test = require('tap').test 5 | const progressTracker = require('../lib/progressTracker') 6 | const autocannon = require('../autocannon') 7 | test('progress tracker should throw if no instance is provided', t => { 8 | t.plan(1) 9 | try { 10 | progressTracker(null, {}) 11 | } catch (error) { 12 | t.same(error.message, 'instance required for tracking') 13 | } 14 | }) 15 | 16 | test('should work', t => { 17 | const server = helper.startServer({ statusCode: 404 }) 18 | const instance = autocannon({ 19 | url: `http://localhost:${server.address().port}`, 20 | pipelining: 2 21 | }, console.log) 22 | 23 | setTimeout(() => { 24 | instance.stop() 25 | t.end() 26 | }, 2000) 27 | 28 | autocannon.track(instance, { 29 | renderProgressBar: true, 30 | renderLatencyTable: true 31 | }) 32 | }) 33 | 34 | test('should work with amount', t => { 35 | const server = helper.startServer() 36 | const instance = autocannon({ 37 | url: `http://localhost:${server.address().port}`, 38 | pipelining: 1, 39 | amount: 10 40 | }, process.stdout) 41 | 42 | setTimeout(() => { 43 | instance.stop() 44 | t.end() 45 | }, 2000) 46 | autocannon.track(instance, { 47 | renderProgressBar: true 48 | }) 49 | t.pass() 50 | }) 51 | 52 | test('should log mismatches', t => { 53 | const server = helper.startServer() 54 | const instance = autocannon({ 55 | url: `http://localhost:${server.address().port}`, 56 | pipelining: 1, 57 | amount: 10, 58 | expectBody: 'modified' 59 | }, console.log) 60 | 61 | setTimeout(() => { 62 | instance.stop() 63 | t.end() 64 | }, 2000) 65 | autocannon.track(instance, { 66 | renderProgressBar: true 67 | }) 68 | t.pass() 69 | }) 70 | -------------------------------------------------------------------------------- /test/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | Proc-Type: 4,ENCRYPTED 3 | DEK-Info: DES-EDE3-CBC,081E2A4DFAF06358 4 | 5 | yfmHgUwn2RV+CGBYKJMBfLu+EQCKIOprjUxSBPaytTwc6NsOeVjOb/iAcVvr0D6B 6 | a6Kc8BrIMfVVjABghufAnJXav+aB6Q/LkfaqJp6AfVI86WMaCBb0Fzkg8IYYSbgy 7 | JiskTYmV1IrjYfR6TEtQmeFP0DMh1RU9VLIfehEFRMU7LcDh5Ah3bsZE5muBSlws 8 | 98IVBwEwEXoQdVxLJmz+kR3fEjXYmgNZQzP6GFb3jQENV3XijQffgQ4ajREB5EZL 9 | Zag+K0vwbFZORqWNfI5VFVVWOEErRF0BSDFZWJP8jbiSX4tNyEFRzO2dgdjGycqt 10 | 3dtWbofY28Hjf36P3ee2mMHe3V8XZUtCtfufiYFLKHedHShg7FolfSSMmIfNmSgE 11 | jCnTFNU4Fsq8I/q6ywfVh3CP/rjEe/I4GAU7wql0PW7LXjAHqRjLshJ09mX5J3w8 12 | +tvsrYiBLsgmtdnX47LDJDyeYZY5gbBaz9XsNd432z1M6lRILsT48RYwXEz5oHBE 13 | El1oKNko/PHljDkTph/xI1jUlkYJ7w9//stao/9VLzn0XQG839+lz1MNi/opmpUi 14 | 3P9Z3+yTbdwibRHLmgCR6PNE4EJdfv0VNantsxiSATyhIS/xPGhU4lich8gMNDcv 15 | 9IcUSCTkmVGy8ksJITt/m7mespQ8Y1oD3xvXoaCbTJ8aig6EQ5flYwmmiIHrWSol 16 | 5w5iKjkNbdNYftAg+RB6+EwrNz43oHLYRMB3dckYc1L+otd/X+lmlTlqOo8WDgh1 17 | cH4MI4cwjdYmFdp5Nhfd9/nWFtayqcj59IBgEAMzafbzcmhma6oJD9GsxSnw9eRY 18 | vb3b7fhtcqeKDgqfN4TklX/7kkoLpRVMEdW4uNyU0US1QC5gTxiFRW3/XGhaQJ6o 19 | quE2UP3qySp7kHsz++P6mya2xF2JNcKh1zxxF+wSnjymUxaWXz1LKGQs4iOjU8Xg 20 | HkPD3wTxtMXXZw/0LGMu7XLMFWMV3nuMGtt/I/GGtCbKbFkDOOI4imZWpmsvvO0s 21 | liMhhtCwJTqkYvlxPtfcLSKFJrwcCeb2xHcLEDAaRa5xUbUt3P60l7Ujyv3p+bVi 22 | sItGE6Mk8kGXglmZGBA0RIGhXlX7r6pNryqMUy0CU5/YniJFCKoaU9WOG8VwHynY 23 | psqvZ1umAtl4twe7SxF0znLdOGiYsOOQls5wcz1rDfj5IXRcbrWEeLwYD9b0oXms 24 | TvgkcfHlutZ+M+NtP7wGFBen1X68HsYBhvhLrVbox5bgMZVrXCN4+HiBO4VAwq+m 25 | c2akVF9kQfoR5iGMhzzgskH8c7TWuF7IDqA8KBuFKcicBHK/Ns/1ljycWr07cLJc 26 | WabX25aI0x58+ise5fJAQvNW6Oq2Jvt3qcUXnr2OMnR6WUQ7amgVC7JAdfRoahYU 27 | 2WJl6jkCANIv5mY7ubEgVgXnuYYL4ljScpMZLER3cLW4uQnRXWo4zciz06+BadfI 28 | PjGb9xt+3EE4VB64O3yI9yBoDAM/lL0oadB8TPxFLN1NZA3gixbE6dXd5jGD9YxJ 29 | rJNu8xR4MUttiHA8+aVw84kiqsY8OzrjbJnVr2SSXARYw4dnz8DfxhPCt6hMCWnw 30 | -----END RSA PRIVATE KEY----- 31 | -------------------------------------------------------------------------------- /test/serial/autocannon.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const childProcess = require('child_process') 4 | const fs = require('fs') 5 | const path = require('path') 6 | const test = require('tap').test 7 | 8 | const { promisify } = require('util') 9 | 10 | const exec = promisify(childProcess.exec).bind(childProcess) 11 | const proxyquire = require('proxyquire') 12 | 13 | const baseDir = path.join(__dirname, '..', '..') 14 | 15 | test('should print error if url.URL is not a function', t => { 16 | t.plan(2) 17 | 18 | const _error = console.error 19 | const _exit = process.exit 20 | 21 | process.exit = (code) => { 22 | t.is(code, 1) 23 | process.exit = _exit 24 | t.end() 25 | } 26 | console.error = (obj) => { 27 | t.is( 28 | obj, 29 | 'autocannon requires the WHATWG URL API, but it is not available. Please upgrade to Node 6.13+.' 30 | ) 31 | console.error = _error 32 | } 33 | proxyquire('../..', { 34 | url: { 35 | URL: null 36 | } 37 | }) 38 | }) 39 | 40 | test('should print version if invoked with --version', async t => { 41 | t.plan(1) 42 | const res = await exec(`node ${baseDir}/autocannon.js --version`) 43 | t.ok(res.stdout.match(/autocannon v(\d+\.\d+\.\d+)/)) 44 | }) 45 | 46 | test('should print help if invoked with --help', async t => { 47 | t.plan(1) 48 | const help = fs.readFileSync(path.join(baseDir, 'help.txt'), 'utf8') 49 | const res = await exec(`node ${baseDir}/autocannon.js --help`) 50 | t.same(res.stderr.trim(), help.trim()) // console.error adds \n at the end of print 51 | }) 52 | 53 | test('should print help if no url is provided', async t => { 54 | t.plan(1) 55 | const help = fs.readFileSync(path.join(baseDir, 'help.txt'), 'utf8') 56 | const res = await exec(`node ${baseDir}/autocannon.js`) 57 | t.same(res.stderr.trim(), help.trim()) // console.error adds \n at the end of print 58 | }) 59 | -------------------------------------------------------------------------------- /test/envPort.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const t = require('tap') 4 | const split = require('split2') 5 | const path = require('path') 6 | const childProcess = require('child_process') 7 | const helper = require('./helper') 8 | 9 | const lines = [ 10 | /Running 1s test @ .*$/, 11 | /10 connections.*$/, 12 | /$/, 13 | /.*/, 14 | /Stat.*2\.5%.*50%.*97\.5%.*99%.*Avg.*Stdev.*Max.*$/, 15 | /.*/, 16 | /Latency.*$/, 17 | /$/, 18 | /.*/, 19 | /Stat.*1%.*2\.5%.*50%.*97\.5%.*Avg.*Stdev.*Min.*$/, 20 | /.*/, 21 | /Req\/Sec.*$/, 22 | /$/, 23 | /Bytes\/Sec.*$/, 24 | /.*/, 25 | /$/, 26 | /Req\/Bytes counts sampled once per second.*$/, 27 | /$/, 28 | /.* requests in ([0-9]|\.)+s, .* read/ 29 | ] 30 | 31 | t.plan(lines.length * 2 + 2) 32 | 33 | const server = helper.startServer() 34 | const port = server.address().port 35 | const url = '/path' // no hostname 36 | 37 | const child = childProcess.spawn(process.execPath, [path.join(__dirname, '..'), '-d', '1', url], { 38 | cwd: __dirname, 39 | env: Object.assign({}, process.env, { 40 | PORT: port 41 | }), 42 | stdio: ['ignore', 'pipe', 'pipe'], 43 | detached: false 44 | }) 45 | 46 | t.tearDown(() => { 47 | child.kill() 48 | }) 49 | 50 | child 51 | .stderr 52 | .pipe(split()) 53 | .on('data', (line) => { 54 | const regexp = lines.shift() 55 | t.ok(regexp, 'we are expecting this line') 56 | t.ok(regexp.test(line), 'line matches ' + regexp) 57 | }) 58 | .on('end', () => { 59 | t.ok(server.autocannonConnects > 0, 'targeted the correct port') 60 | }) 61 | 62 | const noPortChild = childProcess.spawn(process.execPath, [path.join(__dirname, '..'), url], { 63 | cwd: __dirname, 64 | env: process.env, 65 | stdio: ['ignore', 'pipe', 'pipe'], 66 | detached: false 67 | }) 68 | 69 | noPortChild.on('exit', (code) => { 70 | t.equal(code, 1, 'should exit with error when a hostless URL is passed and no PORT var is available') 71 | }) 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "autocannon", 3 | "version": "4.6.0", 4 | "description": "Fast HTTP benchmarking tool written in Node.js", 5 | "main": "autocannon.js", 6 | "bin": { 7 | "autocannon": "autocannon.js" 8 | }, 9 | "scripts": { 10 | "test": "standard && tap --no-esm --no-jsx --timeout 45 test/serial/*.test.js test/*.test.js", 11 | "standard:fix": "standard --fix" 12 | }, 13 | "pre-commit": [ 14 | "test" 15 | ], 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/mcollina/autocannon.git" 19 | }, 20 | "keywords": [ 21 | "http", 22 | "soak", 23 | "load", 24 | "fast", 25 | "wrk", 26 | "ab", 27 | "test" 28 | ], 29 | "author": "Matteo Collina ", 30 | "contributors": [ 31 | "Glen Keane ", 32 | "Donald Robertson \]/g, hyperid())) 38 | : ret 39 | } 40 | 41 | RequestIterator.prototype.setRequests = function (newRequests) { 42 | this.requests = newRequests || [{}] 43 | this.currentRequestIndex = 0 44 | this.currentRequest = this.requests[0] 45 | this.rebuildRequests() 46 | } 47 | 48 | RequestIterator.prototype.rebuildRequests = function () { 49 | this.requests.forEach((request) => { 50 | request.requestBuffer = this.requestBuilder(request) 51 | }) 52 | } 53 | 54 | RequestIterator.prototype.setHeaders = function (newHeaders) { 55 | this.currentRequest.headers = newHeaders || {} 56 | this.rebuildRequest() 57 | } 58 | 59 | RequestIterator.prototype.setBody = function (newBody) { 60 | this.currentRequest.body = newBody || Buffer.alloc(0) 61 | this.rebuildRequest() 62 | } 63 | 64 | RequestIterator.prototype.setHeadersAndBody = function (newHeaders, newBody) { 65 | this.currentRequest.headers = newHeaders || {} 66 | this.currentRequest.body = newBody || Buffer.alloc(0) 67 | this.rebuildRequest() 68 | } 69 | 70 | RequestIterator.prototype.setRequest = function (newRequest) { 71 | this.currentRequest = newRequest || {} 72 | this.rebuildRequest() 73 | } 74 | 75 | RequestIterator.prototype.rebuildRequest = function () { 76 | this.currentRequest.requestBuffer = this.requestBuilder(this.currentRequest) 77 | this.requests[this.currentRequestIndex] = this.currentRequest 78 | } 79 | 80 | module.exports = RequestIterator 81 | -------------------------------------------------------------------------------- /test/runAmount.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('tap').test 4 | const run = require('../lib/run') 5 | const helper = require('./helper') 6 | const timeoutServer = helper.startTimeoutServer() 7 | const server = helper.startServer() 8 | 9 | test('run should only send the expected number of requests', (t) => { 10 | t.plan(10) 11 | 12 | let done = false 13 | 14 | run({ 15 | url: `http://localhost:${server.address().port}`, 16 | duration: 1, 17 | connections: 100, 18 | amount: 50146 19 | }, (err, res) => { 20 | t.error(err) 21 | t.equal(res.requests.total + res.timeouts, 50146, 'results should match the amount') 22 | t.equal(res.requests.sent, 50146, 'totalRequests should match the amount') 23 | done = true 24 | }) 25 | 26 | setTimeout(() => { 27 | t.notOk(done) 28 | }, 1000) 29 | 30 | run({ 31 | url: `http://localhost:${server.address().port}`, 32 | connections: 2, 33 | maxConnectionRequests: 10 34 | }, (err, res) => { 35 | t.error(err) 36 | t.equal(res.requests.total, 20, 'results should match max connection requests * connections') 37 | t.equal(res.requests.sent, 20, 'totalRequests should match the expected amount') 38 | }) 39 | 40 | run({ 41 | url: `http://localhost:${server.address().port}`, 42 | connections: 2, 43 | maxOverallRequests: 10 44 | }, (err, res) => { 45 | t.error(err) 46 | t.equal(res.requests.total, 10, 'results should match max overall requests') 47 | t.equal(res.requests.sent, 10, 'totalRequests should match the expected amount') 48 | }) 49 | }) 50 | 51 | test('should shutdown after all amounts timeout', (t) => { 52 | t.plan(5) 53 | 54 | run({ 55 | url: `http://localhost:${timeoutServer.address().port}`, 56 | amount: 10, 57 | timeout: 2, 58 | connections: 10 59 | }, (err, res) => { 60 | t.error(err) 61 | t.equal(res.errors, 10) 62 | t.equal(res.timeouts, 10) 63 | t.equal(res.requests.sent, 10, 'totalRequests should match the expected amount') 64 | t.equal(res.requests.total, 0, 'total completed requests should be 0') 65 | }) 66 | }) 67 | 68 | test('should reconnect twice to the server with a reset rate of 10 for 20 connections', (t) => { 69 | t.plan(3) 70 | const testServer = helper.startServer() 71 | 72 | run({ 73 | url: 'localhost:' + testServer.address().port, 74 | connections: 1, 75 | amount: 20, 76 | reconnectRate: 2 77 | }, (err, res) => { 78 | t.error(err) 79 | t.equal(res.requests.sent, 20, 'totalRequests should match the expected amount') 80 | t.equal(testServer.autocannonConnects, 10, 'should have connected to the server 10 times after dropping the connection every second request') 81 | t.end() 82 | }) 83 | }) 84 | -------------------------------------------------------------------------------- /test/cli-ipc.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const t = require('tap') 4 | const split = require('split2') 5 | const os = require('os') 6 | const path = require('path') 7 | const childProcess = require('child_process') 8 | const helper = require('./helper') 9 | 10 | const win = process.platform === 'win32' 11 | 12 | const lines = [ 13 | /Running 1s test @ http:\/\/example.com\/foo \([^)]*\)$/, 14 | /10 connections.*$/, 15 | /$/, 16 | /.*/, 17 | /Stat.*2\.5%.*50%.*97\.5%.*99%.*Avg.*Stdev.*Max.*$/, 18 | /.*/, 19 | /Latency.*$/, 20 | /$/, 21 | /.*/, 22 | /Stat.*1%.*2\.5%.*50%.*97\.5%.*Avg.*Stdev.*Min.*$/, 23 | /.*/, 24 | /Req\/Sec.*$/, 25 | /.*/, 26 | /Bytes\/Sec.*$/, 27 | /.*/, 28 | /$/, 29 | /Req\/Bytes counts sampled once per second.*$/, 30 | /$/, 31 | /.* requests in ([0-9]|\.)+s, .* read/ 32 | ] 33 | 34 | if (!win) { 35 | // If not Windows we can predict exactly how many lines there will be. On 36 | // Windows we rely on t.end() being called. 37 | t.plan(lines.length) 38 | } 39 | 40 | t.autoend(false) 41 | t.tearDown(function () { 42 | child.kill() 43 | }) 44 | 45 | const socketPath = win 46 | ? path.join('\\\\?\\pipe', process.cwd(), 'autocannon-' + Date.now()) 47 | : path.join(os.tmpdir(), 'autocannon-' + Date.now() + '.sock') 48 | 49 | helper.startServer({ socketPath }) 50 | 51 | const child = childProcess.spawn(process.execPath, [path.join(__dirname, '..'), '-d', '1', '-S', socketPath, 'example.com/foo'], { 52 | cwd: __dirname, 53 | env: process.env, 54 | stdio: ['ignore', 'pipe', 'pipe'], 55 | detached: false 56 | }) 57 | 58 | // For handling the last line on Windows 59 | let errorLine = false 60 | let failsafeTimer 61 | 62 | child 63 | .stderr 64 | .pipe(split()) 65 | .on('data', (line) => { 66 | let regexp = lines.shift() 67 | const lastLine = lines.length === 0 68 | 69 | if (regexp) { 70 | t.ok(regexp.test(line), 'line matches ' + regexp) 71 | 72 | if (lastLine && win) { 73 | // We can't be sure the error line is outputted on Windows, so in case 74 | // this really is the last line, we'll set a timer to auto-end the test 75 | // in case there are no more lines. 76 | failsafeTimer = setTimeout(function () { 77 | t.end() 78 | }, 1000) 79 | } 80 | } else if (!errorLine && win) { 81 | // On Windows a few errors are expected. We'll accept a 1% error rate on 82 | // the pipe. 83 | errorLine = true 84 | clearTimeout(failsafeTimer) 85 | regexp = /^(\d+) errors \(0 timeouts\)$/ 86 | const match = line.match(regexp) 87 | t.ok(match, 'line matches ' + regexp) 88 | const errors = Number(match[1]) 89 | t.ok(errors / 15000 < 0.01, `should have less than 1% errors on Windows (had ${errors} errors)`) 90 | t.end() 91 | } else { 92 | throw new Error('Unexpected line: ' + JSON.stringify(line)) 93 | } 94 | }) 95 | -------------------------------------------------------------------------------- /lib/httpRequestBuilder.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const methods = require('./httpMethods') 4 | 5 | // this is a build request factory, that curries the build request function 6 | // and sets the default for it 7 | function requestBuilder (defaults) { 8 | // these need to be defined per request builder creation, because of the way 9 | // headers don't get deep copied 10 | const builderDefaults = { 11 | method: 'GET', 12 | path: '/', 13 | headers: {}, 14 | body: Buffer.alloc(0), 15 | hostname: 'localhost', 16 | setupRequest: reqData => reqData, 17 | port: 80 18 | } 19 | 20 | defaults = Object.assign(builderDefaults, defaults) 21 | 22 | // buildRequest takes an object, and turns it into a buffer representing the 23 | // http request 24 | return function buildRequest (reqData) { 25 | // below is a hack to enable deep extending of the headers so the default 26 | // headers object isn't overwritten by accident 27 | reqData = reqData || {} 28 | reqData.headers = Object.assign({}, defaults.headers, reqData.headers) 29 | 30 | reqData = Object.assign({}, defaults, reqData) 31 | 32 | reqData = reqData.setupRequest(reqData) 33 | 34 | // for some reason some tests fail with method === undefined 35 | // the reqData.method should be set to SOMETHING in this case 36 | // cannot find reason for failure if `|| 'GET'` is taken out 37 | const method = reqData.method 38 | const path = reqData.path 39 | const headers = reqData.headers 40 | const body = reqData.body 41 | 42 | let host = reqData.headers.host || reqData.host 43 | if (!host) { 44 | const hostname = reqData.hostname 45 | const port = reqData.port 46 | host = hostname + ':' + port 47 | } 48 | 49 | if (reqData.auth) { 50 | const encodedAuth = Buffer.from(reqData.auth).toString('base64') 51 | headers.Authorization = `Basic ${encodedAuth}` 52 | } 53 | 54 | if (methods.indexOf(method) < 0) { 55 | throw new Error(`${method} HTTP method is not supported`) 56 | } 57 | 58 | const baseReq = [ 59 | `${method} ${path} HTTP/1.1`, 60 | `Host: ${host}`, 61 | 'Connection: keep-alive' 62 | ] 63 | 64 | let bodyBuf 65 | 66 | if (typeof body === 'string') { 67 | bodyBuf = Buffer.from(body) 68 | } else if (Buffer.isBuffer(body)) { 69 | bodyBuf = body 70 | } else if (body) { 71 | throw new Error('body must be either a string or a buffer') 72 | } 73 | 74 | if (bodyBuf && bodyBuf.length > 0) { 75 | const idCount = reqData.idReplacement 76 | ? (bodyBuf.toString().match(/\[\]/g) || []).length 77 | : 0 78 | headers['Content-Length'] = `${bodyBuf.length + (idCount * 27)}` 79 | } 80 | 81 | for (const [key, header] of Object.entries(headers)) { 82 | baseReq.push(`${key}: ${header}`) 83 | } 84 | 85 | let req = Buffer.from(baseReq.join('\r\n') + '\r\n\r\n', 'utf8') 86 | 87 | if (bodyBuf && bodyBuf.length > 0) { 88 | req = Buffer.concat([req, bodyBuf]) 89 | } 90 | 91 | return req 92 | } 93 | } 94 | 95 | module.exports = requestBuilder 96 | -------------------------------------------------------------------------------- /test/argumentParsing.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('tap').test 4 | const Autocannon = require('../autocannon') 5 | 6 | test('parse argument', (t) => { 7 | t.plan(4) 8 | 9 | var args = Autocannon.parseArguments([ 10 | '-H', 'X-Http-Method-Override=GET', 11 | '-m', 'POST', 12 | '-b', 'the body', 13 | 'http://localhost/foo/bar' 14 | ]) 15 | 16 | t.equal(args.url, 'http://localhost/foo/bar') 17 | t.strictSame(args.headers, { 'X-Http-Method-Override': 'GET' }) 18 | t.equal(args.method, 'POST') 19 | t.equal(args.body, 'the body') 20 | }) 21 | 22 | test('parse argument with multiple headers', (t) => { 23 | t.plan(3) 24 | 25 | var args = Autocannon.parseArguments([ 26 | '-H', 'header1=value1', 27 | '-H', 'header2=value2', 28 | '-H', 'header3=value3', 29 | '-H', 'header4=value4', 30 | '-H', 'header5=value5', 31 | 'http://localhost/foo/bar' 32 | ]) 33 | 34 | t.equal(args.url, 'http://localhost/foo/bar') 35 | t.strictSame(args.headers, { 36 | header1: 'value1', 37 | header2: 'value2', 38 | header3: 'value3', 39 | header4: 'value4', 40 | header5: 'value5' 41 | }) 42 | t.equal(args.method, 'GET') 43 | }) 44 | 45 | test('parse argument with multiple complex headers', (t) => { 46 | t.plan(3) 47 | 48 | var args = Autocannon.parseArguments([ 49 | '-H', 'header1=value1;data=asd', 50 | '-H', 'header2=value2;data=asd', 51 | '-H', 'header3=value3;data=asd', 52 | '-H', 'header4=value4;data=asd', 53 | '-H', 'header5=value5;data=asd', 54 | 'http://localhost/foo/bar' 55 | ]) 56 | 57 | t.equal(args.url, 'http://localhost/foo/bar') 58 | t.strictSame(args.headers, { 59 | header1: 'value1;data=asd', 60 | header2: 'value2;data=asd', 61 | header3: 'value3;data=asd', 62 | header4: 'value4;data=asd', 63 | header5: 'value5;data=asd' 64 | }) 65 | t.equal(args.method, 'GET') 66 | }) 67 | 68 | test('parse argument with multiple headers in standard notation', (t) => { 69 | t.plan(3) 70 | 71 | var args = Autocannon.parseArguments([ 72 | '-H', 'header1: value1', 73 | '-H', 'header2: value2', 74 | '-H', 'header3: value3', 75 | '-H', 'header4: value4', 76 | '-H', 'header5: value5', 77 | 'http://localhost/foo/bar' 78 | ]) 79 | 80 | t.equal(args.url, 'http://localhost/foo/bar') 81 | t.strictSame(args.headers, { 82 | header1: 'value1', 83 | header2: 'value2', 84 | header3: 'value3', 85 | header4: 'value4', 86 | header5: 'value5' 87 | }) 88 | t.equal(args.method, 'GET') 89 | }) 90 | 91 | test('parse argument with multiple complex headers in standard notation', (t) => { 92 | t.plan(3) 93 | 94 | var args = Autocannon.parseArguments([ 95 | '-H', 'header1: value1;data=asd', 96 | '-H', 'header2: value2;data=asd', 97 | '-H', 'header3: value3;data=asd', 98 | '-H', 'header4: value4;data=asd', 99 | '-H', 'header5: value5;data=asd', 100 | 'http://localhost/foo/bar' 101 | ]) 102 | 103 | t.equal(args.url, 'http://localhost/foo/bar') 104 | t.strictSame(args.headers, { 105 | header1: 'value1;data=asd', 106 | header2: 'value2;data=asd', 107 | header3: 'value3;data=asd', 108 | header4: 'value4;data=asd', 109 | header5: 'value5;data=asd' 110 | }) 111 | t.equal(args.method, 'GET') 112 | }) 113 | 114 | test('parse argument with "=" in value header', (t) => { 115 | t.plan(1) 116 | 117 | var args = Autocannon.parseArguments([ 118 | '-H', 'header1=foo=bar', 119 | 'http://localhost/foo/bar' 120 | ]) 121 | 122 | t.strictSame(args.headers, { 123 | header1: 'foo=bar' 124 | }) 125 | }) 126 | 127 | test('parse argument with ":" in value header', (t) => { 128 | t.plan(1) 129 | 130 | var args = Autocannon.parseArguments([ 131 | '-H', 'header1=foo:bar', 132 | 'http://localhost/foo/bar' 133 | ]) 134 | 135 | t.strictSame(args.headers, { 136 | header1: 'foo:bar' 137 | }) 138 | }) 139 | 140 | test('parse argument not correctly formatted header', (t) => { 141 | t.plan(1) 142 | 143 | t.throws(() => { 144 | Autocannon.parseArguments([ 145 | '-H', 'header1', 146 | 'http://localhost/foo/bar' 147 | ]) 148 | }, /An HTTP header was not correctly formatted/) 149 | }) 150 | -------------------------------------------------------------------------------- /help.txt: -------------------------------------------------------------------------------- 1 | Usage: autocannon [opts] URL 2 | 3 | URL is any valid http or https url. 4 | If the PORT environment variable is set, the URL can be a path. In that case 'http://localhost:$PORT/path' will be used as the URL. 5 | 6 | Available options: 7 | 8 | -c/--connections NUM 9 | The number of concurrent connections to use. default: 10. 10 | -p/--pipelining NUM 11 | The number of pipelined requests to use. default: 1. 12 | -d/--duration SEC 13 | The number of seconds to run the autocannnon. default: 10. 14 | -a/--amount NUM 15 | The amount of requests to make before exiting the benchmark. If set, duration is ignored. 16 | -S/--socketPath 17 | A path to a Unix Domain Socket or a Windows Named Pipe. A URL is still required in order to send the correct Host header and path. 18 | --on-port 19 | Start the command listed after -- on the command line. When it starts listening on a port, 20 | start sending requests to that port. A URL is still required in order to send requests to 21 | the correct path. The hostname can be omitted, `localhost` will be used by default. 22 | -m/--method METHOD 23 | The http method to use. default: 'GET'. 24 | -t/--timeout NUM 25 | The number of seconds before timing out and resetting a connection. default: 10 26 | -T/--title TITLE 27 | The title to place in the results for identification. 28 | -b/--body BODY 29 | The body of the request. 30 | -F/--form FORM 31 | Upload a form (multipart/form-data). The form options can be a JSON string like 32 | '{ "field 1": { "type": "text", "value": "a text value"}, "field 2": { "type": "file", "path": "path to the file" } }' 33 | or a path to a JSON file containing the form options. 34 | When uploading a file the default filename value can be overridden by using the corresponding option: 35 | '{ "field name": { "type": "file", "path": "path to the file", "options": { "filename": "myfilename" } } }' 36 | Passing the filepath to the form can be done by using the corresponding option: 37 | '{ "field name": { "type": "file", "path": "path to the file", "options": { "filepath": "/some/path/myfilename" } } }' 38 | -i/--input FILE 39 | The body of the request. 40 | -H/--headers K=V 41 | The request headers. 42 | -B/--bailout NUM 43 | The number of failures before initiating a bailout. 44 | -M/--maxConnectionRequests NUM 45 | The max number of requests to make per connection to the server. 46 | -O/--maxOverallRequests NUM 47 | The max number of requests to make overall to the server. 48 | -r/--connectionRate NUM 49 | The max number of requests to make per second from an individual connection. 50 | -R/--overallRate NUM 51 | The max number of requests to make per second from an all connections. 52 | connection rate will take precedence if both are set. 53 | NOTE: if using rate limiting and a very large rate is entered which cannot be met, 54 | Autocannon will do as many requests as possible per second. 55 | -D/--reconnectRate NUM 56 | Some number of requests to make before resetting a connections connection to the 57 | server. 58 | -n/--no-progress 59 | Don't render the progress bar. default: false. 60 | -l/--latency 61 | Print all the latency data. default: false. 62 | -I/--idReplacement 63 | Enable replacement of [] with a randomly generated ID within the request body. default: false. 64 | -j/--json 65 | Print the output as newline delimited json. This will cause the progress bar and results not to be rendered. default: false. 66 | -f/--forever 67 | Run the benchmark forever. Efficiently restarts the benchmark on completion. default: false. 68 | -s/--servername 69 | Server name for the SNI (Server Name Indication) TLS extension. 70 | -x/--excludeErrorStats 71 | Exclude error statistics (non 2xx http responses) from the final latency and bytes per second averages. default: false. 72 | -E/--expectBody EXPECTED 73 | Ensure the body matches this value. If enabled, mismatches count towards bailout. 74 | Enabling this option will slow down the load testing. 75 | --debug 76 | Print connection errors to stderr. 77 | -v/--version 78 | Print the version number. 79 | -h/--help 80 | Print this menu. 81 | -------------------------------------------------------------------------------- /test/httpRequestBuilder.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('tap').test 4 | const helper = require('./helper') 5 | const server = helper.startServer() 6 | const RequestBuilder = require('../lib/httpRequestBuilder') 7 | const httpMethods = require('../lib/httpMethods') 8 | 9 | test('request builder should create a request with sensible defaults', (t) => { 10 | t.plan(1) 11 | 12 | const opts = server.address() 13 | 14 | const build = RequestBuilder(opts) 15 | 16 | const result = build() 17 | t.same(result, 18 | Buffer.from(`GET / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\n\r\n`), 19 | 'request is okay') 20 | }) 21 | 22 | test('request builder should allow default overwriting', (t) => { 23 | t.plan(1) 24 | 25 | const opts = server.address() 26 | opts.method = 'POST' 27 | 28 | const build = RequestBuilder(opts) 29 | 30 | const result = build() 31 | t.same(result, 32 | Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\n\r\n`), 33 | 'request is okay') 34 | }) 35 | 36 | test('request builder should allow per build overwriting', (t) => { 37 | t.plan(1) 38 | 39 | const opts = server.address() 40 | opts.method = 'POST' 41 | 42 | const build = RequestBuilder(opts) 43 | 44 | const result = build({ method: 'GET' }) 45 | 46 | t.same(result, 47 | Buffer.from(`GET / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\n\r\n`), 48 | 'request is okay') 49 | }) 50 | 51 | test('request builder should throw on unknown http method', (t) => { 52 | t.plan(1) 53 | 54 | const opts = server.address() 55 | 56 | const build = RequestBuilder(opts) 57 | 58 | t.throws(() => build({ method: 'UNKNOWN' })) 59 | }) 60 | 61 | test('request builder should accept all valid standard http methods', (t) => { 62 | t.plan(httpMethods.length) 63 | httpMethods.forEach((method) => { 64 | const opts = server.address() 65 | 66 | const build = RequestBuilder(opts) 67 | 68 | t.doesNotThrow(() => build({ method: method }), `${method} should be usable by the request builded`) 69 | }) 70 | t.end() 71 | }) 72 | 73 | test('request builder should add a Content-Length header when the body buffer exists as a default override', (t) => { 74 | t.plan(1) 75 | 76 | const opts = server.address() 77 | opts.method = 'POST' 78 | opts.body = 'body' 79 | 80 | const build = RequestBuilder(opts) 81 | 82 | const result = build() 83 | t.same(result, 84 | Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 4\r\n\r\nbody`), 85 | 'request is okay') 86 | }) 87 | 88 | test('request builder should add a Content-Length header when the body buffer exists as per build override', (t) => { 89 | t.plan(1) 90 | 91 | const opts = server.address() 92 | opts.method = 'POST' 93 | 94 | const build = RequestBuilder(opts) 95 | 96 | const result = build({ body: 'body' }) 97 | t.same(result, 98 | Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 4\r\n\r\nbody`), 99 | 'request is okay') 100 | }) 101 | 102 | test('request builder should add a Content-Length header with correct calculated value when the body buffer exists and idReplacement is enabled as a default override', (t) => { 103 | t.plan(1) 104 | 105 | const opts = server.address() 106 | opts.method = 'POST' 107 | opts.body = '[]' 108 | opts.idReplacement = true 109 | 110 | const build = RequestBuilder(opts) 111 | 112 | const result = build() 113 | t.same(result, 114 | Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 33\r\n\r\n[]`), 115 | 'request is okay') 116 | }) 117 | 118 | test('request builder should add a Content-Length header with value "[]" when the body buffer exists and idReplacement is enabled as a per build override', (t) => { 119 | t.plan(1) 120 | 121 | const opts = server.address() 122 | opts.method = 'POST' 123 | opts.body = '[]' 124 | 125 | const build = RequestBuilder(opts) 126 | 127 | const result = build({ idReplacement: true }) 128 | t.same(result, 129 | Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 33\r\n\r\n[]`), 130 | 'request is okay') 131 | }) 132 | 133 | test('request builder should allow http basic authentication', (t) => { 134 | t.plan(1) 135 | 136 | const opts = server.address() 137 | opts.auth = 'username:password' 138 | 139 | const build = RequestBuilder(opts) 140 | 141 | const result = build() 142 | t.same(result, 143 | Buffer.from(`GET / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nAuthorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=\r\n\r\n`), 144 | 'request is okay') 145 | }) 146 | 147 | test('should throw error if body is not a string or a buffer', (t) => { 148 | t.plan(1) 149 | 150 | const opts = server.address() 151 | 152 | const build = RequestBuilder(opts) 153 | 154 | try { 155 | build({ body: [] }) 156 | } catch (error) { 157 | t.is(error.message, 'body must be either a string or a buffer') 158 | } 159 | }) 160 | -------------------------------------------------------------------------------- /test/helper.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const http = require('http') 4 | const https = require('https') 5 | const tls = require('tls') 6 | const fs = require('fs') 7 | const path = require('path') 8 | const BusBoy = require('busboy') 9 | 10 | function startServer (opts) { 11 | opts = opts || {} 12 | 13 | const statusCode = opts.statusCode || 200 14 | const server = http.createServer(handle) 15 | server.autocannonConnects = 0 16 | 17 | server.on('connection', () => { server.autocannonConnects++ }) 18 | 19 | server.listen(opts.socketPath || 0) 20 | 21 | function handle (req, res) { 22 | res.statusCode = statusCode 23 | res.end(opts.body || 'hello world') 24 | } 25 | 26 | server.unref() 27 | 28 | return server 29 | } 30 | 31 | function startTrailerServer () { 32 | const server = http.createServer(handle) 33 | 34 | function handle (req, res) { 35 | res.writeHead(200, { 'Content-Type': 'text/plain', Trailer: 'Content-MD5' }) 36 | res.write('hello ') 37 | res.addTrailers({ 'Content-MD5': '7895bf4b8828b55ceaf47747b4bca667' }) 38 | res.end('world') 39 | } 40 | 41 | server.listen(0) 42 | 43 | server.unref() 44 | 45 | return server 46 | } 47 | 48 | // this server won't reply to requests 49 | function startTimeoutServer () { 50 | const server = http.createServer(() => {}) 51 | 52 | server.listen(0) 53 | server.unref() 54 | 55 | return server 56 | } 57 | 58 | // this server destroys the socket on connection, should result in ECONNRESET 59 | function startSocketDestroyingServer () { 60 | const server = http.createServer(handle) 61 | 62 | function handle (req, res) { 63 | res.destroy() 64 | server.close() 65 | } 66 | 67 | server.listen(0) 68 | server.unref() 69 | 70 | return server 71 | } 72 | 73 | // this server won't reply to requests 74 | function startHttpsServer (opts = {}) { 75 | const options = { 76 | key: fs.readFileSync(path.join(__dirname, '/key.pem')), 77 | cert: fs.readFileSync(path.join(__dirname, '/cert.pem')), 78 | passphrase: 'test' 79 | } 80 | 81 | const server = https.createServer(options, handle) 82 | 83 | server.listen(opts.socketPath || 0) 84 | 85 | function handle (req, res) { 86 | res.end('hello world') 87 | } 88 | 89 | server.unref() 90 | 91 | return server 92 | } 93 | 94 | // this server will echo the SNI Server Name in a HTTP header 95 | function startTlsServer () { 96 | const key = fs.readFileSync(path.join(__dirname, '/key.pem')) 97 | const cert = fs.readFileSync(path.join(__dirname, '/cert.pem')) 98 | const passphrase = 'test' 99 | var servername = '' 100 | 101 | const options = { 102 | key, 103 | cert, 104 | passphrase, 105 | SNICallback: function (name, cb) { 106 | servername = name 107 | cb(null, tls.createSecureContext({ 108 | key, 109 | cert, 110 | passphrase 111 | })) 112 | } 113 | } 114 | 115 | const server = tls.createServer(options, handle) 116 | 117 | server.listen(0) 118 | 119 | function handle (socket) { 120 | socket.on('data', function (data) { 121 | // Assume this is a http get request and send back the servername in an otherwise empty reponse. 122 | socket.write('HTTP/1.1 200 OK\nX-servername: ' + servername + '\nContent-Length: 0\n\n') 123 | socket.setEncoding('utf8') 124 | socket.pipe(socket) 125 | }) 126 | 127 | socket.on('error', noop) 128 | } 129 | 130 | server.unref() 131 | 132 | return server 133 | } 134 | 135 | function startMultipartServer (opts = {}, test = () => {}) { 136 | const server = http.createServer(handle) 137 | const allowed = ['POST', 'PUT'] 138 | function handle (req, res) { 139 | if (allowed.includes(req.method)) { 140 | const bboy = new BusBoy({ headers: req.headers, ...opts }) 141 | const fileData = [] 142 | const payload = {} 143 | bboy.on('file', (fieldname, file, filename, encoding, mimetype) => { 144 | payload[fieldname] = { 145 | filename, 146 | encoding, 147 | mimetype 148 | } 149 | file.on('data', data => fileData.push(data)) 150 | }) 151 | bboy.on('field', (fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype) => { 152 | payload[fieldname] = val 153 | }) 154 | bboy.on('finish', () => { 155 | res.statusCode = fileData.length ? 201 : 400 156 | res.write(JSON.stringify(payload)) 157 | res.end() 158 | test(payload) 159 | }) 160 | req.pipe(bboy) 161 | } else { 162 | res.statusCode = 404 163 | res.write(JSON.stringify({})) 164 | res.end() 165 | } 166 | } 167 | 168 | server.listen(0) 169 | server.unref() 170 | 171 | return server 172 | } 173 | function startBasicAuthServer () { 174 | const server = http.createServer(handle) 175 | 176 | function handle (req, res) { 177 | if (!req.headers.authorization || req.headers.authorization.indexOf('Basic ') === -1) { 178 | res.writeHead(401) 179 | return res.end() 180 | } 181 | 182 | res.writeHead(200) 183 | res.end('hello world') 184 | } 185 | 186 | server.listen(0) 187 | server.unref() 188 | 189 | return server 190 | } 191 | 192 | module.exports.startServer = startServer 193 | module.exports.startTimeoutServer = startTimeoutServer 194 | module.exports.startSocketDestroyingServer = startSocketDestroyingServer 195 | module.exports.startHttpsServer = startHttpsServer 196 | module.exports.startTrailerServer = startTrailerServer 197 | module.exports.startTlsServer = startTlsServer 198 | module.exports.startMultipartServer = startMultipartServer 199 | module.exports.startBasicAuthServer = startBasicAuthServer 200 | 201 | function noop () {} 202 | -------------------------------------------------------------------------------- /lib/httpClient.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const inherits = require('util').inherits 4 | const EE = require('events').EventEmitter 5 | const net = require('net') 6 | const tls = require('tls') 7 | const retimer = require('retimer') 8 | const HTTPParser = require('http-parser-js').HTTPParser 9 | const RequestIterator = require('./requestIterator') 10 | const clone = require('clone') 11 | 12 | function Client (opts) { 13 | if (!(this instanceof Client)) { 14 | return new Client(opts) 15 | } 16 | 17 | this.opts = clone(opts) 18 | 19 | this.opts.setupClient = this.opts.setupClient || noop 20 | this.opts.pipelining = this.opts.pipelining || 1 21 | this.opts.port = this.opts.port || 80 22 | this.opts.expectBody = this.opts.expectBody || null 23 | this.timeout = (this.opts.timeout || 10) * 1000 24 | this.ipc = !!this.opts.socketPath 25 | this.secure = this.opts.protocol === 'https:' 26 | this.auth = this.opts.auth || null 27 | 28 | if (this.secure && this.opts.port === 80) { 29 | this.opts.port = 443 30 | } 31 | 32 | this.parser = new HTTPParser(HTTPParser.RESPONSE) 33 | this.requestIterator = new RequestIterator(this.opts) 34 | 35 | this.reqsMade = 0 36 | 37 | // used for request limiting 38 | this.responseMax = this.opts.responseMax 39 | 40 | // used for rate limiting 41 | this.reqsMadeThisSecond = 0 42 | this.rate = this.opts.rate 43 | 44 | // used for forcing reconnects 45 | this.reconnectRate = this.opts.reconnectRate 46 | 47 | this.resData = new Array(this.opts.pipelining) 48 | for (let i = 0; i < this.opts.pipelining; i++) { 49 | this.resData[i] = { 50 | bytes: 0, 51 | headers: {}, 52 | startTime: [0, 0] 53 | } 54 | } 55 | 56 | // cer = current expected response 57 | this.cer = 0 58 | this.destroyed = false 59 | 60 | this.opts.setupClient(this) 61 | 62 | const handleTimeout = () => { 63 | // all pipelined requests have timed out here 64 | this.resData.forEach(() => this.emit('timeout')) 65 | this.cer = 0 66 | this._destroyConnection() 67 | 68 | // timeout has already occured, need to set a new timeoutTicker 69 | this.timeoutTicker = retimer(handleTimeout, this.timeout) 70 | 71 | this._connect() 72 | } 73 | 74 | if (this.rate) { 75 | this.rateInterval = setInterval(() => { 76 | this.reqsMadeThisSecond = 0 77 | if (this.paused) this._doRequest(this.cer) 78 | this.paused = false 79 | }, 1000) 80 | } 81 | 82 | this.timeoutTicker = retimer(handleTimeout, this.timeout) 83 | this.parser[HTTPParser.kOnHeaders] = () => {} 84 | this.parser[HTTPParser.kOnHeadersComplete] = (opts) => { 85 | this.emit('headers', opts) 86 | this.resData[this.cer].headers = opts 87 | } 88 | 89 | this.parser[HTTPParser.kOnBody] = (body, start, len) => { 90 | this.emit('body', body) 91 | 92 | if (this.opts.expectBody) { 93 | const bodyString = '' + body.slice(start, start + len) 94 | if (this.opts.expectBody !== bodyString) { 95 | return this.emit('mismatch', bodyString) 96 | } 97 | } 98 | } 99 | 100 | this.parser[HTTPParser.kOnMessageComplete] = () => { 101 | const end = process.hrtime(this.resData[this.cer].startTime) 102 | const responseTime = end[0] * 1e3 + end[1] / 1e6 103 | this.emit('response', this.resData[this.cer].headers.statusCode, this.resData[this.cer].bytes, responseTime) 104 | this.resData[this.cer].bytes = 0 105 | 106 | if (!this.destroyed && this.reconnectRate && this.reqsMade % this.reconnectRate === 0) { 107 | return this._resetConnection() 108 | } 109 | 110 | this.cer = this.cer === opts.pipelining - 1 ? 0 : this.cer++ 111 | this._doRequest(this.cer) 112 | } 113 | 114 | this._connect() 115 | } 116 | 117 | inherits(Client, EE) 118 | 119 | Client.prototype._connect = function () { 120 | if (this.secure) { 121 | if (this.ipc) { 122 | this.conn = tls.connect(this.opts.socketPath, { rejectUnauthorized: false }) 123 | } else { 124 | this.conn = tls.connect(this.opts.port, this.opts.hostname, { rejectUnauthorized: false, servername: this.opts.servername }) 125 | } 126 | } else { 127 | if (this.ipc) { 128 | this.conn = net.connect(this.opts.socketPath) 129 | } else { 130 | this.conn = net.connect(this.opts.port, this.opts.hostname) 131 | } 132 | } 133 | 134 | this.conn.on('error', (error) => { 135 | this.emit('connError', error) 136 | if (!this.destroyed) this._connect() 137 | }) 138 | 139 | this.conn.on('data', (chunk) => { 140 | this.resData[this.cer].bytes += chunk.length 141 | this.parser.execute(chunk) 142 | }) 143 | 144 | this.conn.on('end', () => { 145 | if (!this.destroyed) this._connect() 146 | }) 147 | 148 | for (let i = 0; i < this.opts.pipelining; i++) { 149 | this._doRequest(i) 150 | } 151 | } 152 | 153 | // rpi = request pipelining index 154 | Client.prototype._doRequest = function (rpi) { 155 | if (!this.rate || (this.rate && this.reqsMadeThisSecond++ < this.rate)) { 156 | if (!this.destroyed && this.responseMax && this.reqsMade >= this.responseMax) { 157 | return this.destroy() 158 | } 159 | this.emit('request') 160 | this.resData[rpi].startTime = process.hrtime() 161 | this.conn.write(this.requestIterator.move()) 162 | this.timeoutTicker.reschedule(this.timeout) 163 | this.reqsMade++ 164 | } else { 165 | this.paused = true 166 | } 167 | } 168 | 169 | Client.prototype._resetConnection = function () { 170 | this._destroyConnection() 171 | this._connect() 172 | } 173 | 174 | Client.prototype._destroyConnection = function () { 175 | this.conn.removeAllListeners('error') 176 | this.conn.removeAllListeners('end') 177 | this.conn.on('error', () => {}) 178 | this.conn.destroy() 179 | } 180 | 181 | Client.prototype.destroy = function () { 182 | if (!this.destroyed) { 183 | this.destroyed = true 184 | this.timeoutTicker.clear() 185 | if (this.rate) clearInterval(this.rateInterval) 186 | this.emit('done') 187 | this._destroyConnection() 188 | } 189 | } 190 | 191 | Client.prototype.getRequestBuffer = function (newHeaders) { 192 | return this.requestIterator.currentRequest.requestBuffer 193 | } 194 | 195 | Client.prototype.setHeaders = function (newHeaders) { 196 | this._okayToUpdateCheck() 197 | this.requestIterator.setHeaders(newHeaders) 198 | } 199 | 200 | Client.prototype.setBody = function (newBody) { 201 | this._okayToUpdateCheck() 202 | this.requestIterator.setBody(newBody) 203 | } 204 | 205 | Client.prototype.setHeadersAndBody = function (newHeaders, newBody) { 206 | this._okayToUpdateCheck() 207 | this.requestIterator.setHeadersAndBody(newHeaders, newBody) 208 | } 209 | 210 | Client.prototype.setRequest = function (newRequest) { 211 | this._okayToUpdateCheck() 212 | this.requestIterator.setRequest(newRequest) 213 | } 214 | 215 | Client.prototype.setRequests = function (newRequests) { 216 | this._okayToUpdateCheck() 217 | this.requestIterator.setRequests(newRequests) 218 | } 219 | 220 | Client.prototype._okayToUpdateCheck = function () { 221 | if (this.opts.pipelining > 1) { 222 | throw new Error('cannot update requests when the piplining factor is greater than 1') 223 | } 224 | } 225 | 226 | function noop () {} 227 | 228 | module.exports = Client 229 | -------------------------------------------------------------------------------- /autocannon.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | 'use strict' 4 | 5 | const crossArgv = require('cross-argv') 6 | const minimist = require('minimist') 7 | const fs = require('fs') 8 | const os = require('os') 9 | const net = require('net') 10 | const path = require('path') 11 | const URL = require('url').URL 12 | const spawn = require('child_process').spawn 13 | const managePath = require('manage-path') 14 | const hasAsyncHooks = require('has-async-hooks') 15 | const help = fs.readFileSync(path.join(__dirname, 'help.txt'), 'utf8') 16 | const run = require('./lib/run') 17 | const track = require('./lib/progressTracker') 18 | 19 | if (typeof URL !== 'function') { 20 | console.error('autocannon requires the WHATWG URL API, but it is not available. Please upgrade to Node 6.13+.') 21 | process.exit(1) 22 | } 23 | 24 | module.exports = run 25 | module.exports.track = track 26 | 27 | module.exports.start = start 28 | module.exports.parseArguments = parseArguments 29 | 30 | function parseArguments (argvs) { 31 | const argv = minimist(argvs, { 32 | boolean: ['json', 'n', 'help', 'renderLatencyTable', 'renderProgressBar', 'forever', 'idReplacement', 'excludeErrorStats', 'onPort', 'debug'], 33 | alias: { 34 | connections: 'c', 35 | pipelining: 'p', 36 | timeout: 't', 37 | duration: 'd', 38 | amount: 'a', 39 | json: 'j', 40 | renderLatencyTable: ['l', 'latency'], 41 | onPort: 'on-port', 42 | method: 'm', 43 | headers: ['H', 'header'], 44 | body: 'b', 45 | form: 'F', 46 | servername: 's', 47 | bailout: 'B', 48 | input: 'i', 49 | maxConnectionRequests: 'M', 50 | maxOverallRequests: 'O', 51 | connectionRate: 'r', 52 | overallRate: 'R', 53 | reconnectRate: 'D', 54 | renderProgressBar: 'progress', 55 | title: 'T', 56 | version: 'v', 57 | forever: 'f', 58 | idReplacement: 'I', 59 | socketPath: 'S', 60 | excludeErrorStats: 'x', 61 | expectBody: 'E', 62 | help: 'h' 63 | }, 64 | default: { 65 | connections: 10, 66 | timeout: 10, 67 | pipelining: 1, 68 | duration: 10, 69 | reconnectRate: 0, 70 | renderLatencyTable: false, 71 | renderProgressBar: true, 72 | json: false, 73 | forever: false, 74 | method: 'GET', 75 | idReplacement: false, 76 | excludeErrorStats: false, 77 | debug: false 78 | }, 79 | '--': true 80 | }) 81 | 82 | argv.url = argv._[0] 83 | 84 | if (argv.onPort) { 85 | argv.spawn = argv['--'] 86 | } 87 | 88 | // support -n to disable the progress bar and results table 89 | if (argv.n) { 90 | argv.renderProgressBar = false 91 | argv.renderResultsTable = false 92 | } 93 | 94 | if (argv.version) { 95 | console.log('autocannon', 'v' + require('./package').version) 96 | console.log('node', process.version) 97 | return 98 | } 99 | 100 | if (!argv.url || argv.help) { 101 | console.error(help) 102 | return 103 | } 104 | 105 | // if PORT is set (like by `0x`), target `localhost:PORT/path` by default. 106 | // this allows doing: 107 | // 0x --on-port 'autocannon /path' -- node server.js 108 | if (process.env.PORT) { 109 | argv.url = new URL(argv.url, `http://localhost:${process.env.PORT}`).href 110 | } 111 | // Add http:// if it's not there and this is not a /path 112 | if (argv.url.indexOf('http') !== 0 && argv.url[0] !== '/') { 113 | argv.url = `http://${argv.url}` 114 | } 115 | 116 | // check that the URL is valid. 117 | try { 118 | // If --on-port is given, it's acceptable to not have a hostname 119 | if (argv.onPort) { 120 | new URL(argv.url, 'http://localhost') // eslint-disable-line no-new 121 | } else { 122 | new URL(argv.url) // eslint-disable-line no-new 123 | } 124 | } catch (err) { 125 | console.error(err.message) 126 | console.error('') 127 | console.error('When targeting a path without a hostname, the PORT environment variable must be available.') 128 | console.error('Use a full URL or set the PORT variable.') 129 | process.exit(1) 130 | } 131 | 132 | if (argv.input) { 133 | argv.body = fs.readFileSync(argv.input) 134 | } 135 | 136 | if (argv.headers) { 137 | if (!Array.isArray(argv.headers)) { 138 | argv.headers = [argv.headers] 139 | } 140 | 141 | argv.headers = argv.headers.reduce((obj, header) => { 142 | const colonIndex = header.indexOf(':') 143 | const equalIndex = header.indexOf('=') 144 | const index = Math.min(colonIndex < 0 ? Infinity : colonIndex, equalIndex < 0 ? Infinity : equalIndex) 145 | if (Number.isFinite(index) && index > 0) { 146 | obj[header.slice(0, index)] = header.slice(index + 1).trim() 147 | return obj 148 | } else throw new Error(`An HTTP header was not correctly formatted: ${header}`) 149 | }, {}) 150 | } 151 | 152 | return argv 153 | } 154 | 155 | function start (argv) { 156 | if (!argv) { 157 | // we are printing the help 158 | return 159 | } 160 | 161 | if (argv.onPort) { 162 | if (!hasAsyncHooks()) { 163 | console.error('The --on-port flag requires the async_hooks builtin module, but it is not available. Please upgrade to Node 8.1+.') 164 | process.exit(1) 165 | } 166 | 167 | const { socketPath, server } = createChannel((port) => { 168 | const url = new URL(argv.url, `http://localhost:${port}`).href 169 | const opts = Object.assign({}, argv, { 170 | onPort: false, 171 | url: url 172 | }) 173 | runTracker(opts, () => { 174 | proc.kill('SIGINT') 175 | server.close() 176 | }) 177 | }) 178 | 179 | // manage-path always uses the $PATH variable, but we can pretend 180 | // that it is equal to $NODE_PATH 181 | const alterPath = managePath({ PATH: process.env.NODE_PATH }) 182 | alterPath.unshift(path.join(__dirname, 'lib/preload')) 183 | 184 | const proc = spawn(argv.spawn[0], argv.spawn.slice(1), { 185 | stdio: ['ignore', 'inherit', 'inherit'], 186 | env: Object.assign({}, process.env, { 187 | NODE_OPTIONS: ['-r', 'autocannonDetectPort'].join(' ') + 188 | (process.env.NODE_OPTIONS ? ` ${process.env.NODE_OPTIONS}` : ''), 189 | NODE_PATH: alterPath.get(), 190 | AUTOCANNON_SOCKET: socketPath 191 | }) 192 | }) 193 | } else { 194 | runTracker(argv) 195 | } 196 | } 197 | 198 | function createChannel (onport) { 199 | const pipeName = `${process.pid}.autocannon` 200 | const socketPath = process.platform === 'win32' 201 | ? `\\\\?\\pipe\\${pipeName}` 202 | : path.join(os.tmpdir(), pipeName) 203 | const server = net.createServer((socket) => { 204 | socket.once('data', (chunk) => { 205 | const port = chunk.toString() 206 | onport(port) 207 | }) 208 | }) 209 | server.listen(socketPath) 210 | server.on('close', () => { 211 | try { 212 | fs.unlinkSync(socketPath) 213 | } catch (err) {} 214 | }) 215 | 216 | return { socketPath, server } 217 | } 218 | 219 | function runTracker (argv, ondone) { 220 | const tracker = run(argv) 221 | 222 | tracker.on('done', (result) => { 223 | if (ondone) ondone() 224 | if (argv.json) { 225 | console.log(JSON.stringify(result)) 226 | } 227 | }) 228 | 229 | tracker.on('error', (err) => { 230 | if (err) { 231 | throw err 232 | } 233 | }) 234 | 235 | // if not rendering json, or if std isn't a tty, track progress 236 | if (!argv.json || !process.stdout.isTTY) track(tracker, argv) 237 | 238 | process.once('SIGINT', () => { 239 | tracker.stop() 240 | }) 241 | } 242 | 243 | if (require.main === module) { 244 | const argv = crossArgv(process.argv.slice(2)) 245 | start(parseArguments(argv)) 246 | } 247 | -------------------------------------------------------------------------------- /lib/progressTracker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Prints out the test result details. It doesn't have not much business logic. 3 | * We skip test coverage for this file 4 | */ 5 | /* istanbul ignore file */ 6 | 'use strict' 7 | 8 | const ProgressBar = require('progress') 9 | const Table = require('cli-table3') 10 | const Chalk = require('chalk') 11 | const testColorSupport = require('color-support') 12 | const prettyBytes = require('pretty-bytes') 13 | const format = require('./format') 14 | const percentiles = require('hdr-histogram-percentiles-obj').percentiles 15 | const defaults = { 16 | // use stderr as its progressBar's default 17 | outputStream: process.stderr, 18 | renderProgressBar: true, 19 | renderResultsTable: true, 20 | renderLatencyTable: false 21 | } 22 | 23 | function track (instance, opts) { 24 | if (!instance) { 25 | throw new Error('instance required for tracking') 26 | } 27 | 28 | opts = Object.assign({}, defaults, opts) 29 | 30 | const chalk = new Chalk.Instance(testColorSupport({ stream: opts.outputStream, alwaysReturn: true })) 31 | // this default needs to be set after chalk is setup, because chalk is now local to this func 32 | opts.progressBarString = opts.progressBarString || `${chalk.green('running')} [:bar] :percent` 33 | 34 | const iOpts = instance.opts 35 | let durationProgressBar 36 | let amountProgressBar 37 | let addedListeners = false 38 | 39 | instance.on('start', () => { 40 | if (opts.renderProgressBar) { 41 | const socketPath = iOpts.socketPath ? ` (${iOpts.socketPath})` : '' 42 | let msg = `${iOpts.connections} connections` 43 | 44 | if (iOpts.pipelining > 1) { 45 | msg += ` with ${iOpts.pipelining} pipelining factor` 46 | } 47 | 48 | if (!iOpts.amount) { 49 | logToStream(`Running ${iOpts.duration}s test @ ${iOpts.url}${socketPath}\n${msg}\n`) 50 | 51 | durationProgressBar = trackDuration(instance, opts, iOpts) 52 | } else { 53 | logToStream(`Running ${iOpts.amount} requests test @ ${iOpts.url}${socketPath}\n${msg}\n`) 54 | 55 | amountProgressBar = trackAmount(instance, opts, iOpts) 56 | } 57 | 58 | addListener() 59 | } 60 | }) 61 | 62 | function addListener () { 63 | // add listeners for progress bar to instance here so they aren't 64 | // added on restarting, causing listener leaks 65 | if (addedListeners) { 66 | return 67 | } 68 | 69 | addedListeners = true 70 | // note: Attempted to curry the functions below, but that breaks the functionality 71 | // as they use the scope/closure of the progress bar variables to allow them to be reset 72 | if (opts.outputStream.isTTY) { 73 | if (!iOpts.amount) { // duration progress bar 74 | instance.on('tick', () => { durationProgressBar.tick() }) 75 | instance.on('done', () => { durationProgressBar.tick(iOpts.duration - 1) }) 76 | process.once('SIGINT', () => { durationProgressBar.tick(iOpts.duration - 1) }) 77 | } else { // amount progress bar 78 | instance.on('response', () => { amountProgressBar.tick() }) 79 | instance.on('reqError', () => { amountProgressBar.tick() }) 80 | instance.on('done', () => { amountProgressBar.tick(iOpts.amount - 1) }) 81 | process.once('SIGINT', () => { amountProgressBar.tick(iOpts.amount - 1) }) 82 | } 83 | } 84 | } 85 | 86 | instance.on('done', (result) => { 87 | // the code below this `if` just renders the results table... 88 | // if the user doesn't want to render the table, we can just return early 89 | if (!opts.renderResultsTable) return 90 | 91 | const shortLatency = new Table({ 92 | head: asColor(chalk.cyan, ['Stat', '2.5%', '50%', '97.5%', '99%', 'Avg', 'Stdev', 'Max']) 93 | }) 94 | shortLatency.push(asLowRow(chalk.bold('Latency'), asMs(result.latency))) 95 | logToStream(shortLatency.toString()) 96 | 97 | const requests = new Table({ 98 | head: asColor(chalk.cyan, ['Stat', '1%', '2.5%', '50%', '97.5%', 'Avg', 'Stdev', 'Min']) 99 | }) 100 | 101 | requests.push(asHighRow(chalk.bold('Req/Sec'), result.requests)) 102 | requests.push(asHighRow(chalk.bold('Bytes/Sec'), asBytes(result.throughput))) 103 | logToStream(requests.toString()) 104 | logToStream('') 105 | logToStream('Req/Bytes counts sampled once per second.\n') 106 | 107 | if (opts.renderLatencyTable) { 108 | const latencies = new Table({ 109 | head: asColor(chalk.cyan, ['Percentile', 'Latency (ms)']) 110 | }) 111 | percentiles.map((perc) => { 112 | const key = `p${perc}`.replace('.', '_') 113 | return [ 114 | chalk.bold('' + perc), 115 | result.latency[key] 116 | ] 117 | }).forEach(row => { 118 | latencies.push(row) 119 | }) 120 | logToStream(latencies.toString()) 121 | logToStream('') 122 | } 123 | 124 | if (result.non2xx) { 125 | logToStream(`${result['2xx']} 2xx responses, ${result.non2xx} non 2xx responses`) 126 | } 127 | logToStream(`${format(result.requests.total)} requests in ${result.duration}s, ${prettyBytes(result.throughput.total)} read`) 128 | if (result.errors) { 129 | logToStream(`${format(result.errors)} errors (${format(result.timeouts)} timeouts)`) 130 | } 131 | if (result.mismatches) { 132 | logToStream(`${format(result.mismatches)} requests with mismatched body`) 133 | } 134 | }) 135 | 136 | function logToStream (msg) { 137 | opts.outputStream.write(msg + '\n') 138 | } 139 | } 140 | 141 | function trackDuration (instance, opts, iOpts) { 142 | // if switch needed needed to avoid 143 | // https://github.com/mcollina/autocannon/issues/60 144 | if (!opts.outputStream.isTTY) return 145 | 146 | const progressBar = new ProgressBar(opts.progressBarString, { 147 | width: 20, 148 | incomplete: ' ', 149 | total: iOpts.duration, 150 | clear: true, 151 | stream: opts.outputStream 152 | }) 153 | 154 | progressBar.tick(0) 155 | return progressBar 156 | } 157 | 158 | function trackAmount (instance, opts, iOpts) { 159 | // if switch needed needed to avoid 160 | // https://github.com/mcollina/autocannon/issues/60 161 | if (!opts.outputStream.isTTY) return 162 | 163 | const progressBar = new ProgressBar(opts.progressBarString, { 164 | width: 20, 165 | incomplete: ' ', 166 | total: iOpts.amount, 167 | clear: true, 168 | stream: opts.outputStream 169 | }) 170 | 171 | progressBar.tick(0) 172 | return progressBar 173 | } 174 | 175 | // create a table row for stats where low values is better 176 | function asLowRow (name, stat) { 177 | return [ 178 | name, 179 | stat.p2_5, 180 | stat.p50, 181 | stat.p97_5, 182 | stat.p99, 183 | stat.average, 184 | stat.stddev, 185 | typeof stat.max === 'string' ? stat.max : Math.floor(stat.max * 100) / 100 186 | ] 187 | } 188 | 189 | // create a table row for stats where high values is better 190 | function asHighRow (name, stat) { 191 | return [ 192 | name, 193 | stat.p1, 194 | stat.p2_5, 195 | stat.p50, 196 | stat.p97_5, 197 | stat.average, 198 | stat.stddev, 199 | typeof stat.min === 'string' ? stat.min : Math.floor(stat.min * 100) / 100 200 | ] 201 | } 202 | 203 | function asColor (colorise, row) { 204 | return row.map((entry) => colorise(entry)) 205 | } 206 | 207 | function asMs (stat) { 208 | const result = Object.create(null) 209 | Object.keys(stat).forEach((k) => { 210 | result[k] = `${stat[k]} ms` 211 | }) 212 | result.max = typeof stat.max === 'string' ? stat.max : `${Math.floor(stat.max * 100) / 100} ms` 213 | 214 | return result 215 | } 216 | 217 | function asBytes (stat) { 218 | const result = Object.create(stat) 219 | 220 | percentiles.forEach((p) => { 221 | const key = `p${p}`.replace('.', '_') 222 | result[key] = prettyBytes(stat[key]) 223 | }) 224 | 225 | result.average = prettyBytes(stat.average) 226 | result.stddev = prettyBytes(stat.stddev) 227 | result.max = prettyBytes(stat.max) 228 | result.min = prettyBytes(stat.min) 229 | return result 230 | } 231 | 232 | module.exports = track 233 | -------------------------------------------------------------------------------- /test/runMultipart.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('tap').test 4 | const { tmpdir } = require('os') 5 | const { join } = require('path') 6 | const { writeFile } = require('fs') 7 | const { promisify } = require('util') 8 | const run = require('../lib/run') 9 | const helper = require('./helper') 10 | const writef = promisify(writeFile) 11 | 12 | test('run should return an error with invalid form options', async t => { 13 | const cases = [ 14 | { 15 | name: 'invalid JSON', 16 | value: 'u', 17 | message: 'Invalid JSON or file where to get form data' 18 | }, 19 | { 20 | name: 'non existing JSON file', 21 | value: 'nonexisting.json', 22 | message: 'Invalid JSON or file where to get form data' 23 | }, 24 | { 25 | name: 'JSON options missing path key in file type', 26 | value: '{ "image": { "type": "file" }}', 27 | message: 'Missing key \'path\' in form object for key \'image\'' 28 | }, 29 | { 30 | name: 'JS Object missing path key in file type', 31 | value: { image: { type: 'file' } }, 32 | message: 'Missing key \'path\' in form object for key \'image\'' 33 | }, 34 | { 35 | name: 'JSON options missing value in text type', 36 | value: '{ "image": { "type": "text" }}', 37 | message: 'Missing key \'value\' in form object for key \'image\'' 38 | }, 39 | { 40 | name: 'JS Object missing value in text type', 41 | value: { image: { type: 'text' } }, 42 | message: 'Missing key \'value\' in form object for key \'image\'' 43 | }, 44 | { 45 | name: 'JSON options with not supported type', 46 | value: '{ "image": { "type": "random" }}', 47 | message: 'A \'type\' key with value \'text\' or \'file\' should be specified' 48 | }, 49 | { 50 | name: 'JS Object with not supported type', 51 | value: { image: { type: 'random' } }, 52 | message: 'A \'type\' key with value \'text\' or \'file\' should be specified' 53 | } 54 | ] 55 | 56 | const server = helper.startMultipartServer() 57 | t.tearDown(() => server.close()) 58 | 59 | for (const c of cases) { 60 | t.test(c.name, async t => { 61 | const [err] = await new Promise((resolve) => { 62 | run({ 63 | url: 'http://localhost:' + server.address().port, 64 | connections: 1, 65 | amount: 1, 66 | form: c.value 67 | }, (err, res) => { 68 | resolve([err, res]) 69 | }) 70 | }) 71 | await t.test('error', t => { 72 | t.notEqual(null, err) 73 | t.equal(c.message, err.message, `mismatching error message ${err.message}`) 74 | t.end() 75 | }) 76 | }) 77 | } 78 | }) 79 | 80 | test('run should take form options as a JSON string or a JS Object', async t => { 81 | const form = { 82 | image: { 83 | type: 'file', 84 | path: require.resolve('./j5.jpeg') 85 | }, 86 | name: { 87 | type: 'text', 88 | value: 'j5' 89 | } 90 | } 91 | const string = JSON.stringify(form) 92 | const temp = tmpdir() 93 | const jsonFile = join(temp, 'multipart.json') 94 | 95 | await writef(jsonFile, string, 'utf8') 96 | 97 | const cases = [ 98 | { 99 | name: 'from string', 100 | value: string 101 | }, 102 | { 103 | name: 'from json file', 104 | value: jsonFile 105 | }, 106 | { 107 | name: 'from JS Object', 108 | value: form 109 | } 110 | ] 111 | 112 | for (const c of cases) { 113 | t.test(c.name, async t => { 114 | const server = helper.startMultipartServer(null, payload => { 115 | t.equal('j5', payload.name) 116 | t.equal('j5.jpeg', payload.image.filename) 117 | }) 118 | t.tearDown(() => server.close()) 119 | const [err, res] = await new Promise((resolve) => { 120 | run({ 121 | url: 'http://localhost:' + server.address().port, 122 | connections: 1, 123 | amount: 1, 124 | form: c.value 125 | }, (err, res) => { 126 | resolve([err, res]) 127 | }) 128 | }) 129 | await t.test('result', t => { 130 | t.equal(null, err) 131 | t.equal(0, res.errors, 'result should not have errors') 132 | t.equal(1, res['2xx'], 'result status code should be 2xx') 133 | t.equal(0, res.non2xx, 'result status code should be 2xx') 134 | t.end() 135 | }) 136 | }) 137 | } 138 | }) 139 | 140 | test('run should use a custom method if `options.method` is passed', t => { 141 | const server = helper.startMultipartServer(null, payload => { 142 | t.equal('j5', payload.name) 143 | t.equal('j5.jpeg', payload.image.filename) 144 | }) 145 | t.tearDown(() => server.close()) 146 | 147 | const form = { 148 | image: { 149 | type: 'file', 150 | path: require.resolve('./j5.jpeg') 151 | }, 152 | name: { 153 | type: 'text', 154 | value: 'j5' 155 | } 156 | } 157 | run({ 158 | url: 'http://localhost:' + server.address().port, 159 | method: 'PUT', 160 | connections: 1, 161 | amount: 1, 162 | form 163 | }, (err, res) => { 164 | t.equal(null, err) 165 | t.equal(0, res.errors, 'result should not have errors') 166 | t.equal(1, res['2xx'], 'result status code should be 2xx') 167 | t.equal(0, res.non2xx, 'result status code should be 2xx') 168 | t.end() 169 | }) 170 | }) 171 | 172 | test('run should set filename', t => { 173 | const server = helper.startMultipartServer(null, payload => { 174 | t.equal('j5', payload.name) 175 | t.equal('j5.jpeg', payload.image.filename) 176 | }) 177 | t.tearDown(() => server.close()) 178 | 179 | const form = { 180 | image: { 181 | type: 'file', 182 | path: require.resolve('./j5.jpeg') 183 | }, 184 | name: { 185 | type: 'text', 186 | value: 'j5' 187 | } 188 | } 189 | run({ 190 | url: 'http://localhost:' + server.address().port, 191 | method: 'POST', 192 | connections: 1, 193 | amount: 1, 194 | form 195 | }, (err, res) => { 196 | t.equal(null, err) 197 | t.equal(0, res.errors, 'result should not have errors') 198 | t.equal(1, res['2xx'], 'result status code should be 2xx') 199 | t.equal(0, res.non2xx, 'result status code should be 2xx') 200 | t.end() 201 | }) 202 | }) 203 | 204 | test('run should allow overriding filename', t => { 205 | const server = helper.startMultipartServer(null, payload => { 206 | t.equal('j5', payload.name) 207 | t.equal('testfilename.jpeg', payload.image.filename) 208 | }) 209 | t.tearDown(() => server.close()) 210 | 211 | const form = { 212 | image: { 213 | type: 'file', 214 | path: require.resolve('./j5.jpeg'), 215 | options: { 216 | filename: 'testfilename.jpeg' 217 | } 218 | }, 219 | name: { 220 | type: 'text', 221 | value: 'j5' 222 | } 223 | } 224 | run({ 225 | url: 'http://localhost:' + server.address().port, 226 | method: 'POST', 227 | connections: 1, 228 | amount: 1, 229 | form 230 | }, (err, res) => { 231 | t.equal(null, err) 232 | t.equal(0, res.errors, 'result should not have errors') 233 | t.equal(1, res['2xx'], 'result status code should be 2xx') 234 | t.equal(0, res.non2xx, 'result status code should be 2xx') 235 | t.end() 236 | }) 237 | }) 238 | 239 | test('run should allow overriding filename with file path', t => { 240 | const server = helper.startMultipartServer({ preservePath: true }, payload => { 241 | t.equal('j5', payload.name) 242 | t.equal('some/path/testfilename.jpeg', payload.image.filename) 243 | }) 244 | t.tearDown(() => server.close()) 245 | 246 | const form = { 247 | image: { 248 | type: 'file', 249 | path: require.resolve('./j5.jpeg'), 250 | options: { 251 | filepath: 'some/path/testfilename.jpeg' 252 | } 253 | }, 254 | name: { 255 | type: 'text', 256 | value: 'j5' 257 | } 258 | } 259 | run({ 260 | url: 'http://localhost:' + server.address().port, 261 | method: 'POST', 262 | connections: 1, 263 | amount: 1, 264 | form 265 | }, (err, res) => { 266 | t.equal(null, err) 267 | t.equal(0, res.errors, 'result should not have errors') 268 | t.equal(1, res['2xx'], 'result status code should be 2xx') 269 | t.equal(0, res.non2xx, 'result status code should be 2xx') 270 | t.end() 271 | }) 272 | }) 273 | -------------------------------------------------------------------------------- /test/requestIterator.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('tap').test 4 | const helper = require('./helper') 5 | const server = helper.startServer() 6 | const RequestIterator = require('../lib/requestIterator') 7 | 8 | test('request iterator should create requests with sensible defaults', (t) => { 9 | t.plan(3) 10 | 11 | const opts = server.address() 12 | 13 | let iterator = new RequestIterator(opts) 14 | 15 | t.same(iterator.currentRequest.requestBuffer, 16 | Buffer.from(`GET / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\n\r\n`), 17 | 'request is okay') 18 | 19 | opts.requests = [{}] 20 | 21 | iterator = new RequestIterator(opts) 22 | 23 | t.same(iterator.currentRequest.requestBuffer, 24 | Buffer.from(`GET / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\n\r\n`), 25 | 'request is okay') 26 | 27 | opts.requests = [] 28 | 29 | iterator = new RequestIterator(opts) 30 | 31 | t.notOk(iterator.currentRequest, 'request doesn\'t exist') 32 | }) 33 | 34 | test('request iterator should create requests with overwritten defaults', (t) => { 35 | t.plan(1) 36 | 37 | const opts = server.address() 38 | opts.method = 'POST' 39 | 40 | const iterator = new RequestIterator(opts) 41 | 42 | t.same(iterator.currentRequest.requestBuffer, 43 | Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\n\r\n`), 44 | 'request is okay') 45 | }) 46 | 47 | test('request iterator should create requests with overwritten defaults', (t) => { 48 | t.plan(3) 49 | 50 | const opts = server.address() 51 | opts.method = 'POST' 52 | 53 | const requests = [ 54 | { 55 | body: 'hello world' 56 | }, 57 | { 58 | method: 'GET', 59 | body: 'modified' 60 | } 61 | ] 62 | 63 | const request1Res = Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 11\r\n\r\nhello world`) 64 | const request2Res = Buffer.from(`GET / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 8\r\n\r\nmodified`) 65 | 66 | opts.requests = requests 67 | 68 | const iterator = new RequestIterator(opts) 69 | 70 | t.same(iterator.currentRequest.requestBuffer, request1Res, 'request was okay') 71 | iterator.nextRequest() 72 | t.same(iterator.currentRequest.requestBuffer, request2Res, 'request was okay') 73 | iterator.nextRequest() 74 | t.same(iterator.currentRequest.requestBuffer, request1Res, 'request was okay') 75 | }) 76 | 77 | test('request iterator should allow for overwriting the requests passed in, but still use overwritten defaults', (t) => { 78 | t.plan(5) 79 | 80 | const opts = server.address() 81 | opts.method = 'POST' 82 | 83 | const requests1 = [ 84 | { 85 | body: 'hello world' 86 | }, 87 | { 88 | method: 'GET', 89 | body: 'modified' 90 | } 91 | ] 92 | 93 | const requests2 = [ 94 | { 95 | body: 'hell0 w0rld' 96 | } 97 | ] 98 | 99 | const request1Res = Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 11\r\n\r\nhello world`) 100 | const request2Res = Buffer.from(`GET / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 8\r\n\r\nmodified`) 101 | const request3Res = Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 11\r\n\r\nhell0 w0rld`) 102 | 103 | opts.requests = requests1 104 | 105 | const iterator = new RequestIterator(opts) 106 | 107 | t.same(iterator.currentRequest.requestBuffer, request1Res, 'request was okay') 108 | iterator.nextRequest() 109 | t.same(iterator.currentRequest.requestBuffer, request2Res, 'request was okay') 110 | iterator.nextRequest() 111 | t.same(iterator.currentRequest.requestBuffer, request1Res, 'request was okay') 112 | 113 | iterator.setRequests(requests2) 114 | t.same(iterator.currentRequest.requestBuffer, request3Res, 'request was okay') 115 | iterator.nextRequest() 116 | t.same(iterator.currentRequest.requestBuffer, request3Res, 'request was okay') 117 | }) 118 | 119 | test('request iterator should allow for rebuilding the current request', (t) => { 120 | t.plan(6) 121 | 122 | const opts = server.address() 123 | opts.method = 'POST' 124 | 125 | const requests1 = [ 126 | { 127 | body: 'hello world' 128 | }, 129 | { 130 | method: 'GET', 131 | body: 'modified' 132 | } 133 | ] 134 | 135 | const request1Res = Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 11\r\n\r\nhello world`) 136 | const request2Res = Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 8\r\n\r\nmodified`) 137 | const request3Res = Buffer.from(`GET / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 8\r\n\r\nmodified`) 138 | const request4Res = Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nheader: modifiedHeader\r\nContent-Length: 8\r\n\r\nmodified`) 139 | const request5Res = Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\n\r\n`) 140 | 141 | opts.requests = requests1 142 | 143 | const iterator = new RequestIterator(opts) 144 | t.same(iterator.currentRequest.requestBuffer, request1Res, 'request was okay') 145 | iterator.setBody('modified') 146 | t.same(iterator.currentRequest.requestBuffer, request2Res, 'request was okay') 147 | iterator.nextRequest() // verify it didn't affect the other request 148 | t.same(iterator.currentRequest.requestBuffer, request3Res, 'request was okay') 149 | iterator.nextRequest() 150 | t.same(iterator.currentRequest.requestBuffer, request2Res, 'request was okay') 151 | iterator.setHeaders({ header: 'modifiedHeader' }) 152 | t.same(iterator.currentRequest.requestBuffer, request4Res, 'request was okay') 153 | iterator.setRequest() // this should build default request 154 | t.same(iterator.currentRequest.requestBuffer, request5Res, 'request was okay') 155 | }) 156 | 157 | test('request iterator should not replace any [] tags with generated IDs when calling move with idReplacement disabled', (t) => { 158 | t.plan(2) 159 | 160 | const opts = server.address() 161 | opts.method = 'POST' 162 | opts.body = '[]' 163 | opts.requests = [{}] 164 | 165 | const iterator = new RequestIterator(opts) 166 | const result = iterator.move().toString().trim() 167 | 168 | const contentLength = result.split('Content-Length: ')[1].slice(0, 1) 169 | t.equal(contentLength, '6', 'Content-Length was incorrect') 170 | 171 | const body = result.split('Content-Length: 6')[1].trim() 172 | t.equal(body, '[]', '[] should be present in body') 173 | }) 174 | 175 | test('request iterator should replace all [] tags with generated IDs when calling move with idReplacement enabled', (t) => { 176 | t.plan(2) 177 | 178 | const opts = server.address() 179 | opts.method = 'POST' 180 | opts.body = '[]' 181 | opts.requests = [{}] 182 | opts.idReplacement = true 183 | 184 | const iterator = new RequestIterator(opts) 185 | const result = iterator.move().toString().trim() 186 | 187 | t.equal(result.includes('[]'), false, 'One or more [] tags were not replaced') 188 | t.equal(result.slice(-1), '0', 'Generated ID should end with request number') 189 | }) 190 | 191 | test('request iterator should properly mutate requests if a setupRequest function is located', (t) => { 192 | t.plan(6) 193 | 194 | const opts = server.address() 195 | opts.method = 'POST' 196 | 197 | let i = 0 198 | 199 | const requests1 = [ 200 | { 201 | body: 'hello world', 202 | setupRequest: req => { 203 | req.body += i++ 204 | return req 205 | } 206 | }, 207 | { 208 | method: 'POST', 209 | body: 'modified', 210 | setupRequest: req => { 211 | req.method = 'GET' 212 | return req 213 | } 214 | } 215 | ] 216 | 217 | const request1Res = Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 12\r\n\r\nhello world0`) 218 | const request2Res = Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 9\r\n\r\nmodified1`) 219 | const request3Res = Buffer.from(`GET / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 8\r\n\r\nmodified`) 220 | const request4Res = Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nheader: modifiedHeader\r\nContent-Length: 9\r\n\r\nmodified2`) 221 | const request5Res = Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\n\r\n`) 222 | 223 | opts.requests = requests1 224 | 225 | const iterator = new RequestIterator(opts) 226 | t.same(iterator.currentRequest.requestBuffer, request1Res, 'request was okay') 227 | iterator.setBody('modified') 228 | t.same(iterator.currentRequest.requestBuffer, request2Res, 'request was okay') 229 | iterator.nextRequest() // verify it didn't affect the other request 230 | t.same(iterator.currentRequest.requestBuffer, request3Res, 'request was okay') 231 | iterator.nextRequest() 232 | t.same(iterator.currentRequest.requestBuffer, request2Res, 'request was okay') 233 | iterator.setHeaders({ header: 'modifiedHeader' }) 234 | t.same(iterator.currentRequest.requestBuffer, request4Res, 'request was okay') 235 | iterator.setRequest() // this should build default request 236 | t.same(iterator.currentRequest.requestBuffer, request5Res, 'request was okay') 237 | }) 238 | 239 | test('request iterator should return instance of RequestIterator', t => { 240 | t.plan(1) 241 | const caller = {} 242 | const opts = server.address() 243 | 244 | const iterator = RequestIterator.call(caller, opts) 245 | t.type(iterator, RequestIterator) 246 | }) 247 | 248 | test('request iterator should return next request buffer', (t) => { 249 | t.plan(1) 250 | 251 | const opts = server.address() 252 | opts.method = 'POST' 253 | 254 | const requests = [ 255 | { 256 | body: 'hello world' 257 | }, 258 | { 259 | method: 'GET', 260 | body: 'modified' 261 | } 262 | ] 263 | 264 | const request2Res = Buffer.from(`GET / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 8\r\n\r\nmodified`) 265 | 266 | opts.requests = requests 267 | 268 | const iterator = new RequestIterator(opts) 269 | const buffer = iterator.nextRequestBuffer() 270 | t.same(request2Res, buffer, 'request is okay') 271 | }) 272 | -------------------------------------------------------------------------------- /lib/run.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const EE = require('events').EventEmitter 4 | const URL = require('url') 5 | const hdr = require('hdr-histogram-js') 6 | const timestring = require('timestring') 7 | const Client = require('./httpClient') 8 | const DefaultOptions = require('./defaultOptions') 9 | const multipart = require('./multipart') 10 | const histUtil = require('hdr-histogram-percentiles-obj') 11 | const reInterval = require('reinterval') 12 | const histAsObj = histUtil.histAsObj 13 | const addPercentiles = histUtil.addPercentiles 14 | 15 | function run (opts, cb) { 16 | return _run(opts, cb) 17 | } 18 | 19 | function _run (opts, cb, tracker) { 20 | const cbPassedIn = (typeof cb === 'function') 21 | 22 | cb = cb || noop 23 | 24 | tracker = tracker || new EE() 25 | 26 | if (!cbPassedIn && !opts.forever) { 27 | const promise = new Promise((resolve, reject) => { 28 | _run(opts, function (err, results) { 29 | if (err) return reject(err) 30 | resolve(results) 31 | }, tracker) 32 | }) 33 | tracker.then = promise.then.bind(promise) 34 | tracker.catch = promise.catch.bind(promise) 35 | return tracker 36 | } 37 | 38 | const latencies = hdr.build({ 39 | bitBucketSize: 64, 40 | autoResize: true, 41 | lowestDiscernibleValue: 1, 42 | highestTrackableValue: 10000, 43 | numberOfSignificantValueDigits: 5 44 | }) 45 | 46 | const requests = hdr.build({ 47 | bitBucketSize: 64, 48 | autoResize: true, 49 | lowestDiscernibleValue: 1, 50 | highestTrackableValue: 1000000, 51 | numberOfSignificantValueDigits: 3 52 | }) 53 | 54 | const throughput = hdr.build({ 55 | bitBucketSize: 64, 56 | autoResize: true, 57 | lowestDiscernibleValue: 1, 58 | highestTrackableValue: 100000000000, 59 | numberOfSignificantValueDigits: 3 60 | }) 61 | 62 | const statusCodes = [ 63 | 0, // 1xx 64 | 0, // 2xx 65 | 0, // 3xx 66 | 0, // 4xx 67 | 0 // 5xx 68 | ] 69 | 70 | if (opts && opts.form) { 71 | opts.method = opts.method || 'POST' 72 | } 73 | opts = Object.assign({}, DefaultOptions, opts) 74 | 75 | // do error checking, if error, return 76 | if (checkOptsForErrors()) return tracker 77 | 78 | // set tracker.opts here, so throwing over invalid opts and setting defaults etc. 79 | // is done 80 | tracker.opts = opts 81 | 82 | if (opts.url.indexOf('http') !== 0) opts.url = 'http://' + opts.url 83 | const url = URL.parse(opts.url) // eslint-disable-line node/no-deprecated-api 84 | 85 | if (opts.overallRate && (opts.overallRate < opts.connections)) opts.connections = opts.overallRate 86 | 87 | let counter = 0 88 | let bytes = 0 89 | let errors = 0 90 | let timeouts = 0 91 | let mismatches = 0 92 | let totalBytes = 0 93 | let totalRequests = 0 94 | let totalCompletedRequests = 0 95 | const amount = opts.amount 96 | let stop = false 97 | let restart = true 98 | let numRunning = opts.connections 99 | let startTime = Date.now() 100 | const includeErrorStats = !opts.excludeErrorStats 101 | let form 102 | 103 | if (opts.form) { 104 | try { 105 | form = multipart(opts.form) 106 | } catch (error) { 107 | errorCb(error) 108 | return tracker 109 | } 110 | } 111 | 112 | // copy over fields so that the client 113 | // performs the right HTTP requests 114 | url.pipelining = opts.pipelining 115 | url.method = opts.method 116 | url.body = form ? form.getBuffer() : opts.body 117 | url.headers = form ? Object.assign({}, opts.headers, form.getHeaders()) : opts.headers 118 | url.setupClient = opts.setupClient 119 | url.timeout = opts.timeout 120 | url.requests = opts.requests 121 | url.reconnectRate = opts.reconnectRate 122 | url.responseMax = amount || opts.maxConnectionRequests || opts.maxOverallRequests 123 | url.rate = opts.connectionRate || opts.overallRate 124 | url.idReplacement = opts.idReplacement 125 | url.socketPath = opts.socketPath 126 | url.servername = opts.servername 127 | url.expectBody = opts.expectBody 128 | 129 | let clients = [] 130 | initialiseClients(clients) 131 | 132 | if (!amount) { 133 | var stopTimer = setTimeout(() => { 134 | stop = true 135 | }, opts.duration * 1000) 136 | } 137 | 138 | tracker.stop = () => { 139 | stop = true 140 | restart = false 141 | } 142 | 143 | const interval = reInterval(tickInterval, 1000) 144 | 145 | // put the start emit in a setImmediate so trackers can be added, etc. 146 | setImmediate(() => { tracker.emit('start') }) 147 | 148 | function tickInterval () { 149 | totalBytes += bytes 150 | totalCompletedRequests += counter 151 | requests.recordValue(counter) 152 | throughput.recordValue(bytes) 153 | tracker.emit('tick', { counter, bytes }) 154 | counter = 0 155 | bytes = 0 156 | 157 | if (stop) { 158 | if (stopTimer) clearTimeout(stopTimer) 159 | interval.clear() 160 | clients.forEach((client) => client.destroy()) 161 | const result = { 162 | title: opts.title, 163 | url: opts.url, 164 | socketPath: opts.socketPath, 165 | requests: addPercentiles(requests, histAsObj(requests, totalCompletedRequests)), 166 | latency: addPercentiles(latencies, histAsObj(latencies)), 167 | throughput: addPercentiles(throughput, histAsObj(throughput, totalBytes)), 168 | errors: errors, 169 | timeouts: timeouts, 170 | mismatches: mismatches, 171 | duration: Math.round((Date.now() - startTime) / 10) / 100, 172 | start: new Date(startTime), 173 | finish: new Date(), 174 | connections: opts.connections, 175 | pipelining: opts.pipelining, 176 | non2xx: statusCodes[0] + statusCodes[2] + statusCodes[3] + statusCodes[4] 177 | } 178 | result.requests.sent = totalRequests 179 | statusCodes.forEach((code, index) => { result[(index + 1) + 'xx'] = code }) 180 | if (result.requests.min === Number.MAX_SAFE_INTEGER) result.requests.min = 0 181 | if (result.throughput.min === Number.MAX_SAFE_INTEGER) result.throughput.min = 0 182 | if (result.latency.min === Number.MAX_SAFE_INTEGER) result.latency.min = 0 183 | 184 | tracker.emit('done', result) 185 | if (!opts.forever) cb(null, result) 186 | 187 | // the restart function 188 | setImmediate(() => { 189 | if (opts.forever && restart) { 190 | stop = false 191 | stopTimer = setTimeout(() => { 192 | stop = true 193 | }, opts.duration * 1000) 194 | errors = 0 195 | timeouts = 0 196 | mismatches = 0 197 | totalBytes = 0 198 | totalRequests = 0 199 | totalCompletedRequests = 0 200 | statusCodes.fill(0) 201 | requests.reset() 202 | latencies.reset() 203 | throughput.reset() 204 | startTime = Date.now() 205 | 206 | // reinitialise clients 207 | if (opts.overallRate && (opts.overallRate < opts.connections)) opts.connections = opts.overallRate 208 | clients = [] 209 | initialiseClients(clients) 210 | 211 | interval.reschedule(1000) 212 | tracker.emit('start') 213 | } 214 | }) 215 | } 216 | } 217 | 218 | function initialiseClients (clients) { 219 | for (let i = 0; i < opts.connections; i++) { 220 | if (!amount && !opts.maxConnectionRequests && opts.maxOverallRequests) { 221 | url.responseMax = distributeNums(opts.maxOverallRequests, i) 222 | } 223 | if (amount) { 224 | url.responseMax = distributeNums(amount, i) 225 | if (url.responseMax === 0) { 226 | throw Error('connections cannot be greater than amount') 227 | } 228 | } 229 | if (!opts.connectionRate && opts.overallRate) { 230 | url.rate = distributeNums(opts.overallRate, i) 231 | } 232 | 233 | const client = new Client(url) 234 | client.on('response', onResponse) 235 | client.on('connError', onError) 236 | client.on('mismatch', onExpectMismatch) 237 | client.on('timeout', onTimeout) 238 | client.on('request', () => { totalRequests++ }) 239 | client.on('done', onDone) 240 | clients.push(client) 241 | 242 | // we will miss the initial request emits because the client emits request on construction 243 | totalRequests += url.pipelining < url.rate ? url.rate : url.pipelining 244 | } 245 | 246 | function distributeNums (x, i) { 247 | return (Math.floor(x / opts.connections) + (((i + 1) <= (x % opts.connections)) ? 1 : 0)) 248 | } 249 | 250 | function onResponse (statusCode, resBytes, responseTime) { 251 | tracker.emit('response', this, statusCode, resBytes, responseTime) 252 | const codeIndex = Math.floor(parseInt(statusCode) / 100) - 1 253 | statusCodes[codeIndex] += 1 254 | // only recordValue 2xx latencies 255 | if (codeIndex === 1 || includeErrorStats) latencies.recordValue(responseTime) 256 | if (codeIndex === 1 || includeErrorStats) bytes += resBytes 257 | counter++ 258 | } 259 | 260 | function onError (error) { 261 | for (let i = 0; i < opts.pipelining; i++) tracker.emit('reqError', error) 262 | errors++ 263 | if (opts.debug) console.error(error) 264 | if (opts.bailout && errors >= opts.bailout) stop = true 265 | } 266 | 267 | function onExpectMismatch (bpdyStr) { 268 | for (let i = 0; i < opts.pipelining; i++) { 269 | tracker.emit('reqMismatch', bpdyStr) 270 | } 271 | 272 | mismatches++ 273 | if (opts.bailout && mismatches >= opts.bailout) stop = true 274 | } 275 | 276 | // treat a timeout as a special type of error 277 | function onTimeout () { 278 | const error = new Error('request timed out') 279 | for (let i = 0; i < opts.pipelining; i++) tracker.emit('reqError', error) 280 | errors++ 281 | timeouts++ 282 | if (opts.bailout && errors >= opts.bailout) stop = true 283 | } 284 | 285 | function onDone () { 286 | if (!--numRunning) stop = true 287 | } 288 | } 289 | 290 | function errorCb (error) { 291 | if (cbPassedIn) { 292 | cb(error) 293 | } else { 294 | // wrapped in setImmediate so any error event handlers that are added to 295 | // the tracker can be added before being emitted 296 | setImmediate(() => { tracker.emit('error', error) }) 297 | } 298 | } 299 | 300 | // will return true if error with opts entered 301 | function checkOptsForErrors () { 302 | if (!opts.url && !opts.socketPath) { 303 | errorCb(new Error('url or socketPath option required')) 304 | return true 305 | } 306 | 307 | if (typeof opts.duration === 'string') { 308 | if (/[a-zA-Z]/.exec(opts.duration)) opts.duration = timestring(opts.duration) 309 | else opts.duration = Number(opts.duration.trim()) 310 | } 311 | 312 | if (typeof opts.duration === 'number') { 313 | if (lessThanZeroError(opts.duration, 'duration')) return true 314 | } else { 315 | errorCb(new Error('duration entered was in an invalid format')) 316 | return true 317 | } 318 | 319 | if (opts.expectBody && opts.requests !== DefaultOptions.requests) { 320 | errorCb(new Error('expectBody cannot be used in conjunction with requests')) 321 | return true 322 | } 323 | 324 | if (lessThanOneError(opts.connections, 'connections')) return true 325 | if (lessThanOneError(opts.pipelining, 'pipelining factor')) return true 326 | if (greaterThanZeroError(opts.timeout, 'timeout')) return true 327 | if (opts.bailout && lessThanOneError(opts.bailout, 'bailout threshold')) return true 328 | if (opts.connectionRate && lessThanOneError(opts.connectionRate, 'connectionRate')) return true 329 | if (opts.overallRate && lessThanOneError(opts.overallRate, 'bailout overallRate')) return true 330 | if (opts.amount && lessThanOneError(opts.amount, 'amount')) return true 331 | if (opts.maxConnectionRequests && lessThanOneError(opts.maxConnectionRequests, 'maxConnectionRequests')) return true 332 | if (opts.maxOverallRequests && lessThanOneError(opts.maxOverallRequests, 'maxOverallRequests')) return true 333 | 334 | if (opts.forever && cbPassedIn) { 335 | errorCb(new Error('should not use the callback parameter when the `forever` option is set to true. Use the `done` event on this event emitter')) 336 | return true 337 | } 338 | 339 | function lessThanZeroError (x, label) { 340 | if (x < 0) { 341 | errorCb(new Error(`${label} can not be less than 0`)) 342 | return true 343 | } 344 | return false 345 | } 346 | 347 | function lessThanOneError (x, label) { 348 | if (x < 1) { 349 | errorCb(new Error(`${label} can not be less than 1`)) 350 | return true 351 | } 352 | return false 353 | } 354 | 355 | function greaterThanZeroError (x, label) { 356 | if (x <= 0) { 357 | errorCb(new Error(`${label} must be greater than 0`)) 358 | return true 359 | } 360 | return false 361 | } 362 | 363 | return false 364 | } // checkOptsForErrors 365 | 366 | return tracker 367 | } // run 368 | 369 | /* istanbul ignore next */ 370 | function noop () {} 371 | 372 | module.exports = run 373 | -------------------------------------------------------------------------------- /test/httpClient.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const os = require('os') 4 | const path = require('path') 5 | const test = require('tap').test 6 | const Client = require('../lib/httpClient') 7 | const helper = require('./helper') 8 | const server = helper.startServer() 9 | const timeoutServer = helper.startTimeoutServer() 10 | const httpsServer = helper.startHttpsServer() 11 | const tlsServer = helper.startTlsServer() 12 | const trailerServer = helper.startTrailerServer() 13 | const bl = require('bl') 14 | 15 | test('client calls a server twice', (t) => { 16 | t.plan(4) 17 | 18 | const client = new Client(server.address()) 19 | let count = 0 20 | 21 | client.on('response', (statusCode, length) => { 22 | t.equal(statusCode, 200, 'status code matches') 23 | t.ok(length > 'hello world'.length, 'length includes the headers') 24 | if (count++ > 0) { 25 | client.destroy() 26 | } 27 | }) 28 | }) 29 | 30 | test('client calls a https server twice', (t) => { 31 | t.plan(4) 32 | 33 | var opts = httpsServer.address() 34 | opts.protocol = 'https:' 35 | const client = new Client(opts) 36 | let count = 0 37 | 38 | client.on('response', (statusCode, length) => { 39 | t.equal(statusCode, 200, 'status code matches') 40 | t.ok(length > 'hello world'.length, 'length includes the headers') 41 | if (count++ > 0) { 42 | client.destroy() 43 | } 44 | }) 45 | }) 46 | 47 | test('client calls a tls server without SNI servername twice', (t) => { 48 | t.plan(4) 49 | 50 | var opts = tlsServer.address() 51 | opts.protocol = 'https:' 52 | const client = new Client(opts) 53 | let count = 0 54 | 55 | client.on('headers', (response) => { 56 | t.equal(response.statusCode, 200, 'status code matches') 57 | t.deepEqual(response.headers, ['X-servername', '', 'Content-Length', '0']) 58 | if (count++ > 0) { 59 | client.destroy() 60 | } 61 | }) 62 | }) 63 | 64 | test('client calls a tls server with SNI servername twice', (t) => { 65 | t.plan(4) 66 | 67 | var opts = tlsServer.address() 68 | opts.protocol = 'https:' 69 | opts.servername = 'example.com' 70 | const client = new Client(opts) 71 | let count = 0 72 | 73 | client.on('headers', (response) => { 74 | t.equal(response.statusCode, 200, 'status code matches') 75 | t.deepEqual(response.headers, ['X-servername', opts.servername, 'Content-Length', '0']) 76 | if (count++ > 0) { 77 | client.destroy() 78 | } 79 | }) 80 | }) 81 | 82 | test('http client automatically reconnects', (t) => { 83 | t.plan(4) 84 | 85 | const client = new Client(server.address()) 86 | let count = 0 87 | 88 | client.on('response', (statusCode, length) => { 89 | t.equal(statusCode, 200, 'status code matches') 90 | t.ok(length > 'hello world'.length, 'length includes the headers') 91 | if (count++ > 0) { 92 | client.destroy() 93 | } 94 | }) 95 | 96 | server.once('request', function (req, res) { 97 | setImmediate(() => { 98 | req.socket.destroy() 99 | }) 100 | }) 101 | }) 102 | 103 | test('http clients should have a different body', (t) => { 104 | t.plan(3) 105 | 106 | let clientCnt = 0 107 | const clients = [] 108 | const reqArray = ['John', 'Gabriel', 'Jason'] 109 | const opts = server.address() 110 | 111 | opts.setupClient = (client) => { 112 | client.setBody(JSON.stringify({ name: reqArray[clientCnt] })) 113 | clientCnt++ 114 | } 115 | 116 | for (let i = 0; i < 3; i++) { 117 | const client = new Client(opts) 118 | 119 | clients.push(client) 120 | } 121 | 122 | for (let i = 0; i < clients.length; i++) { 123 | const client = clients[i] 124 | const body = JSON.parse(client.requestIterator.currentRequest.body) 125 | 126 | t.equal(body.name, reqArray[i], 'body match') 127 | client.destroy() 128 | } 129 | }) 130 | 131 | test('client supports custom headers', (t) => { 132 | t.plan(3) 133 | 134 | const opts = server.address() 135 | opts.headers = { 136 | hello: 'world' 137 | } 138 | const client = new Client(opts) 139 | 140 | server.once('request', (req, res) => { 141 | t.equal(req.headers.hello, 'world', 'custom header matches') 142 | }) 143 | 144 | client.on('response', (statusCode, length) => { 145 | t.equal(statusCode, 200, 'status code matches') 146 | t.ok(length > 'hello world'.length, 'length includes the headers') 147 | client.destroy() 148 | }) 149 | }) 150 | 151 | test('client supports host custom header', (t) => { 152 | t.plan(2) 153 | 154 | const opts = server.address() 155 | opts.headers = { 156 | host: 'www.autocannon.com' 157 | } 158 | const client = new Client(opts) 159 | 160 | server.once('request', (req, res) => { 161 | t.equal(req.headers.host, 'www.autocannon.com', 'host header matches') 162 | }) 163 | 164 | client.on('response', (statusCode, length) => { 165 | t.equal(statusCode, 200, 'status code matches') 166 | client.destroy() 167 | }) 168 | }) 169 | 170 | test('client supports response trailers', (t) => { 171 | t.plan(3) 172 | 173 | const client = new Client(trailerServer.address()) 174 | let n = 0 175 | client.on('body', (raw) => { 176 | if (++n === 1) { 177 | // trailer value 178 | t.ok(/7895bf4b8828b55ceaf47747b4bca667/.test(raw.toString())) 179 | } 180 | }) 181 | client.on('response', (statusCode, length) => { 182 | t.equal(statusCode, 200, 'status code matches') 183 | t.ok(length > 'hello world'.length, 'length includes the headers') 184 | client.destroy() 185 | }) 186 | }) 187 | 188 | ; 189 | [ 190 | 'DELETE', 191 | 'POST', 192 | 'PUT' 193 | ].forEach((method) => { 194 | test(`client supports ${method}`, (t) => { 195 | t.plan(3) 196 | 197 | const opts = server.address() 198 | opts.method = method 199 | 200 | const client = new Client(opts) 201 | 202 | server.once('request', (req, res) => { 203 | t.equal(req.method, method, 'custom method matches') 204 | }) 205 | 206 | client.on('response', (statusCode, length) => { 207 | t.equal(statusCode, 200, 'status code matches') 208 | t.ok(length > 'hello world'.length, 'length includes the headers') 209 | client.destroy() 210 | }) 211 | }) 212 | }) 213 | 214 | test('client supports sending a body', (t) => { 215 | t.plan(4) 216 | 217 | const opts = server.address() 218 | opts.method = 'POST' 219 | opts.body = Buffer.from('hello world') 220 | 221 | const client = new Client(opts) 222 | 223 | server.once('request', (req, res) => { 224 | req.pipe(bl((err, body) => { 225 | t.error(err) 226 | t.deepEqual(body.toString(), opts.body.toString(), 'body matches') 227 | })) 228 | }) 229 | 230 | client.on('response', (statusCode, length) => { 231 | t.equal(statusCode, 200, 'status code matches') 232 | t.ok(length > 'hello world'.length, 'length includes the headers') 233 | client.destroy() 234 | }) 235 | }) 236 | 237 | test('client supports sending a body which is a string', (t) => { 238 | t.plan(4) 239 | 240 | const opts = server.address() 241 | opts.method = 'POST' 242 | opts.body = 'hello world' 243 | 244 | const client = new Client(opts) 245 | 246 | server.once('request', (req, res) => { 247 | req.pipe(bl((err, body) => { 248 | t.error(err) 249 | t.deepEqual(body.toString(), opts.body, 'body matches') 250 | })) 251 | }) 252 | 253 | client.on('response', (statusCode, length) => { 254 | t.equal(statusCode, 200, 'status code matches') 255 | t.ok(length > 'hello world'.length, 'length includes the headers') 256 | client.destroy() 257 | }) 258 | }) 259 | 260 | test('client supports changing the body', (t) => { 261 | t.plan(2) 262 | 263 | const opts = server.address() 264 | opts.method = 'POST' 265 | opts.body = 'hello world' 266 | 267 | const client = new Client(opts) 268 | 269 | t.same(client.getRequestBuffer(), 270 | Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 11\r\n\r\nhello world`), 271 | 'request is okay before modifying') 272 | 273 | client.setBody('modified') 274 | 275 | t.same(client.getRequestBuffer(), 276 | Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 8\r\n\r\nmodified`), 277 | 'body changes updated request') 278 | client.destroy() 279 | }) 280 | 281 | test('client supports changing the headers', (t) => { 282 | t.plan(2) 283 | 284 | const opts = server.address() 285 | opts.method = 'POST' 286 | 287 | const client = new Client(opts) 288 | t.same(client.getRequestBuffer(), 289 | Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\n\r\n`), 290 | 'request is okay before modifying') 291 | 292 | client.setHeaders({ 293 | header: 'modified' 294 | }) 295 | 296 | t.same(client.getRequestBuffer(), 297 | Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nheader: modified\r\n\r\n`), 298 | 'header changes updated request') 299 | client.destroy() 300 | }) 301 | 302 | test('client supports changing the headers and body', (t) => { 303 | t.plan(2) 304 | 305 | const opts = server.address() 306 | opts.body = 'hello world' 307 | opts.method = 'POST' 308 | 309 | const client = new Client(opts) 310 | 311 | t.same(client.getRequestBuffer(), 312 | Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 11\r\n\r\nhello world`), 313 | 'request is okay before modifying') 314 | 315 | client.setBody('modified') 316 | client.setHeaders({ 317 | header: 'modifiedHeader' 318 | }) 319 | 320 | t.same(client.getRequestBuffer(), 321 | Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nheader: modifiedHeader\r\nContent-Length: 8\r\n\r\nmodified`), 322 | 'changes updated request') 323 | client.destroy() 324 | }) 325 | 326 | test('client supports changing the headers and body together', (t) => { 327 | t.plan(2) 328 | 329 | const opts = server.address() 330 | opts.body = 'hello world' 331 | opts.method = 'POST' 332 | 333 | const client = new Client(opts) 334 | 335 | t.same(client.getRequestBuffer(), 336 | Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 11\r\n\r\nhello world`), 337 | 'request is okay before modifying') 338 | 339 | client.setHeadersAndBody({ 340 | header: 'modifiedHeader' 341 | }, 'modified') 342 | 343 | t.same(client.getRequestBuffer(), 344 | Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nheader: modifiedHeader\r\nContent-Length: 8\r\n\r\nmodified`), 345 | 'changes updated request') 346 | client.destroy() 347 | }) 348 | 349 | test('client supports changing the headers and body with null values', (t) => { 350 | t.plan(2) 351 | 352 | const opts = server.address() 353 | opts.body = 'hello world' 354 | opts.method = 'POST' 355 | 356 | const client = new Client(opts) 357 | 358 | t.same(client.getRequestBuffer(), 359 | Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 11\r\n\r\nhello world`), 360 | 'request is okay before modifying') 361 | 362 | client.setBody(null) 363 | client.setHeaders(null) 364 | 365 | t.same(client.getRequestBuffer(), 366 | Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\n\r\n`), 367 | 'changes updated request') 368 | client.destroy() 369 | }) 370 | 371 | test('client supports changing the headers and body together with null values', (t) => { 372 | t.plan(2) 373 | 374 | const opts = server.address() 375 | opts.body = 'hello world' 376 | opts.method = 'POST' 377 | 378 | const client = new Client(opts) 379 | 380 | t.same(client.getRequestBuffer(), 381 | Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 11\r\n\r\nhello world`), 382 | 'request is okay before modifying') 383 | 384 | client.setHeadersAndBody(null, null) 385 | 386 | t.same(client.getRequestBuffer(), 387 | Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\n\r\n`), 388 | 'changes updated request') 389 | client.destroy() 390 | }) 391 | 392 | test('client supports updating the current request object', (t) => { 393 | t.plan(2) 394 | 395 | const opts = server.address() 396 | opts.body = 'hello world' 397 | opts.method = 'POST' 398 | 399 | const client = new Client(opts) 400 | 401 | t.same(client.getRequestBuffer(), 402 | Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 11\r\n\r\nhello world`), 403 | 'request is okay before modifying') 404 | 405 | client.setRequest({ 406 | headers: { 407 | header: 'modifiedHeader' 408 | }, 409 | body: 'modified', 410 | method: 'GET' 411 | }) 412 | 413 | t.same(client.getRequestBuffer(), 414 | Buffer.from(`GET / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nheader: modifiedHeader\r\nContent-Length: 8\r\n\r\nmodified`), 415 | 'changes updated request') 416 | client.destroy() 417 | }) 418 | 419 | test('client customiseRequest function overwrites the headers and body', (t) => { 420 | t.plan(5) 421 | 422 | const opts = server.address() 423 | opts.body = 'hello world' 424 | opts.method = 'POST' 425 | opts.setupClient = (client) => { 426 | t.ok(client.setHeadersAndBody, 'client had setHeadersAndBody method') 427 | t.ok(client.setHeaders, 'client had setHeaders method') 428 | t.ok(client.setBody, 'client had setBody method') 429 | 430 | client.setHeadersAndBody({ 431 | header: 'modifiedHeader' 432 | }, 'modified') 433 | } 434 | 435 | const client = new Client(opts) 436 | 437 | t.same(client.getRequestBuffer(), 438 | Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nheader: modifiedHeader\r\nContent-Length: 8\r\n\r\nmodified`), 439 | 'changes updated request') 440 | 441 | t.notSame(client.getRequestBuffer(), 442 | Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 11\r\n\r\nhello world`), 443 | 'changes updated request') 444 | 445 | client.destroy() 446 | }) 447 | 448 | test('client should throw when attempting to modify the request with a pipelining greater than 1', (t) => { 449 | t.plan(1) 450 | 451 | const opts = server.address() 452 | opts.pipelining = 10 453 | const client = new Client(opts) 454 | 455 | t.throws(() => client.setHeaders({})) 456 | 457 | client.destroy() 458 | }) 459 | 460 | test('client resData length should equal pipelining when greater than 1', (t) => { 461 | t.plan(1) 462 | 463 | const opts = server.address() 464 | opts.pipelining = 10 465 | const client = new Client(opts) 466 | 467 | t.equal(client.resData.length, client.opts.pipelining) 468 | 469 | client.destroy() 470 | }) 471 | 472 | test('client should emit a timeout when no response is received', (t) => { 473 | t.plan(1) 474 | 475 | const opts = timeoutServer.address() 476 | opts.timeout = 1 477 | const client = new Client(opts) 478 | 479 | client.on('timeout', () => { 480 | t.ok(1, 'timeout should have happened') 481 | }) 482 | 483 | setTimeout(() => client.destroy(), 1500) 484 | }) 485 | 486 | test('client should emit 2 timeouts when no responses are received', (t) => { 487 | t.plan(2) 488 | 489 | const opts = timeoutServer.address() 490 | opts.timeout = 1 491 | const client = new Client(opts) 492 | 493 | client.on('timeout', () => { 494 | t.ok(1, 'timeout should have happened') 495 | }) 496 | 497 | setTimeout(() => client.destroy(), 2500) 498 | }) 499 | 500 | test('client should have 2 different requests it iterates over', (t) => { 501 | t.plan(3) 502 | const server = helper.startServer() 503 | const opts = server.address() 504 | opts.method = 'POST' 505 | 506 | const requests = [ 507 | { 508 | body: 'hello world again' 509 | }, 510 | { 511 | method: 'GET', 512 | body: 'modified' 513 | } 514 | ] 515 | 516 | const client = new Client(opts) 517 | let number = 0 518 | client.setRequests(requests) 519 | client.on('response', (statusCode, length) => { 520 | number++ 521 | switch (number) { 522 | case 1: 523 | case 3: 524 | t.same(client.getRequestBuffer(), 525 | Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 17\r\n\r\nhello world again`), 526 | 'request was okay') 527 | break 528 | case 2: 529 | t.same(client.getRequestBuffer(), 530 | Buffer.from(`GET / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 8\r\n\r\nmodified`), 531 | 'body changes updated request') 532 | break 533 | case 4: 534 | client.destroy() 535 | t.end() 536 | break 537 | } 538 | }) 539 | }) 540 | 541 | test('client supports http basic authentication', (t) => { 542 | t.plan(2) 543 | const server = helper.startServer() 544 | const opts = server.address() 545 | opts.auth = 'username:password' 546 | const client = new Client(opts) 547 | 548 | server.once('request', (req, res) => { 549 | t.equal(req.headers.authorization, 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=', 'authorization header matches') 550 | }) 551 | 552 | client.on('response', (statusCode, length) => { 553 | t.equal(statusCode, 200, 'status code matches') 554 | client.destroy() 555 | t.end() 556 | }) 557 | }) 558 | 559 | test('should return client instance', (t) => { 560 | t.plan(1) 561 | const caller = {} 562 | const opts = server.address() 563 | opts.auth = 'username:password' 564 | const client = Client.call(caller, opts) 565 | 566 | t.type(client, Client) 567 | client.destroy() 568 | }) 569 | 570 | test('client calls twice using socket on secure server', (t) => { 571 | t.plan(4) 572 | const socketPath = process.platform === 'win32' 573 | ? path.join('\\\\?\\pipe', process.cwd(), 'autocannon-' + Date.now()) 574 | : path.join(os.tmpdir(), 'autocannon-' + Date.now() + '.sock') 575 | 576 | helper.startHttpsServer({ socketPath }) 577 | const client = new Client({ 578 | url: 'localhost', 579 | protocol: 'https:', 580 | socketPath, 581 | connections: 1 582 | }) 583 | let count = 0 584 | client.on('response', (statusCode, length) => { 585 | t.equal(statusCode, 200, 'status code matches') 586 | t.ok(length > 'hello world'.length, 'length includes the headers') 587 | if (count++ > 0) { 588 | client.destroy() 589 | t.end() 590 | } 591 | }) 592 | }) 593 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![banner](autocannon-banner.png) 2 | 3 | # autocannon 4 | 5 | [![Join the chat at https://gitter.im/mcollina/autocannon](https://badges.gitter.im/mcollina/autocannon.svg)](https://gitter.im/mcollina/autocannon?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 6 | ![Node.js CI](https://github.com/mcollina/autocannon/workflows/Node.js%20CI/badge.svg) 7 | [![Greenkeeper badge](https://badges.greenkeeper.io/mcollina/autocannon.svg)](https://greenkeeper.io/) 8 | 9 | 10 | ![demo](https://raw.githubusercontent.com/mcollina/autocannon/master/demo.gif) 11 | 12 | A HTTP/1.1 benchmarking tool written in node, greatly inspired by [wrk][wrk] 13 | and [wrk2][wrk2], with support for HTTP pipelining and HTTPS. 14 | On _my_ box, *autocannon* can produce more load than `wrk` and `wrk2`. 15 | 16 | * [Installation](#install) 17 | * [Usage](#usage) 18 | * [API](#api) 19 | * [Acknowledgements](#acknowledgements) 20 | * [License](#license) 21 | 22 | ## Install 23 | 24 | ``` 25 | npm i autocannon -g 26 | ``` 27 | 28 | or if you want to use the [API](#api) or as a dependency: 29 | 30 | ``` 31 | npm i autocannon --save 32 | ``` 33 | 34 | ## Usage 35 | 36 | ### Command Line 37 | 38 | ``` 39 | Usage: autocannon [opts] URL 40 | 41 | URL is any valid http or https url. 42 | If the PORT environment variable is set, the URL can be a path. In that case 'http://localhost:$PORT/path' will be used as the URL. 43 | 44 | Available options: 45 | 46 | -c/--connections NUM 47 | The number of concurrent connections to use. default: 10. 48 | -p/--pipelining NUM 49 | The number of pipelined requests to use. default: 1. 50 | -d/--duration SEC 51 | The number of seconds to run the autocannnon. default: 10. 52 | -a/--amount NUM 53 | The amount of requests to make before exiting the benchmark. If set, duration is ignored. 54 | -S/--socketPath 55 | A path to a Unix Domain Socket or a Windows Named Pipe. A URL is still required in order to send the correct Host header and path. 56 | --on-port 57 | Start the command listed after -- on the command line. When it starts listening on a port, 58 | start sending requests to that port. A URL is still required in order to send requests to 59 | the correct path. The hostname can be omitted, `localhost` will be used by default. 60 | -m/--method METHOD 61 | The http method to use. default: 'GET'. 62 | -t/--timeout NUM 63 | The number of seconds before timing out and resetting a connection. default: 10 64 | -T/--title TITLE 65 | The title to place in the results for identification. 66 | -b/--body BODY 67 | The body of the request. 68 | Note: This option needs to be used with the '-H/--headers' option in some frameworks 69 | -F/--form FORM 70 | Upload a form (multipart/form-data). The form options can be a JSON string like 71 | '{ "field 1": { "type": "text", "value": "a text value"}, "field 2": { "type": "file", "path": "path to the file" } }' 72 | or a path to a JSON file containing the form options. 73 | When uploading a file the default filename value can be overridden by using the corresponding option: 74 | '{ "field name": { "type": "file", "path": "path to the file", "options": { "filename": "myfilename" } } }' 75 | Passing the filepath to the form can be done by using the corresponding option: 76 | '{ "field name": { "type": "file", "path": "path to the file", "options": { "filepath": "/some/path/myfilename" } } }' 77 | -i/--input FILE 78 | The body of the request. See '-b/body' for more details. 79 | -H/--headers K=V 80 | The request headers. 81 | -B/--bailout NUM 82 | The number of failures before initiating a bailout. 83 | -M/--maxConnectionRequests NUM 84 | The max number of requests to make per connection to the server. 85 | -O/--maxOverallRequests NUM 86 | The max number of requests to make overall to the server. 87 | -r/--connectionRate NUM 88 | The max number of requests to make per second from an individual connection. 89 | -R/--overallRate NUM 90 | The max number of requests to make per second from an all connections. 91 | connection rate will take precedence if both are set. 92 | NOTE: if using rate limiting and a very large rate is entered which cannot be met, 93 | Autocannon will do as many requests as possible per second. 94 | -D/--reconnectRate NUM 95 | Some number of requests to make before resetting a connections connection to the 96 | server. 97 | -n/--no-progress 98 | Don't render the progress bar. default: false. 99 | -l/--latency 100 | Print all the latency data. default: false. 101 | -I/--idReplacement 102 | Enable replacement of [] with a randomly generated ID within the request body. default: false. 103 | -j/--json 104 | Print the output as newline delimited json. This will cause the progress bar and results not to be rendered. default: false. 105 | -f/--forever 106 | Run the benchmark forever. Efficiently restarts the benchmark on completion. default: false. 107 | -s/--servername 108 | Server name for the SNI (Server Name Indication) TLS extension. 109 | -x/--excludeErrorStats 110 | Exclude error statistics (non 2xx http responses) from the final latency and bytes per second averages. default: false. 111 | -E/--expectBody EXPECTED 112 | Ensure the body matches this value. If enabled, mismatches count towards bailout. 113 | Enabling this option will slow down the load testing. 114 | -v/--version 115 | Print the version number. 116 | -h/--help 117 | Print this menu. 118 | ``` 119 | 120 | autocannon outputs data in tables like this: 121 | 122 | ``` 123 | Running 10s test @ http://localhost:3000 124 | 10 connections 125 | 126 | ┌─────────┬──────┬──────┬───────┬──────┬─────────┬─────────┬──────────┐ 127 | │ Stat │ 2.5% │ 50% │ 97.5% │ 99% │ Avg │ Stdev │ Max │ 128 | ├─────────┼──────┼──────┼───────┼──────┼─────────┼─────────┼──────────┤ 129 | │ Latency │ 0 ms │ 0 ms │ 0 ms │ 1 ms │ 0.02 ms │ 0.16 ms │ 16.45 ms │ 130 | └─────────┴──────┴──────┴───────┴──────┴─────────┴─────────┴──────────┘ 131 | ┌───────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┐ 132 | │ Stat │ 1% │ 2.5% │ 50% │ 97.5% │ Avg │ Stdev │ Min │ 133 | ├───────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤ 134 | │ Req/Sec │ 20623 │ 20623 │ 25583 │ 26271 │ 25131.2 │ 1540.94 │ 20615 │ 135 | ├───────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤ 136 | │ Bytes/Sec │ 2.29 MB │ 2.29 MB │ 2.84 MB │ 2.92 MB │ 2.79 MB │ 171 kB │ 2.29 MB │ 137 | └───────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┘ 138 | 139 | Req/Bytes counts sampled once per second. 140 | 141 | 251k requests in 10.05s, 27.9 MB read 142 | ``` 143 | 144 | There are two tables: one for the request latency, and one for the request volume. 145 | 146 | The latency table lists the request times at the 2.5% percentile, the fast outliers; at 50%, the median; at 97.5%, the slow outliers; at 99%, the very slowest outliers. Here, lower means faster. 147 | 148 | The request volume table lists the amount of requests sent and the amount of bytes downloaded. These values are sampled once per second. Higher values mean more requests were processed. In the above example, 4.78 MB was downloaded in 1 second in the worst case (slowest 1%). Since we only ran for 5 seconds, there are just 5 samples—the Min value and the 1% and 2.5% percentiles are actually all the same sample. With longer durations these numbers will differ more. 149 | 150 | When passing the `-l` flag, a third table lists all the latency percentiles recorded by autocannon: 151 | 152 | ``` 153 | ┌────────────┬──────────────┐ 154 | │ Percentile │ Latency (ms) │ 155 | ├────────────┼──────────────┤ 156 | │ 0.001 │ 0 │ 157 | ├────────────┼──────────────┤ 158 | │ 0.01 │ 0 │ 159 | ├────────────┼──────────────┤ 160 | │ 0.1 │ 0 │ 161 | ├────────────┼──────────────┤ 162 | │ 1 │ 0 │ 163 | ├────────────┼──────────────┤ 164 | │ 2.5 │ 0 │ 165 | ├────────────┼──────────────┤ 166 | │ 10 │ 0 │ 167 | ├────────────┼──────────────┤ 168 | │ 25 │ 0 │ 169 | ├────────────┼──────────────┤ 170 | │ 50 │ 0 │ 171 | ├────────────┼──────────────┤ 172 | │ 75 │ 0 │ 173 | ├────────────┼──────────────┤ 174 | │ 90 │ 0 │ 175 | ├────────────┼──────────────┤ 176 | │ 97.5 │ 0 │ 177 | ├────────────┼──────────────┤ 178 | │ 99 │ 1 │ 179 | ├────────────┼──────────────┤ 180 | │ 99.9 │ 1 │ 181 | ├────────────┼──────────────┤ 182 | │ 99.99 │ 3 │ 183 | ├────────────┼──────────────┤ 184 | │ 99.999 │ 15 │ 185 | └────────────┴──────────────┘ 186 | ``` 187 | 188 | This can give some more insight if a lot (millions) of requests were sent. 189 | 190 | ### Programmatically 191 | 192 | ```js 193 | 'use strict' 194 | 195 | const autocannon = require('autocannon') 196 | 197 | autocannon({ 198 | url: 'http://localhost:3000', 199 | connections: 10, //default 200 | pipelining: 1, // default 201 | duration: 10 // default 202 | }, console.log) 203 | 204 | // async/await 205 | async function foo () { 206 | const result = await autocannon({ 207 | url: 'http://localhost:3000', 208 | connections: 10, //default 209 | pipelining: 1, // default 210 | duration: 10 // default 211 | }) 212 | console.log(result) 213 | } 214 | 215 | ``` 216 | 217 | ## API 218 | 219 | ### autocannon(opts[, cb]) 220 | 221 | Start autocannon against the given target. 222 | 223 | * `opts`: Configuration options for the autocannon instance. This can have the following attributes. _REQUIRED_. 224 | * `url`: The given target. Can be http or https. _REQUIRED_. 225 | * `socketPath`: A path to a Unix Domain Socket or a Windows Named Pipe. A `url` is still required in order to send the correct Host header and path. _OPTIONAL_. 226 | * `connections`: The number of concurrent connections. _OPTIONAL_ default: `10`. 227 | * `duration`: The number of seconds to run the autocannon. Can be a [timestring](https://www.npmjs.com/package/timestring). _OPTIONAL_ default: `10`. 228 | * `amount`: A `Number` stating the amount of requests to make before ending the test. This overrides duration and takes precedence, so the test won't end until the amount of requests needed to be completed are completed. _OPTIONAL_. 229 | * `timeout`: The number of seconds to wait for a response before . _OPTIONAL_ default: `10`. 230 | * `pipelining`: The number of [pipelined requests](https://en.wikipedia.org/wiki/HTTP_pipelining) for each connection. Will cause the `Client` API to throw when greater than 1. _OPTIONAL_ default: `1`. 231 | * `bailout`: The threshold of the number of errors when making the requests to the server before this instance bail's out. This instance will take all existing results so far and aggregate them into the results. If none passed here, the instance will ignore errors and never bail out. _OPTIONAL_ default: `undefined`. 232 | * `method`: The http method to use. _OPTIONAL_ `default: 'GET'`. 233 | * `title`: A `String` to be added to the results for identification. _OPTIONAL_ default: `undefined`. 234 | * `body`: A `String` or a `Buffer` containing the body of the request. Insert one or more randomly generated IDs into the body by including `[]` where the randomly generated ID should be inserted (Must also set idReplacement to true). This can be useful in soak testing POST endpoints where one or more fields must be unique. Leave undefined for an empty body. _OPTIONAL_ default: `undefined`. 235 | * `form`: A `String` or an `Object` containing the multipart/form-data options or a path to the JSON file containing them 236 | * `headers`: An `Object` containing the headers of the request. _OPTIONAL_ default: `{}`. 237 | * `setupClient`: A `Function` which will be passed the `Client` object for each connection to be made. This can be used to customise each individual connection headers and body using the API shown below. The changes you make to the client in this function will take precedence over the default `body` and `headers` you pass in here. There is an example of this in the samples folder. _OPTIONAL_ default: `function noop () {}`. 238 | * `maxConnectionRequests`: A `Number` stating the max requests to make per connection. `amount` takes precedence if both are set. _OPTIONAL_ 239 | * `maxOverallRequests`: A `Number` stating the max requests to make overall. Can't be less than `connections`. `maxConnectionRequests` takes precedence if both are set. _OPTIONAL_ 240 | * `connectionRate`: A `Number` stating the rate of requests to make per second from each individual connection. No rate limiting by default. _OPTIONAL_ 241 | * `overallRate`: A `Number` stating the rate of requests to make per second from all connections. `connectionRate` takes precedence if both are set. No rate limiting by default. _OPTIONAL_ 242 | * `reconnectRate`: A `Number` which makes the individual connections disconnect and reconnect to the server whenever it has sent that number of requests. _OPTIONAL_ 243 | * `requests`: An `Array` of `Object`s which represents the sequence of requests to make while benchmarking. Can be used in conjunction with the `body`, `headers` and `method` params above. The `Object`s in this array can have `body`, `headers`, `method`, or `path` attributes, which overwrite those that are passed in this `opts` object. Therefore, the ones in this (`opts`) object take precedence and should be viewed as defaults. Additionally, an optional `setupRequest` `Function` may be provided that will mutate the raw `request` object, e.g. `request.method = 'GET'`. Check the samples folder for an example of how this might be used. _OPTIONAL_. 244 | * `idReplacement`: A `Boolean` which enables the replacement of `[]` tags within the request body with a randomly generated ID, allowing for unique fields to be sent with requests. Check out [an example of programmatic usage](./samples/using-id-replacement.js) can be found in the samples. _OPTIONAL_ default: `false` 245 | * `forever`: A `Boolean` which allows you to setup an instance of autocannon that restarts indefinitely after emiting results with the `done` event. Useful for efficiently restarting your instance. To stop running forever, you must cause a `SIGINT` or call the `.stop()` function on your instance. _OPTIONAL_ default: `false` 246 | * `servername`: A `String` identifying the server name for the SNI (Server Name Indication) TLS extension. _OPTIONAL_ default: `undefined`. 247 | * `excludeErrorStats`: A `Boolean` which allows you to disable tracking non 2xx code responses in latency and bytes per second calculations. _OPTIONAL_ default: `false`. 248 | * `expectBody`: A `String` representing the expected response body. Each request whose response body is not equal to `expectBody`is counted in `mismatches`. If enabled, mismatches count towards bailout. _OPTIONAL_ 249 | * `cb`: The callback which is called on completion of a benchmark. Takes the following params. _OPTIONAL_. 250 | * `err`: If there was an error encountered with the run. 251 | * `results`: The results of the run. 252 | 253 | **Returns** an instance/event emitter for tracking progress, etc. If cb omitted, the return value can also be used as a Promise. 254 | 255 | ### autocannon.track(instance[, opts]) 256 | 257 | Track the progress of your autocannon, programmatically. 258 | 259 | * `instance`: The instance of autocannon. _REQUIRED_. 260 | * `opts`: Configuration options for tracking. This can have the following attibutes. _OPTIONAL_. 261 | * `outputStream`: The stream to output to. default: `process.stderr`. 262 | * `renderProgressBar`: A truthy value to enable the rendering of the progress bar. default: `true`. 263 | * `renderResultsTable`: A truthy value to enable the rendering of the results table. default: `true`. 264 | * `renderLatencyTable`: A truthy value to enable the rendering of the advanced latency table. default: `false`. 265 | * `progressBarString`: A `string` defining the format of the progress display output. Must be valid input for the [progress bar module](http://npm.im/progress). default: `'running [:bar] :percent'`. 266 | 267 | Example that just prints the table of results on completion: 268 | 269 | ```js 270 | 'use strict' 271 | 272 | const autocannon = require('autocannon') 273 | 274 | const instance = autocannon({ 275 | url: 'http://localhost:3000' 276 | }, console.log) 277 | 278 | // this is used to kill the instance on CTRL-C 279 | process.once('SIGINT', () => { 280 | instance.stop() 281 | }) 282 | 283 | // just render results 284 | autocannon.track(instance, {renderProgressBar: false}) 285 | ``` 286 | 287 | Checkout [this example](./samples/track-run.js) to see it in use, as well. 288 | 289 | ### autocannon events 290 | 291 | Because an autocannon instance is an `EventEmitter`, it emits several events. these are below: 292 | 293 | * `start`: Emitted once everything has been setup in your autocannon instance and it has started. Useful for if running the instance forever. 294 | * `tick`: Emitted every second this autocannon is running a benchmark. Useful for displaying stats, etc. Used by the `track` function. The `tick` event propagates an object containing the `counter` and `bytes` values, which can be used for extended reports. 295 | * `done`: Emitted when the autocannon finishes a benchmark. passes the `results` as an argument to the callback. 296 | * `response`: Emitted when the autocannons http-client gets a http response from the server. This passes the following arguments to the callback: 297 | * `client`: The `http-client` itself. Can be used to modify the headers and body the client will send to the server. API below. 298 | * `statusCode`: The http status code of the response. 299 | * `resBytes`: The response byte length. 300 | * `responseTime`: The time taken to get a response for the initiating the request. 301 | * `reqError`: Emitted in the case of a request error e.g. a timeout. 302 | * `error`: Emitted if there is an error during the setup phase of autocannon. 303 | 304 | ### results 305 | 306 | The results object emitted by `done` and passed to the `autocannon()` callback has these properties: 307 | 308 | * `title`: Value of the `title` option passed to `autocannon()`. 309 | * `url`: The URL that was targeted. 310 | * `socketPath`: The UNIX Domain Socket or Windows Named Pipe that was targeted, or `undefined`. 311 | * `requests`: A histogram object containing statistics about the amount of requests that were sent per second. 312 | * `latency`: A histogram object containing statistics about response latency. 313 | * `throughput`: A histogram object containing statistics about the response data throughput per second. 314 | * `duration`: The amount of time the test took, **in seconds**. 315 | * `errors`: The number of connection errors (including timeouts) that occurred. 316 | * `timeouts`: The number of connection timeouts that occurred. 317 | * `mismatches`: The number of requests with a mismatched body. 318 | * `start`: A Date object representing when the test started. 319 | * `finish`: A Date object representing when the test ended. 320 | * `connections`: The amount of connections used (value of `opts.connections`). 321 | * `pipelining`: The number of pipelined requests used per connection (value of `opts.pipelining`). 322 | * `non2xx`: The number of non-2xx response status codes received. 323 | 324 | The histogram objects for `requests`, `latency` and `throughput` are [hdr-histogram-percentiles-obj](https://github.com/thekemkid/hdr-histogram-percentiles-obj) objects and have this shape: 325 | 326 | * `min`: The lowest value for this statistic. 327 | * `max`: The highest value for this statistic. 328 | * `average`: The average (mean) value. 329 | * `stddev`: The standard deviation. 330 | * `p*`: The XXth percentile value for this statistic. The percentile properties are: `p2_5`, `p50`, `p75`, `p90`, `p97_5`, `p99`, `p99_9`, `p99_99`, `p99_999`. 331 | 332 | ### `Client` API 333 | 334 | This object is passed as the first parameter of both the `setupClient` function and the `response` event from an autocannon instance. You can use this to modify the requests you are sending while benchmarking. This is also an `EventEmitter`, with the events and their params listed below. 335 | 336 | * `client.setHeaders(headers)`: Used to modify the headers of the request this client iterator is currently on. `headers` should be an `Object`, or `undefined` if you want to remove your headers. 337 | * `client.setBody(body)`: Used to modify the body of the request this client iterator is currently on. `body` should be a `String` or `Buffer`, or `undefined` if you want to remove the body. 338 | * `client.setHeadersAndBody(headers, body)`: Used to modify the both the headers and body this client iterator is currently on.`headers` and `body` should take the same form as above. 339 | * `client.setRequest(request)`: Used to modify the both the entire request that this client iterator is currently on. Can have `headers`, `body`, `method`, or `path` as attributes. Defaults to the values passed into the autocannon instance when it was created. `Note: call this when modifying multiple request values for faster encoding` 340 | * `client.setRequests(newRequests)`: Used to overwrite the entire requests array that was passed into the instance on initiation. `Note: call this when modifying multiple requests for faster encoding` 341 | 342 | ### `Client` events 343 | 344 | The events a `Client` can emit are listed here: 345 | 346 | * `headers`: Emitted when a request sent from this client has received the headers of its reply. This received an `Object` as the parameter. 347 | * `body`: Emitted when a request sent from this client has received the body of a reply. This receives a `Buffer` as the parameter. 348 | * `response`: Emitted when the client has received a completed response for a request it made. This is passed the following arguments: 349 | * `statusCode`: The http status code of the response. 350 | * `resBytes`: The response byte length. 351 | * `responseTime`: The time taken to get a response for the initiating the request. 352 | 353 | Example using the autocannon events and the client API and events: 354 | 355 | ```js 356 | 'use strict' 357 | 358 | const autocannon = require('autocannon') 359 | 360 | const instance = autocannon({ 361 | url: 'http://localhost:3000', 362 | setupClient: setupClient 363 | }, (err, result) => handleResults(result)) 364 | // results passed to the callback are the same as those emitted from the done events 365 | instance.on('done', handleResults) 366 | 367 | instance.on('tick', () => console.log('ticking')) 368 | 369 | instance.on('response', handleResponse) 370 | 371 | function setupClient (client) { 372 | client.on('body', console.log) // console.log a response body when its received 373 | } 374 | 375 | function handleResponse (client, statusCode, resBytes, responseTime) { 376 | console.log(`Got response with code ${statusCode} in ${responseTime} milliseconds`) 377 | console.log(`response: ${resBytes.toString()}`) 378 | 379 | //update the body or headers 380 | client.setHeaders({new: 'header'}) 381 | client.setBody('new body') 382 | client.setHeadersAndBody({new: 'header'}, 'new body') 383 | } 384 | 385 | function handleResults(result) { 386 | // ... 387 | } 388 | ``` 389 | 390 | 391 | ## Acknowledgements 392 | 393 | This project was kindly sponsored by [nearForm](http://nearform.com). 394 | 395 | Logo and identity designed by Cosmic Fox Design: https://www.behance.net/cosmicfox. 396 | 397 | [wrk][wrk] and [wrk2][wrk2] provided great inspiration. 398 | 399 | ### Chat on Gitter 400 | 401 | If you are using autocannon or you have any questions, let us know: [Gitter](https://gitter.im/mcollina/autocannon) 402 | 403 | ## License 404 | 405 | Copyright [Matteo Collina](https://github.com/mcollina) and other contributors, Licensed under [MIT](./LICENSE). 406 | 407 | [node-gyp]: https://github.com/nodejs/node-gyp#installation 408 | [wrk]: https://github.com/wg/wrk 409 | [wrk2]: https://github.com/giltene/wrk2 410 | -------------------------------------------------------------------------------- /test/run.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const os = require('os') 4 | const path = require('path') 5 | const test = require('tap').test 6 | const run = require('../lib/run') 7 | const defaultOptions = require('../lib/defaultOptions') 8 | const helper = require('./helper') 9 | const server = helper.startServer() 10 | 11 | test('run', (t) => { 12 | run({ 13 | url: 'http://localhost:' + server.address().port, 14 | connections: 2, 15 | duration: 2, 16 | title: 'title321' 17 | }, function (err, result) { 18 | t.error(err) 19 | 20 | t.ok(result.duration >= 2, 'duration is at least 2s') 21 | t.equal(result.title, 'title321', 'title should be what was passed in') 22 | t.equal(result.connections, 2, 'connections is the same') 23 | t.equal(result.pipelining, 1, 'pipelining is the default') 24 | 25 | t.ok(result.latency, 'latency exists') 26 | t.type(result.latency.average, 'number', 'latency.average exists') 27 | t.type(result.latency.stddev, 'number', 'latency.stddev exists') 28 | t.ok(result.latency.min >= 0, 'latency.min exists') 29 | t.type(result.latency.max, 'number', 'latency.max exists') 30 | t.type(result.latency.p2_5, 'number', 'latency.p2_5 (2.5%) exists') 31 | t.type(result.latency.p50, 'number', 'latency.p50 (50%) exists') 32 | t.type(result.latency.p97_5, 'number', 'latency.p97_5 (97.5%) exists') 33 | t.type(result.latency.p99, 'number', 'latency.p99 (99%) exists') 34 | 35 | t.ok(result.requests, 'requests exists') 36 | t.type(result.requests.average, 'number', 'requests.average exists') 37 | t.type(result.requests.stddev, 'number', 'requests.stddev exists') 38 | t.type(result.requests.min, 'number', 'requests.min exists') 39 | t.type(result.requests.max, 'number', 'requests.max exists') 40 | t.ok(result.requests.total >= result.requests.average * 2 / 100 * 95, 'requests.total exists') 41 | t.type(result.requests.sent, 'number', 'sent exists') 42 | t.ok(result.requests.sent >= result.requests.total, 'total requests made should be more than or equal to completed requests total') 43 | t.type(result.requests.p1, 'number', 'requests.p1 (1%) exists') 44 | t.type(result.requests.p2_5, 'number', 'requests.p2_5 (2.5%) exists') 45 | t.type(result.requests.p50, 'number', 'requests.p50 (50%) exists') 46 | t.type(result.requests.p97_5, 'number', 'requests.p97_5 (97.5%) exists') 47 | 48 | t.ok(result.throughput, 'throughput exists') 49 | t.type(result.throughput.average, 'number', 'throughput.average exists') 50 | t.type(result.throughput.stddev, 'number', 'throughput.stddev exists') 51 | t.type(result.throughput.min, 'number', 'throughput.min exists') 52 | t.type(result.throughput.max, 'number', 'throughput.max exists') 53 | t.ok(result.throughput.total >= result.throughput.average * 2 / 100 * 95, 'throughput.total exists') 54 | t.type(result.throughput.p1, 'number', 'throughput.p1 (1%) exists') 55 | t.type(result.throughput.p2_5, 'number', 'throughput.p2_5 (2.5%) exists') 56 | t.type(result.throughput.p50, 'number', 'throughput.p50 (50%) exists') 57 | t.type(result.throughput.p97_5, 'number', 'throughput.p97_5 (97.5%) exists') 58 | 59 | t.ok(result.start, 'start time exists') 60 | t.ok(result.finish, 'finish time exists') 61 | 62 | t.equal(result.errors, 0, 'no errors') 63 | t.equal(result.mismatches, 0, 'no mismatches') 64 | 65 | t.equal(result['1xx'], 0, '1xx codes') 66 | t.equal(result['2xx'], result.requests.total, '2xx codes') 67 | t.equal(result['3xx'], 0, '3xx codes') 68 | t.equal(result['4xx'], 0, '4xx codes') 69 | t.equal(result['5xx'], 0, '5xx codes') 70 | t.equal(result.non2xx, 0, 'non 2xx codes') 71 | 72 | t.end() 73 | }) 74 | }) 75 | 76 | test('tracker.stop()', (t) => { 77 | const tracker = run({ 78 | url: 'http://localhost:' + server.address().port, 79 | connections: 2, 80 | duration: 2 81 | }, function (err, result) { 82 | t.error(err) 83 | 84 | t.ok(result.duration < 5, 'duration is lower because of stop') 85 | t.notOk(result.title, 'title should not exist when not passed in') 86 | t.equal(result.connections, 2, 'connections is the same') 87 | t.equal(result.pipelining, 1, 'pipelining is the default') 88 | 89 | t.ok(result.latency, 'latency exists') 90 | t.type(result.latency.average, 'number', 'latency.average exists') 91 | t.type(result.latency.stddev, 'number', 'latency.stddev exists') 92 | t.ok(result.latency.min >= 0, 'latency.min exists') 93 | t.type(result.latency.max, 'number', 'latency.max exists') 94 | t.type(result.latency.p2_5, 'number', 'latency.p2_5 (2.5%) exists') 95 | t.type(result.latency.p50, 'number', 'latency.p50 (50%) exists') 96 | t.type(result.latency.p97_5, 'number', 'latency.p97_5 (97.5%) exists') 97 | t.type(result.latency.p99, 'number', 'latency.p99 (99%) exists') 98 | 99 | t.ok(result.requests, 'requests exists') 100 | t.type(result.requests.average, 'number', 'requests.average exists') 101 | t.type(result.requests.stddev, 'number', 'requests.stddev exists') 102 | t.type(result.requests.min, 'number', 'requests.min exists') 103 | t.type(result.requests.max, 'number', 'requests.max exists') 104 | t.ok(result.requests.total >= result.requests.average * 2 / 100 * 95, 'requests.total exists') 105 | t.type(result.requests.sent, 'number', 'sent exists') 106 | t.ok(result.requests.sent >= result.requests.total, 'total requests made should be more than or equal to completed requests total') 107 | t.type(result.requests.p1, 'number', 'requests.p1 (1%) exists') 108 | t.type(result.requests.p2_5, 'number', 'requests.p2_5 (2.5%) exists') 109 | t.type(result.requests.p50, 'number', 'requests.p50 (50%) exists') 110 | t.type(result.requests.p97_5, 'number', 'requests.p97_5 (97.5%) exists') 111 | 112 | t.ok(result.throughput, 'throughput exists') 113 | t.type(result.throughput.average, 'number', 'throughput.average exists') 114 | t.type(result.throughput.stddev, 'number', 'throughput.stddev exists') 115 | t.type(result.throughput.min, 'number', 'throughput.min exists') 116 | t.type(result.throughput.max, 'number', 'throughput.max exists') 117 | t.ok(result.throughput.total >= result.throughput.average * 2 / 100 * 95, 'throughput.total exists') 118 | t.type(result.throughput.p1, 'number', 'throughput.p1 (1%) exists') 119 | t.type(result.throughput.p2_5, 'number', 'throughput.p2_5 (2.5%) exists') 120 | t.type(result.throughput.p50, 'number', 'throughput.p50 (50%) exists') 121 | t.type(result.throughput.p97_5, 'number', 'throughput.p97_5 (97.5%) exists') 122 | 123 | t.ok(result.start, 'start time exists') 124 | t.ok(result.finish, 'finish time exists') 125 | 126 | t.equal(result.errors, 0, 'no errors') 127 | t.equal(result.mismatches, 0, 'no mismatches') 128 | 129 | t.equal(result['1xx'], 0, '1xx codes') 130 | t.equal(result['2xx'], result.requests.total, '2xx codes') 131 | t.equal(result['3xx'], 0, '3xx codes') 132 | t.equal(result['4xx'], 0, '4xx codes') 133 | t.equal(result['5xx'], 0, '5xx codes') 134 | t.equal(result.non2xx, 0, 'non 2xx codes') 135 | 136 | t.end() 137 | }) 138 | 139 | t.ok(tracker.opts, 'opts exist on tracker') 140 | 141 | setTimeout(() => { 142 | tracker.stop() 143 | }, 1000) 144 | }) 145 | 146 | test('requests.min should be 0 when there are no successful requests', (t) => { 147 | run({ 148 | url: 'nonexistent', 149 | connections: 1, 150 | amount: 1 151 | }, function (err, result) { 152 | t.error(err) 153 | t.equal(result.requests.min, 0, 'requests.min should be 0') 154 | t.end() 155 | }) 156 | }) 157 | 158 | test('run should callback with an error with an invalid connections factor', (t) => { 159 | t.plan(2) 160 | 161 | run({ 162 | url: 'http://localhost:' + server.address().port, 163 | connections: -1 164 | }, function (err, result) { 165 | t.ok(err, 'invalid connections should cause an error') 166 | t.notOk(result, 'results should not exist') 167 | t.end() 168 | }) 169 | }) 170 | 171 | test('run should callback with an error with an invalid pipelining factor', (t) => { 172 | t.plan(2) 173 | 174 | run({ 175 | url: 'http://localhost:' + server.address().port, 176 | pipelining: -1 177 | }, function (err, result) { 178 | t.ok(err, 'invalid pipelining should cause an error') 179 | t.notOk(result, 'results should not exist') 180 | t.end() 181 | }) 182 | }) 183 | 184 | test('run should callback with an error with an invalid bailout', (t) => { 185 | t.plan(2) 186 | 187 | run({ 188 | url: 'http://localhost:' + server.address().port, 189 | bailout: -1 190 | }, function (err, result) { 191 | t.ok(err, 'invalid bailout should cause an error') 192 | t.notOk(result, 'results should not exist') 193 | t.end() 194 | }) 195 | }) 196 | 197 | test('run should callback with an error with an invalid duration', (t) => { 198 | t.plan(2) 199 | 200 | run({ 201 | url: 'http://localhost:' + server.address().port, 202 | duration: -1 203 | }, function (err, result) { 204 | t.ok(err, 'invalid duration should cause an error') 205 | t.notOk(result, 'results should not exist') 206 | t.end() 207 | }) 208 | }) 209 | 210 | test('run should callback with an error when no url is passed in', (t) => { 211 | t.plan(2) 212 | 213 | run({}, function (err, result) { 214 | t.ok(err, 'no url should cause an error') 215 | t.notOk(result, 'results should not exist') 216 | t.end() 217 | }) 218 | }) 219 | 220 | test('run should callback with an error after a bailout', (t) => { 221 | t.plan(2) 222 | 223 | run({ 224 | url: 'http://localhost:4', // 4 = first unassigned port: https://en.wikipedia.org/wiki/List_of_TCP_and_UDP_port_numbers 225 | bailout: 1 226 | }, function (err, result) { 227 | t.error(err) 228 | t.ok(result, 'results should not exist') 229 | t.end() 230 | }) 231 | }) 232 | 233 | test('run should callback with an error using expectBody and requests', (t) => { 234 | t.plan(2) 235 | 236 | run({ 237 | url: 'http://localhost:' + server.address().port, 238 | requests: [{ body: 'something' }], 239 | expectBody: 'hello' 240 | }, function (err, result) { 241 | t.ok(err, 'expectBody used with requests should cause an error') 242 | t.notOk(result, 'results should not exist') 243 | t.end() 244 | }) 245 | }) 246 | 247 | test('run should allow users to enter timestrings to be used for duration', (t) => { 248 | t.plan(3) 249 | 250 | const instance = run({ 251 | url: 'http://localhost:' + server.address().port, 252 | duration: '10m' 253 | }, function (err, result) { 254 | t.error(err) 255 | t.ok(result, 'results should exist') 256 | t.end() 257 | }) 258 | 259 | t.equal(instance.opts.duration, 10 * 60, 'duration should have been parsed to be 600 seconds (10m)') 260 | 261 | setTimeout(() => { 262 | instance.stop() 263 | }, 500) 264 | }) 265 | 266 | test('run should recognise valid urls without http at the start', (t) => { 267 | t.plan(3) 268 | 269 | run({ 270 | url: 'localhost:' + server.address().port, 271 | duration: 1 272 | }, (err, res) => { 273 | t.error(err) 274 | t.ok(res, 'results should exist') 275 | t.equal(res.url, 'http://localhost:' + server.address().port, 'url should have http:// added to start') 276 | t.end() 277 | }) 278 | }) 279 | 280 | test('run should produce count of mismatches with expectBody set', (t) => { 281 | t.plan(2) 282 | 283 | run({ 284 | url: 'http://localhost:' + server.address().port, 285 | expectBody: 'body will not be this', 286 | maxOverallRequests: 10, 287 | timeout: 100 288 | }, function (err, result) { 289 | t.error(err) 290 | t.equal(result.mismatches, 10) 291 | t.end() 292 | }) 293 | }) 294 | 295 | test('run should produce 0 mismatches with expectBody set and matches', (t) => { 296 | t.plan(2) 297 | 298 | const responseBody = 'hello dave' 299 | const server = helper.startServer({ body: responseBody }) 300 | 301 | run({ 302 | url: 'http://localhost:' + server.address().port, 303 | expectBody: responseBody, 304 | maxOverallRequests: 10 305 | }, function (err, result) { 306 | t.error(err) 307 | t.equal(result.mismatches, 0) 308 | t.end() 309 | }) 310 | }) 311 | 312 | test('run should accept a unix socket/windows pipe', (t) => { 313 | t.plan(11) 314 | 315 | const socketPath = process.platform === 'win32' 316 | ? path.join('\\\\?\\pipe', process.cwd(), 'autocannon-' + Date.now()) 317 | : path.join(os.tmpdir(), 'autocannon-' + Date.now() + '.sock') 318 | 319 | helper.startServer({ socketPath }) 320 | 321 | run({ 322 | url: 'localhost', 323 | socketPath, 324 | connections: 2, 325 | duration: 2 326 | }, (err, result) => { 327 | t.error(err) 328 | t.ok(result, 'results should exist') 329 | t.equal(result.socketPath, socketPath, 'socketPath should be included in result') 330 | t.ok(result.requests.total > 0, 'should make at least one request') 331 | 332 | if (process.platform === 'win32') { 333 | // On Windows a few errors are expected. We'll accept a 1% error rate on 334 | // the pipe. 335 | t.ok(result.errors / result.requests.total < 0.01, `should have less than 1% errors on Windows (had ${result.errors} errors)`) 336 | } else { 337 | t.equal(result.errors, 0, 'no errors') 338 | } 339 | 340 | t.equal(result['1xx'], 0, '1xx codes') 341 | t.equal(result['2xx'], result.requests.total, '2xx codes') 342 | t.equal(result['3xx'], 0, '3xx codes') 343 | t.equal(result['4xx'], 0, '4xx codes') 344 | t.equal(result['5xx'], 0, '5xx codes') 345 | t.equal(result.non2xx, 0, 'non 2xx codes') 346 | t.end() 347 | }) 348 | }) 349 | 350 | for (let i = 1; i <= 5; i++) { 351 | test(`run should count all ${i}xx status codes`, (t) => { 352 | const server = helper.startServer({ statusCode: i * 100 + 2 }) 353 | 354 | run({ 355 | url: `http://localhost:${server.address().port}`, 356 | connections: 2, 357 | duration: 2 358 | }, (err, result) => { 359 | t.error(err) 360 | 361 | t.ok(result[`${i}xx`], `${i}xx status codes recorded`) 362 | 363 | t.ok(result.latency, 'latency exists') 364 | t.ok(!Number.isNaN(result.latency.average), 'latency.average is not NaN') 365 | t.type(result.latency.average, 'number', 'latency.average exists') 366 | t.type(result.latency.stddev, 'number', 'latency.stddev exists') 367 | t.ok(result.latency.min >= 0, 'latency.min exists') 368 | t.type(result.latency.max, 'number', 'latency.max exists') 369 | t.type(result.latency.p2_5, 'number', 'latency.p2_5 (2.5%) exists') 370 | t.type(result.latency.p50, 'number', 'latency.p50 (50%) exists') 371 | t.type(result.latency.p97_5, 'number', 'latency.p97_5 (97.5%) exists') 372 | t.type(result.latency.p99, 'number', 'latency.p99 (99%) exists') 373 | 374 | t.ok(result.throughput, 'throughput exists') 375 | t.ok(!Number.isNaN(result.throughput.average), 'throughput.average is not NaN') 376 | t.type(result.throughput.average, 'number', 'throughput.average exists') 377 | t.type(result.throughput.stddev, 'number', 'throughput.stddev exists') 378 | t.type(result.throughput.min, 'number', 'throughput.min exists') 379 | t.type(result.throughput.max, 'number', 'throughput.max exists') 380 | t.ok(result.throughput.total >= result.throughput.average * 2 / 100 * 95, 'throughput.total exists') 381 | t.type(result.throughput.p1, 'number', 'throughput.p1 (1%) exists') 382 | t.type(result.throughput.p2_5, 'number', 'throughput.p2_5 (2.5%) exists') 383 | t.type(result.throughput.p50, 'number', 'throughput.p50 (50%) exists') 384 | t.type(result.throughput.p97_5, 'number', 'throughput.p97_5 (97.5%) exists') 385 | 386 | t.end() 387 | }) 388 | }) 389 | } 390 | 391 | test('run should not modify default options', (t) => { 392 | const origin = Object.assign({}, defaultOptions) 393 | run({ 394 | url: 'http://localhost:' + server.address().port, 395 | connections: 2, 396 | duration: 2 397 | }, function (err, result) { 398 | t.error(err) 399 | t.deepEqual(defaultOptions, origin, 'calling run function does not modify default options') 400 | t.end() 401 | }) 402 | }) 403 | 404 | test('run will exclude non 2xx stats from latency and throughput averages if excludeErrorStats is true', (t) => { 405 | const server = helper.startServer({ statusCode: 404 }) 406 | 407 | run({ 408 | url: `http://localhost:${server.address().port}`, 409 | connections: 2, 410 | duration: 2, 411 | excludeErrorStats: true 412 | }, (err, result) => { 413 | t.error(err) 414 | 415 | t.equal(result['1xx'], 0, '1xx codes') 416 | t.equal(result['2xx'], 0, '2xx codes') 417 | t.equal(result['3xx'], 0, '3xx codes') 418 | t.equal(result['4xx'], result.requests.total, '4xx codes') 419 | t.equal(result['5xx'], 0, '5xx codes') 420 | t.equal(result.non2xx, result.requests.total, 'non 2xx codes') 421 | 422 | t.ok(result.latency, 'latency exists') 423 | t.equal(result.latency.average, 0, 'latency.average should be 0') 424 | t.equal(result.latency.stddev, 0, 'latency.stddev should be 0') 425 | t.equal(result.latency.min, 0, 'latency.min should be 0') 426 | t.equal(result.latency.max, 0, 'latency.max should be 0') 427 | t.equal(result.latency.p1, 0, 'latency.p1 (1%) should be 0') 428 | t.equal(result.latency.p2_5, 0, 'latency.p2_5 (2.5%) should be 0') 429 | t.equal(result.latency.p50, 0, 'latency.p50 (50%) should be 0') 430 | t.equal(result.latency.p97_5, 0, 'latency.p97_5 (97.5%) should be 0') 431 | t.equal(result.latency.p99, 0, 'latency.p99 (99%) should be 0') 432 | 433 | t.ok(result.throughput, 'throughput exists') 434 | t.equal(result.throughput.average, 0, 'throughput.average should be 0') 435 | t.equal(result.throughput.stddev, 0, 'throughput.stddev should be 0') 436 | t.equal(result.throughput.min, 0, 'throughput.min should be 0') 437 | t.equal(result.throughput.max, 0, 'throughput.max should be 0') 438 | t.equal(result.throughput.total, 0, 'throughput.total should be 0') 439 | t.equal(result.throughput.p1, 0, 'throughput.p1 (1%) should be 0') 440 | t.equal(result.throughput.p2_5, 0, 'throughput.p2_5 (2.5%) should be 0') 441 | t.equal(result.throughput.p50, 0, 'throughput.p50 (50%) should be 0') 442 | t.equal(result.throughput.p97_5, 0, 'throughput.p97_5 (97.5%) should be 0') 443 | 444 | t.end() 445 | }) 446 | }) 447 | 448 | test('tracker will emit reqError with error message on timeout', (t) => { 449 | t.plan(2) 450 | 451 | const server = helper.startTimeoutServer() 452 | 453 | const tracker = run({ 454 | url: `http://localhost:${server.address().port}`, 455 | connections: 1, 456 | duration: 5, 457 | timeout: 2, 458 | bailout: 1, 459 | excludeErrorStats: true 460 | }) 461 | 462 | tracker.once('reqError', (err) => { 463 | t.type(err, Error, 'reqError should pass an Error to listener') 464 | t.equal(err.message, 'request timed out', 'error should indicate timeout') 465 | tracker.stop() 466 | }) 467 | }) 468 | 469 | test('tracker will emit reqError with error message on error', (t) => { 470 | t.plan(2) 471 | 472 | const server = helper.startSocketDestroyingServer() 473 | 474 | const tracker = run({ 475 | url: `http://localhost:${server.address().port}`, 476 | connections: 10, 477 | duration: 15, 478 | method: 'POST', 479 | body: 'hello', 480 | excludeErrorStats: true 481 | }) 482 | 483 | tracker.once('reqError', (err) => { 484 | t.type(err, Error, 'reqError should pass an Error to listener') 485 | t.ok(err.message, 'err.message should have a value') 486 | tracker.stop() 487 | }) 488 | }) 489 | 490 | test('tracker will emit reqMismatch when body does not match expectBody', (t) => { 491 | t.plan(2) 492 | 493 | const responseBody = 'hello world' 494 | const server = helper.startServer({ body: responseBody }) 495 | 496 | const expectBody = 'goodbye world' 497 | 498 | const tracker = run({ 499 | url: `http://localhost:${server.address().port}`, 500 | connections: 10, 501 | duration: 15, 502 | method: 'GET', 503 | body: 'hello', 504 | expectBody 505 | }) 506 | 507 | tracker.once('reqMismatch', (bodyStr) => { 508 | t.equal(bodyStr, responseBody) 509 | t.notEqual(bodyStr, expectBody) 510 | tracker.stop() 511 | }) 512 | }) 513 | 514 | test('tracker will emit tick with current counter value', (t) => { 515 | t.plan(1) 516 | 517 | const server = helper.startSocketDestroyingServer() 518 | 519 | const tracker = run({ 520 | url: `http://localhost:${server.address().port}`, 521 | connections: 10, 522 | duration: 10 523 | }) 524 | 525 | tracker.once('tick', (counter) => { 526 | t.type(counter, 'object') 527 | tracker.stop() 528 | }) 529 | }) 530 | 531 | test('throw if connections is greater than amount', (t) => { 532 | t.plan(1) 533 | 534 | const server = helper.startSocketDestroyingServer() 535 | 536 | t.throws(function () { 537 | run({ 538 | url: `http://localhost:${server.address().port}`, 539 | connections: 10, 540 | amount: 1, 541 | excludeErrorStats: true 542 | }, () => {}) 543 | }) 544 | }) 545 | 546 | test('run promise', (t) => { 547 | run({ 548 | url: 'http://localhost:' + server.address().port, 549 | connections: 2, 550 | duration: 2, 551 | title: 'title321' 552 | }).then(result => { 553 | t.ok(result.duration >= 2, 'duration is at least 2s') 554 | t.equal(result.title, 'title321', 'title should be what was passed in') 555 | t.equal(result.connections, 2, 'connections is the same') 556 | t.equal(result.pipelining, 1, 'pipelining is the default') 557 | 558 | t.ok(result.latency, 'latency exists') 559 | t.type(result.latency.average, 'number', 'latency.average exists') 560 | t.type(result.latency.stddev, 'number', 'latency.stddev exists') 561 | t.ok(result.latency.min >= 0, 'latency.min exists') 562 | t.type(result.latency.max, 'number', 'latency.max exists') 563 | t.type(result.latency.p2_5, 'number', 'latency.p2_5 (2.5%) exists') 564 | t.type(result.latency.p50, 'number', 'latency.p50 (50%) exists') 565 | t.type(result.latency.p97_5, 'number', 'latency.p97_5 (97.5%) exists') 566 | t.type(result.latency.p99, 'number', 'latency.p99 (99%) exists') 567 | 568 | t.ok(result.requests, 'requests exists') 569 | t.type(result.requests.average, 'number', 'requests.average exists') 570 | t.type(result.requests.stddev, 'number', 'requests.stddev exists') 571 | t.type(result.requests.min, 'number', 'requests.min exists') 572 | t.type(result.requests.max, 'number', 'requests.max exists') 573 | t.ok(result.requests.total >= result.requests.average * 2 / 100 * 95, 'requests.total exists') 574 | t.type(result.requests.sent, 'number', 'sent exists') 575 | t.ok(result.requests.sent >= result.requests.total, 'total requests made should be more than or equal to completed requests total') 576 | t.type(result.requests.p1, 'number', 'requests.p1 (1%) exists') 577 | t.type(result.requests.p2_5, 'number', 'requests.p2_5 (2.5%) exists') 578 | t.type(result.requests.p50, 'number', 'requests.p50 (50%) exists') 579 | t.type(result.requests.p97_5, 'number', 'requests.p97_5 (97.5%) exists') 580 | 581 | t.ok(result.throughput, 'throughput exists') 582 | t.type(result.throughput.average, 'number', 'throughput.average exists') 583 | t.type(result.throughput.stddev, 'number', 'throughput.stddev exists') 584 | t.type(result.throughput.min, 'number', 'throughput.min exists') 585 | t.type(result.throughput.max, 'number', 'throughput.max exists') 586 | t.ok(result.throughput.total >= result.throughput.average * 2 / 100 * 95, 'throughput.total exists') 587 | t.type(result.throughput.p1, 'number', 'throughput.p1 (1%) exists') 588 | t.type(result.throughput.p2_5, 'number', 'throughput.p2_5 (2.5%) exists') 589 | t.type(result.throughput.p50, 'number', 'throughput.p50 (50%) exists') 590 | t.type(result.throughput.p97_5, 'number', 'throughput.p97_5 (97.5%) exists') 591 | 592 | t.ok(result.start, 'start time exists') 593 | t.ok(result.finish, 'finish time exists') 594 | 595 | t.equal(result.errors, 0, 'no errors') 596 | 597 | t.equal(result['1xx'], 0, '1xx codes') 598 | t.equal(result['2xx'], result.requests.total, '2xx codes') 599 | t.equal(result['3xx'], 0, '3xx codes') 600 | t.equal(result['4xx'], 0, '4xx codes') 601 | t.equal(result['5xx'], 0, '5xx codes') 602 | t.equal(result.non2xx, 0, 'non 2xx codes') 603 | 604 | t.end() 605 | }) 606 | }) 607 | 608 | test('should throw if duration is not a number nor a string', t => { 609 | t.plan(1) 610 | const server = helper.startServer() 611 | run({ 612 | url: 'http://localhost:' + server.address().port, 613 | connections: 2, 614 | duration: ['foobar'], 615 | title: 'title321' 616 | }) 617 | .then((result) => { 618 | t.fail() 619 | }) 620 | .catch((err) => { 621 | t.is(err.message, 'duration entered was in an invalid format') 622 | }) 623 | }) 624 | 625 | test('should emit error', t => { 626 | t.plan(1) 627 | const server = helper.startServer() 628 | const tracker = run({ 629 | url: `http://unknownhost:${server.address().port}`, 630 | connections: 1, 631 | timeout: 100, 632 | forever: true, 633 | form: { 634 | param1: { 635 | type: 'string', 636 | value: null // this will trigger an error 637 | } 638 | } 639 | }) 640 | 641 | tracker.once('error', (error) => { 642 | t.is(error.message, 'A \'type\' key with value \'text\' or \'file\' should be specified') 643 | t.end() 644 | }) 645 | }) 646 | 647 | test('should throw if timeout is less than zero', t => { 648 | t.plan(1) 649 | const server = helper.startServer() 650 | run({ 651 | url: 'http://localhost:' + server.address().port, 652 | connections: 2, 653 | timeout: -1, 654 | title: 'title321' 655 | }) 656 | .then((result) => { 657 | t.fail() 658 | }) 659 | .catch((err) => { 660 | t.is(err.message, 'timeout must be greater than 0') 661 | }) 662 | }) 663 | 664 | test('should handle duration in string format', t => { 665 | t.plan(1) 666 | const server = helper.startServer() 667 | run({ 668 | url: 'http://localhost:' + server.address().port, 669 | connections: 2, 670 | duration: '1', 671 | title: 'title321' 672 | }).then((result) => { 673 | t.pass() 674 | }) 675 | }) 676 | --------------------------------------------------------------------------------