Skip to content

Commit

Permalink
Merge branch 'master' of github.com:mcollina/autocannon
Browse files Browse the repository at this point in the history
  • Loading branch information
mcollina committed Sep 30, 2020
2 parents d6138e0 + eb60fff commit f2b7ef0
Show file tree
Hide file tree
Showing 12 changed files with 1,736 additions and 49 deletions.
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ Available options:
The body of the request. See '-b/body' for more details.
-H/--headers K=V
The request headers.
--har FILE
When provided, Autocannon will use requests from the HAR file.
CAUTION: you have to to specify one (or more) domain using URL option: only the HAR requests to the same domains will be considered.
NOTE: you can still add extra headers with -H/--headers but -m/--method, -F/--form, -i/--input -b/--body will be ignored.
-B/--bailout NUM
The number of failures before initiating a bailout.
-M/--maxConnectionRequests NUM
Expand Down Expand Up @@ -246,12 +250,13 @@ Start autocannon against the given target.
* `ignoreCoordinatedOmission`: A `Boolean` which disable the correction of latencies to compensate the coordinated omission issue. Does not make sense when no rate of requests has been specified (`connectionRate` or `overallRate`). _OPTIONAL_ default: `false`.
* `reconnectRate`: A `Number` which makes the individual connections disconnect and reconnect to the server whenever it has sent that number of requests. _OPTIONAL_
* `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. Check the samples folder for an example of how this might be used. _OPTIONAL_. Contained objects can have these attributes:
* `body`: When present, will override `opts.body`. _OPTIONAL_.
* `headers`: When present, will override `opts.headers`. _OPTIONAL_.
* `method`: When present, will override `opts.method`. _OPTIONAL_.
* `path`: When present, will override `opts.path`. _OPTIONAL_.
* `setupRequest`: A `Function` you may provide to mutate the raw `request` object, e.g. `request.method = 'GET'`. It takes `request` (Object) and `context` (Object) parameters, and must return the modified request. When it returns a falsey value, autocannon will restart from first request. _OPTIONAL_.
* `onResponse`: A `Function` you may provide to process the received response. It takes `status` (Number), `body` (String) and `context` (Object) parameters. _OPTIONAL_.
* `body`: When present, will override `opts.body`. _OPTIONAL_
* `headers`: When present, will override `opts.headers`. _OPTIONAL_
* `method`: When present, will override `opts.method`. _OPTIONAL_
* `path`: When present, will override `opts.path`. _OPTIONAL_
* `setupRequest`: A `Function` you may provide to mutate the raw `request` object, e.g. `request.method = 'GET'`. It takes `request` (Object) and `context` (Object) parameters, and must return the modified request. When it returns a falsey value, autocannon will restart from first request. _OPTIONAL_
* `onResponse`: A `Function` you may provide to process the received response. It takes `status` (Number), `body` (String) and `context` (Object) parameters. _OPTIONAL_
* `har`: an `Object` of parsed [HAR](https://w3c.github.io/web-performance/specs/HAR/Overview.html) content. Autocannon will extra and use `entries.request`: `requests`, `method`, `form` and `body` options will be ignored. _NOTE_: you must ensure that entries are targeting the same domain as `url` option. _OPTIONAL_
* `idReplacement`: A `Boolean` which enables the replacement of `[<id>]` 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`
* `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`
* `servername`: A `String` identifying the server name for the SNI (Server Name Indication) TLS extension. _OPTIONAL_ default: Defaults to the hostname of the URL when it is not an IP address.
Expand Down
17 changes: 17 additions & 0 deletions autocannon.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const help = fs.readFileSync(path.join(__dirname, 'help.txt'), 'utf8')
const run = require('./lib/run')
const track = require('./lib/progressTracker')
const { checkURL, ofURL } = require('./lib/url')
const { parseHAR } = require('./lib/parseHAR')

if (typeof URL !== 'function') {
console.error('autocannon requires the WHATWG URL API, but it is not available. Please upgrade to Node 6.13+.')
Expand Down Expand Up @@ -156,6 +157,22 @@ function parseArguments (argvs) {
}, {})
}

if (argv.har) {
try {
argv.har = JSON.parse(fs.readFileSync(argv.har))
// warn users about skipped HAR requests
const requestsByOrigin = parseHAR(argv.har)
const allowed = ofURL(argv.url, true).map(url => new URL(url).origin)
for (const [origin] of requestsByOrigin) {
if (!allowed.includes(origin)) {
console.error(`Warning: skipping requests to '${origin}' as the target is ${allowed.join(', ')}`)
}
}
} catch (err) {
throw new Error(`Failed to load HAR file content: ${err.message}`)
}
}

return argv
}

Expand Down
60 changes: 60 additions & 0 deletions lib/parseHAR.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
'use strict'

// given we support node v8
// eslint-disable-next-line node/no-deprecated-api
const { parse } = require('url')

