diff options
Diffstat (limited to '')
-rw-r--r-- | test/agent.js | 782 |
1 files changed, 782 insertions, 0 deletions
diff --git a/test/agent.js b/test/agent.js new file mode 100644 index 0000000..65afd8b --- /dev/null +++ b/test/agent.js @@ -0,0 +1,782 @@ +'use strict' + +const { test, teardown } = require('tap') +const http = require('http') +const { PassThrough } = require('stream') +const { kRunning } = require('../lib/core/symbols') +const { + Agent, + errors, + request, + stream, + pipeline, + Pool, + setGlobalDispatcher, + getGlobalDispatcher +} = require('../') +const importFresh = require('import-fresh') + +test('setGlobalDispatcher', t => { + t.plan(2) + + t.test('fails if agent does not implement `get` method', t => { + t.plan(1) + t.throws(() => setGlobalDispatcher({ dispatch: 'not a function' }), errors.InvalidArgumentError) + }) + + t.test('sets global agent', t => { + t.plan(2) + t.doesNotThrow(() => setGlobalDispatcher(new Agent())) + t.doesNotThrow(() => setGlobalDispatcher({ dispatch: () => {} })) + }) + + t.teardown(() => { + // reset globalAgent to a fresh Agent instance for later tests + setGlobalDispatcher(new Agent()) + }) +}) + +test('Agent', t => { + t.plan(1) + + t.doesNotThrow(() => new Agent()) +}) + +test('agent should call callback after closing internal pools', t => { + t.plan(2) + + const wanted = 'payload' + + const server = http.createServer((req, res) => { + res.setHeader('Content-Type', 'text/plain') + res.end(wanted) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const dispatcher = new Agent() + + const origin = `http://localhost:${server.address().port}` + + request(origin, { dispatcher }) + .then(() => { + t.pass('first request should resolve') + }) + .catch(err => { + t.fail(err) + }) + + dispatcher.once('connect', () => { + dispatcher.close(() => { + request(origin, { dispatcher }) + .then(() => { + t.fail('second request should not resolve') + }) + .catch(err => { + t.type(err, errors.ClientDestroyedError) + }) + }) + }) + }) +}) + +test('agent close throws when callback is not a function', t => { + t.plan(1) + const dispatcher = new Agent() + try { + dispatcher.close({}) + } catch (err) { + t.type(err, errors.InvalidArgumentError) + } +}) + +test('agent should close internal pools', t => { + t.plan(2) + + const wanted = 'payload' + + const server = http.createServer((req, res) => { + res.setHeader('Content-Type', 'text/plain') + res.end(wanted) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const dispatcher = new Agent() + + const origin = `http://localhost:${server.address().port}` + + request(origin, { dispatcher }) + .then(() => { + t.pass('first request should resolve') + }) + .catch(err => { + t.fail(err) + }) + + dispatcher.once('connect', () => { + dispatcher.close() + .then(() => request(origin, { dispatcher })) + .then(() => { + t.fail('second request should not resolve') + }) + .catch(err => { + t.type(err, errors.ClientDestroyedError) + }) + }) + }) +}) + +test('agent should destroy internal pools and call callback', t => { + t.plan(2) + + const wanted = 'payload' + + const server = http.createServer((req, res) => { + res.setHeader('Content-Type', 'text/plain') + res.end(wanted) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const dispatcher = new Agent() + + const origin = `http://localhost:${server.address().port}` + + request(origin, { dispatcher }) + .then(() => { + t.fail() + }) + .catch(err => { + t.type(err, errors.ClientDestroyedError) + }) + + dispatcher.once('connect', () => { + dispatcher.destroy(() => { + request(origin, { dispatcher }) + .then(() => { + t.fail() + }) + .catch(err => { + t.type(err, errors.ClientDestroyedError) + }) + }) + }) + }) +}) + +test('agent destroy throws when callback is not a function', t => { + t.plan(1) + const dispatcher = new Agent() + try { + dispatcher.destroy(new Error('mock error'), {}) + } catch (err) { + t.type(err, errors.InvalidArgumentError) + } +}) + +test('agent close/destroy callback with error', t => { + t.plan(4) + const dispatcher = new Agent() + t.equal(dispatcher.closed, false) + dispatcher.close() + t.equal(dispatcher.closed, true) + t.equal(dispatcher.destroyed, false) + dispatcher.destroy(new Error('mock error')) + t.equal(dispatcher.destroyed, true) +}) + +test('agent should destroy internal pools', t => { + t.plan(2) + + const wanted = 'payload' + + const server = http.createServer((req, res) => { + res.setHeader('Content-Type', 'text/plain') + res.end(wanted) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const dispatcher = new Agent() + + const origin = `http://localhost:${server.address().port}` + + request(origin, { dispatcher }) + .then(() => { + t.fail() + }) + .catch(err => { + t.type(err, errors.ClientDestroyedError) + }) + + dispatcher.once('connect', () => { + dispatcher.destroy() + .then(() => request(origin, { dispatcher })) + .then(() => { + t.fail() + }) + .catch(err => { + t.type(err, errors.ClientDestroyedError) + }) + }) + }) +}) + +test('multiple connections', t => { + const connections = 3 + t.plan(6 * connections) + + const server = http.createServer((req, res) => { + res.writeHead(200, { + Connection: 'keep-alive', + 'Keep-Alive': 'timeout=1s' + }) + res.end('ok') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const origin = `http://localhost:${server.address().port}` + const dispatcher = new Agent({ connections }) + + t.teardown(dispatcher.close.bind(dispatcher)) + + dispatcher.on('connect', (origin, [dispatcher]) => { + t.ok(dispatcher) + }) + dispatcher.on('disconnect', (origin, [dispatcher], error) => { + t.ok(dispatcher) + t.type(error, errors.InformationalError) + t.equal(error.code, 'UND_ERR_INFO') + t.equal(error.message, 'reset') + }) + + for (let i = 0; i < connections; i++) { + await request(origin, { dispatcher }) + .then(() => { + t.pass('should pass') + }) + .catch(err => { + t.fail(err) + }) + } + }) +}) + +test('agent factory supports URL parameter', (t) => { + t.plan(2) + + const noopHandler = { + onConnect () {}, + onHeaders () {}, + onData () {}, + onComplete () { + server.close() + }, + onError (err) { + throw err + } + } + + const dispatcher = new Agent({ + factory: (origin, opts) => { + t.ok(origin instanceof URL) + return new Pool(origin, opts) + } + }) + + const server = http.createServer((req, res) => { + res.setHeader('Content-Type', 'text/plain') + res.end('asd') + }) + + server.listen(0, () => { + t.doesNotThrow(() => dispatcher.dispatch({ + origin: new URL(`http://localhost:${server.address().port}`), + path: '/', + method: 'GET' + }, noopHandler)) + }) +}) + +test('agent factory supports string parameter', (t) => { + t.plan(2) + + const noopHandler = { + onConnect () {}, + onHeaders () {}, + onData () {}, + onComplete () { + server.close() + }, + onError (err) { + throw err + } + } + + const dispatcher = new Agent({ + factory: (origin, opts) => { + t.ok(typeof origin === 'string') + return new Pool(origin, opts) + } + }) + + const server = http.createServer((req, res) => { + res.setHeader('Content-Type', 'text/plain') + res.end('asd') + }) + + server.listen(0, () => { + t.doesNotThrow(() => dispatcher.dispatch({ + origin: `http://localhost:${server.address().port}`, + path: '/', + method: 'GET' + }, noopHandler)) + }) +}) + +test('with globalAgent', t => { + t.plan(6) + const wanted = 'payload' + + const server = http.createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + t.equal(`localhost:${server.address().port}`, req.headers.host) + res.setHeader('Content-Type', 'text/plain') + res.end(wanted) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + request(`http://localhost:${server.address().port}`) + .then(({ statusCode, headers, body }) => { + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal(wanted, Buffer.concat(bufs).toString('utf8')) + }) + }) + .catch(err => { + t.fail(err) + }) + }) +}) + +test('with local agent', t => { + t.plan(6) + const wanted = 'payload' + + const server = http.createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + t.equal(`localhost:${server.address().port}`, req.headers.host) + res.setHeader('Content-Type', 'text/plain') + res.end(wanted) + }) + + t.teardown(server.close.bind(server)) + + const dispatcher = new Agent({ + connect: { + servername: 'agent1' + } + }) + + server.listen(0, () => { + request(`http://localhost:${server.address().port}`, { dispatcher }) + .then(({ statusCode, headers, body }) => { + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal(wanted, Buffer.concat(bufs).toString('utf8')) + }) + }) + .catch(err => { + t.fail(err) + }) + }) +}) + +test('fails with invalid args', t => { + t.throws(() => request(), errors.InvalidArgumentError, 'throws on missing url argument') + t.throws(() => request(''), errors.InvalidArgumentError, 'throws on invalid url') + t.throws(() => request({}), errors.InvalidArgumentError, 'throws on missing url.origin argument') + t.throws(() => request({ origin: '' }), errors.InvalidArgumentError, 'throws on invalid url.origin argument') + t.throws(() => request('https://example.com', { path: 0 }), errors.InvalidArgumentError, 'throws on opts.path argument') + t.throws(() => request('https://example.com', { agent: new Agent() }), errors.InvalidArgumentError, 'throws on opts.path argument') + t.throws(() => request('https://example.com', 'asd'), errors.InvalidArgumentError, 'throws on non object opts argument') + t.end() +}) + +test('with globalAgent', t => { + t.plan(6) + const wanted = 'payload' + + const server = http.createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + t.equal(`localhost:${server.address().port}`, req.headers.host) + res.setHeader('Content-Type', 'text/plain') + res.end(wanted) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + stream( + `http://localhost:${server.address().port}`, + { + opaque: new PassThrough() + }, + ({ statusCode, headers, opaque: pt }) => { + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + pt.on('data', (buf) => { + bufs.push(buf) + }) + pt.on('end', () => { + t.equal(wanted, Buffer.concat(bufs).toString('utf8')) + }) + pt.on('error', () => { + t.fail() + }) + return pt + } + ) + }) +}) + +test('with a local agent', t => { + t.plan(9) + const wanted = 'payload' + + const server = http.createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + t.equal(`localhost:${server.address().port}`, req.headers.host) + res.setHeader('Content-Type', 'text/plain') + res.end(wanted) + }) + + t.teardown(server.close.bind(server)) + + const dispatcher = new Agent() + + dispatcher.on('connect', (origin, [dispatcher]) => { + t.ok(dispatcher) + t.equal(dispatcher[kRunning], 0) + process.nextTick(() => { + t.equal(dispatcher[kRunning], 1) + }) + }) + + server.listen(0, () => { + stream( + `http://localhost:${server.address().port}`, + { + dispatcher, + opaque: new PassThrough() + }, + ({ statusCode, headers, opaque: pt }) => { + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + const bufs = [] + pt.on('data', (buf) => { + bufs.push(buf) + }) + pt.on('end', () => { + t.equal(wanted, Buffer.concat(bufs).toString('utf8')) + }) + pt.on('error', () => { + t.fail() + }) + return pt + } + ) + }) +}) + +test('stream: fails with invalid URL', t => { + t.plan(4) + t.throws(() => stream(), errors.InvalidArgumentError, 'throws on missing url argument') + t.throws(() => stream(''), errors.InvalidArgumentError, 'throws on invalid url') + t.throws(() => stream({}), errors.InvalidArgumentError, 'throws on missing url.origin argument') + t.throws(() => stream({ origin: '' }), errors.InvalidArgumentError, 'throws on invalid url.origin argument') +}) + +test('with globalAgent', t => { + t.plan(6) + const wanted = 'payload' + + const server = http.createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + t.equal(`localhost:${server.address().port}`, req.headers.host) + res.setHeader('Content-Type', 'text/plain') + res.end(wanted) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + const bufs = [] + + pipeline( + `http://localhost:${server.address().port}`, + {}, + ({ statusCode, headers, body }) => { + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + return body + } + ) + .end() + .on('data', buf => { + bufs.push(buf) + }) + .on('end', () => { + t.equal(wanted, Buffer.concat(bufs).toString('utf8')) + }) + .on('error', () => { + t.fail() + }) + }) +}) + +test('with a local agent', t => { + t.plan(6) + const wanted = 'payload' + + const server = http.createServer((req, res) => { + t.equal('/', req.url) + t.equal('GET', req.method) + t.equal(`localhost:${server.address().port}`, req.headers.host) + res.setHeader('Content-Type', 'text/plain') + res.end(wanted) + }) + + t.teardown(server.close.bind(server)) + + const dispatcher = new Agent() + + server.listen(0, () => { + const bufs = [] + + pipeline( + `http://localhost:${server.address().port}`, + { dispatcher }, + ({ statusCode, headers, body }) => { + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'text/plain') + return body + } + ) + .end() + .on('data', buf => { + bufs.push(buf) + }) + .on('end', () => { + t.equal(wanted, Buffer.concat(bufs).toString('utf8')) + }) + .on('error', () => { + t.fail() + }) + }) +}) + +test('pipeline: fails with invalid URL', t => { + t.plan(4) + t.throws(() => pipeline(), errors.InvalidArgumentError, 'throws on missing url argument') + t.throws(() => pipeline(''), errors.InvalidArgumentError, 'throws on invalid url') + t.throws(() => pipeline({}), errors.InvalidArgumentError, 'throws on missing url.origin argument') + t.throws(() => pipeline({ origin: '' }), errors.InvalidArgumentError, 'throws on invalid url.origin argument') +}) + +test('pipeline: fails with invalid onInfo', (t) => { + t.plan(2) + pipeline({ origin: 'http://localhost', path: '/', onInfo: 'foo' }, () => {}).on('error', (err) => { + t.type(err, errors.InvalidArgumentError) + t.equal(err.message, 'invalid onInfo callback') + }) +}) + +test('request: fails with invalid onInfo', async (t) => { + try { + await request({ origin: 'http://localhost', path: '/', onInfo: 'foo' }) + t.fail('should throw') + } catch (e) { + t.ok(e) + t.equal(e.message, 'invalid onInfo callback') + } + t.end() +}) + +test('stream: fails with invalid onInfo', async (t) => { + try { + await stream({ origin: 'http://localhost', path: '/', onInfo: 'foo' }, () => new PassThrough()) + t.fail('should throw') + } catch (e) { + t.ok(e) + t.equal(e.message, 'invalid onInfo callback') + } + t.end() +}) + +test('constructor validations', t => { + t.plan(4) + t.throws(() => new Agent({ factory: 'ASD' }), errors.InvalidArgumentError, 'throws on invalid opts argument') + t.throws(() => new Agent({ maxRedirections: 'ASD' }), errors.InvalidArgumentError, 'throws on invalid opts argument') + t.throws(() => new Agent({ maxRedirections: -1 }), errors.InvalidArgumentError, 'throws on invalid opts argument') + t.throws(() => new Agent({ maxRedirections: null }), errors.InvalidArgumentError, 'throws on invalid opts argument') +}) + +test('dispatch validations', t => { + const dispatcher = new Agent() + + const noopHandler = { + onConnect () {}, + onHeaders () {}, + onData () {}, + onComplete () { + server.close() + }, + onError (err) { + throw err + } + } + + const server = http.createServer((req, res) => { + res.setHeader('Content-Type', 'text/plain') + res.end('asd') + }) + + t.plan(6) + t.throws(() => dispatcher.dispatch('ASD'), errors.InvalidArgumentError, 'throws on missing handler') + t.throws(() => dispatcher.dispatch('ASD', noopHandler), errors.InvalidArgumentError, 'throws on invalid opts argument type') + t.throws(() => dispatcher.dispatch({}, noopHandler), errors.InvalidArgumentError, 'throws on invalid opts.origin argument') + t.throws(() => dispatcher.dispatch({ origin: '' }, noopHandler), errors.InvalidArgumentError, 'throws on invalid opts.origin argument') + t.throws(() => dispatcher.dispatch({}, {}), errors.InvalidArgumentError, 'throws on invalid handler.onError') + + server.listen(0, () => { + t.doesNotThrow(() => dispatcher.dispatch({ + origin: new URL(`http://localhost:${server.address().port}`), + path: '/', + method: 'GET' + }, noopHandler)) + }) +}) + +test('drain', t => { + t.plan(2) + + const dispatcher = new Agent({ + connections: 1, + pipelining: 1 + }) + + dispatcher.on('drain', () => { + t.pass() + }) + + class Handler { + onConnect () {} + onHeaders () {} + onData () {} + onComplete () {} + onError () { + t.fail() + } + } + + const server = http.createServer((req, res) => { + res.setHeader('Content-Type', 'text/plain') + res.end('asd') + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + t.equal(dispatcher.dispatch({ + origin: `http://localhost:${server.address().port}`, + method: 'GET', + path: '/' + }, new Handler()), false) + }) +}) + +test('global api', t => { + t.plan(6 * 2) + + const server = http.createServer((req, res) => { + if (req.url === '/bar') { + t.equal(req.method, 'PUT') + t.equal(req.url, '/bar') + } else { + t.equal(req.method, 'GET') + t.equal(req.url, '/foo') + } + req.pipe(res) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const origin = `http://localhost:${server.address().port}` + await request(origin, { path: '/foo' }) + await request(`${origin}/foo`) + await request({ origin, path: '/foo' }) + await stream({ origin, path: '/foo' }, () => new PassThrough()) + await request({ protocol: 'http:', hostname: 'localhost', port: server.address().port, path: '/foo' }) + await request(`${origin}/bar`, { body: 'asd' }) + }) +}) + +test('global api throws', t => { + const origin = 'http://asd' + t.throws(() => request(`${origin}/foo`, { path: '/foo' }), errors.InvalidArgumentError) + t.throws(() => request({ origin, path: 0 }, { path: '/foo' }), errors.InvalidArgumentError) + t.throws(() => request({ origin, pathname: 0 }, { path: '/foo' }), errors.InvalidArgumentError) + t.throws(() => request({ origin: 0 }, { path: '/foo' }), errors.InvalidArgumentError) + t.throws(() => request(0), errors.InvalidArgumentError) + t.throws(() => request(1), errors.InvalidArgumentError) + t.end() +}) + +test('unreachable request rejects and can be caught', t => { + t.plan(1) + + request('https://thisis.not/avalid/url').catch(() => { + t.pass() + }) +}) + +test('connect is not valid', t => { + t.plan(1) + + t.throws(() => new Agent({ connect: false }), errors.InvalidArgumentError, 'connect must be a function or an object') +}) + +test('the dispatcher is truly global', t => { + const agent = getGlobalDispatcher() + const undiciFresh = importFresh('../index.js') + t.equal(agent, undiciFresh.getGlobalDispatcher()) + t.end() +}) + +teardown(() => process.exit()) |