function parseHAR (har) {
const requestsPerOrigin = new Map()
try {
if (!har || typeof har !== 'object' || typeof har.log !== 'object' || !Array.isArray(har.log.entries) || !har.log.entries.length) {
throw new Error('no entries found')
}
let i = 0
for (const entry of har.log.entries) {
i++
if (!entry || typeof entry !== 'object' || !entry.request || typeof entry.request !== 'object') {
throw new Error(`invalid request in entry #${i}`)
}
const { request: { method, url, headers: headerArray, postData } } = entry
// turn headers array to headers object
const headers = {}
if (!Array.isArray(headerArray)) {
throw new Error(`invalid headers array in entry #${i}`)
}
let j = 0
for (const header of headerArray) {
j++
if (!header || typeof header !== 'object' || typeof header.name !== 'string' || typeof header.value !== 'string') {
throw new Error(`invalid name or value in header #${j} of entry #${i}`)
}
const { name, value } = header
headers[name] = value
}
const { path, hash, host, protocol } = parse(url)
const origin = `${protocol}//${host}`

let requests = requestsPerOrigin.get(origin)
if (!requests) {
requests = []
requestsPerOrigin.set(origin, requests)
}
const request = {
origin,
method,
// only keep path & hash as our HttpClient will handle origin
path: `${path}${hash || ''}`,
headers
}
if (typeof postData === 'object' && typeof postData.text === 'string') {
request.body = postData.text
}
requests.push(request)
}
} catch (err) {
throw new Error(`Could not parse HAR content: ${err.message}`)
}
return requestsPerOrigin
}

exports.parseHAR = parseHAR
15 changes: 14 additions & 1 deletion lib/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const multipart = require('./multipart')
const histUtil = require('hdr-histogram-percentiles-obj')
const reInterval = require('reinterval')
const { ofURL, checkURL } = require('./url')
const { parseHAR } = require('./parseHAR')
const histAsObj = histUtil.histAsObj
const addPercentiles = histUtil.addPercentiles

Expand Down Expand Up @@ -118,6 +119,16 @@ function _run (opts, cb, tracker) {
return url
})

let harRequests = new Map()
if (opts.har) {
try {
harRequests = parseHAR(opts.har)
} catch (error) {
errorCb(error)
return tracker
}
}

const urls = ofURL(opts.url, true).map(url => {
if (url.indexOf('http') !== 0) url = 'http://' + url
url = URL.parse(url) // eslint-disable-line node/no-deprecated-api
Expand All @@ -130,7 +141,9 @@ function _run (opts, cb, tracker) {
url.headers = form ? Object.assign({}, opts.headers, form.getHeaders()) : opts.headers
url.setupClient = opts.setupClient
url.timeout = opts.timeout
url.requests = opts.requests
url.origin = `${url.protocol}//${url.host}`
// only keep requests for that origin, or default to requests from options
url.requests = harRequests.get(url.origin) || opts.requests
url.reconnectRate = opts.reconnectRate
url.responseMax = amount || opts.maxConnectionRequests || opts.maxOverallRequests
url.rate = opts.connectionRate || opts.overallRate
Expand Down
228 changes: 186 additions & 42 deletions test/cli.test.js
Original file line number Diff line number Diff line change
@@ -1,54 +1,198 @@
'use strict'

const t = require('tap')
const test = require('tap').test
const split = require('split2')
const path = require('path')
const fs = require('fs')
const os = require('os')
const childProcess = require('child_process')
const helper = require('./helper')

const lines = [
/Running 1s test @ .*$/,
/10 connections.*$/,
/$/,
/.*/,
/Stat.*2\.5%.*50%.*97\.5%.*99%.*Avg.*Stdev.*Max.*$/,
/.*/,
/Latency.*$/,
/$/,
/.*/,
/Stat.*1%.*2\.5%.*50%.*97\.5%.*Avg.*Stdev.*Min.*$/,
/.*/,
/Req\/Sec.*$/,
/.*/,
/Bytes\/Sec.*$/,
/.*/,
/$/,
/Req\/Bytes counts sampled once per second.*$/,
/$/,
/.* requests in ([0-9]|\.)+s, .* read/
]

t.plan(lines.length * 2)

const server = helper.startServer()
const url = 'http://localhost:' + server.address().port

const child = childProcess.spawn(process.execPath, [path.join(__dirname, '..'), '-d', '1', url], {
cwd: __dirname,
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: false
test('should run benchmark against server', (t) => {
const lines = [
/Running 1s test @ .*$/,
/10 connections.*$/,
/$/,
/.*/,
/Stat.*2\.5%.*50%.*97\.5%.*99%.*Avg.*Stdev.*Max.*$/,
/.*/,
/Latency.*$/,
/$/,
/.*/,
/Stat.*1%.*2\.5%.*50%.*97\.5%.*Avg.*Stdev.*Min.*$/,
/.*/,
/Req\/Sec.*$/,
/.*/,
/Bytes\/Sec.*$/,
/.*/,
/$/,
/Req\/Bytes counts sampled once per second.*$/,
/$/,
/.* requests in ([0-9]|\.)+s, .* read/
]

t.plan(lines.length * 2)

const server = helper.startServer()
const url = 'http://localhost:' + server.address().port

const child = childProcess.spawn(process.execPath, [path.join(__dirname, '..'), '-d', '1', url], {
cwd: __dirname,
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: false
})

t.tearDown(() => {
child.kill()
})

child
.stderr
.pipe(split())
.on('data', (line) => {
const regexp = lines.shift()
t.ok(regexp, 'we are expecting this line')
t.ok(regexp.test(line), 'line matches ' + regexp)
})
.on('end', t.end)
})

t.tearDown(() => {
child.kill()
test('should parse HAR file and run requests', (t) => {
const lines = [
/Running \d+ requests test @ .*$/,
/1 connections.*$/,
/$/,
/.*/,
/Stat.*2\.5%.*50%.*97\.5%.*99%.*Avg.*Stdev.*Max.*$/,
/.*/,
/Latency.*$/,
/$/,
/.*/,
/Stat.*1%.*2\.5%.*50%.*97\.5%.*Avg.*Stdev.*Min.*$/,
/.*/,
/Req\/Sec.*$/,
/.*/,
/Bytes\/Sec.*$/,
/.*/,
/$/,
/Req\/Bytes counts sampled once per second.*$/,
/$/,
/.* requests in ([0-9]|\.)+s, .* read/
]

t.plan(lines.length)

const server = helper.startServer()
const url = `http://localhost:${server.address().port}`
const harPath = path.join(os.tmpdir(), 'autocannon-test.har')
const har = helper.customizeHAR('./fixtures/httpbin-simple-get.json', 'https://httpbin.org', url)
fs.writeFileSync(harPath, JSON.stringify(har))

const child = childProcess.spawn(process.execPath, [path.join(__dirname, '..'), '-a', 4, '-c', 1, '--har', harPath, url], {
cwd: __dirname,
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: false
})

t.tearDown(() => {
child.kill()
})

child
.stderr
.pipe(split())
.on('data', (line) => {
const regexp = lines.shift()
t.ok(regexp.test(line), `"${line}" matches ${regexp}`)
})
.on('end', t.end)
})

child
.stderr
.pipe(split())
.on('data', (line) => {
const regexp = lines.shift()
t.ok(regexp, 'we are expecting this line')
t.ok(regexp.test(line), 'line matches ' + regexp)
test('should throw on unknown HAR file', (t) => {
t.plan(1)

const child = childProcess.spawn(process.execPath, [path.join(__dirname, '..'), '-a', 4, '-c', 1, '--har', 'does not exist', 'http://localhost'], {
cwd: __dirname,
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: false
})

t.tearDown(() => {
child.kill()
})

const lines = []
child
.stderr
.pipe(split())
.on('data', line => lines.push(line))
.on('end', () => {
const output = lines.join('\n')
t.ok(output.includes('Error: Failed to load HAR file content: ENOENT'), `Unexpected output:\n${output}`)
t.end()
})
})

test('should throw on invalid HAR file', (t) => {
t.plan(1)

const harPath = path.join(os.tmpdir(), 'autocannon-test.har')
fs.writeFileSync(harPath, 'not valid JSON content')

const child = childProcess.spawn(process.execPath, [path.join(__dirname, '..'), '-a', 4, '-c', 1, '--har', harPath, 'http://localhost'], {
cwd: __dirname,
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: false
})

t.tearDown(() => {
child.kill()
})

const lines = []
child
.stderr
.pipe(split())
.on('data', line => lines.push(line))
.on('end', () => {
const output = lines.join('\n')
t.ok(output.includes('Error: Failed to load HAR file content: Unexpected token'), `Unexpected output:\n${output}`)
t.end()
})
})

test('should write warning about unused HAR requests', (t) => {
t.plan(1)

const server = helper.startServer()
const url = `http://localhost:${server.address().port}`
const harPath = path.join(os.tmpdir(), 'autocannon-test.har')
const har = helper.customizeHAR('./fixtures/multi-domains.json', 'https://httpbin.org', url)
fs.writeFileSync(harPath, JSON.stringify(har))

const child = childProcess.spawn(process.execPath, [path.join(__dirname, '..'), '-a', 4, '-c', 1, '--har', harPath, url], {
cwd: __dirname,
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: false
})

t.tearDown(() => {
child.kill()
})

const lines = []
child
.stderr
.pipe(split())
.on('data', line => lines.push(line))
.on('end', () => {
const output = lines.join('\n')
t.ok(output.includes(`Warning: skipping requests to 'https://github.com' as the target is ${url}`), `Unexpected output:\n${output}`)
t.end()
})
})

0 comments on commit f2b7ef0

Please sign in to comment.