UNPKG

9.16 kBJavaScriptView Raw
1'use strict';
2
3/**
4 * Module dependencies.
5 */
6
7const { inspect } = require('util');
8const { STATUS_CODES } = require('http');
9const { Server } = require('https');
10const { deepStrictEqual } = require('assert');
11const { Request } = require('superagent');
12
13/** @typedef {import('superagent').Response} Response */
14
15class Test extends Request {
16 /**
17 * Initialize a new `Test` with the given `app`,
18 * request `method` and `path`.
19 *
20 * @param {Server} app
21 * @param {String} method
22 * @param {String} path
23 * @api public
24 */
25 constructor (app, method, path) {
26 super(method.toUpperCase(), path);
27
28 this.redirects(0);
29 this.buffer();
30 this.app = app;
31 this._asserts = [];
32 this.url = typeof app === 'string'
33 ? app + path
34 : this.serverAddress(app, path);
35 }
36
37 /**
38 * Returns a URL, extracted from a server.
39 *
40 * @param {Server} app
41 * @param {String} path
42 * @returns {String} URL address
43 * @api private
44 */
45 serverAddress(app, path) {
46 const addr = app.address();
47
48 if (!addr) this._server = app.listen(0);
49 const port = app.address().port;
50 const protocol = app instanceof Server ? 'https' : 'http';
51 return protocol + '://127.0.0.1:' + port + path;
52 }
53
54 /**
55 * Expectations:
56 *
57 * .expect(200)
58 * .expect(200, fn)
59 * .expect(200, body)
60 * .expect('Some body')
61 * .expect('Some body', fn)
62 * .expect(['json array body', { key: 'val' }])
63 * .expect('Content-Type', 'application/json')
64 * .expect('Content-Type', 'application/json', fn)
65 * .expect(fn)
66 * .expect([200, 404])
67 *
68 * @return {Test}
69 * @api public
70 */
71 expect(a, b, c) {
72 // callback
73 if (typeof a === 'function') {
74 this._asserts.push(wrapAssertFn(a));
75 return this;
76 }
77 if (typeof b === 'function') this.end(b);
78 if (typeof c === 'function') this.end(c);
79
80 // status
81 if (typeof a === 'number') {
82 this._asserts.push(wrapAssertFn(this._assertStatus.bind(this, a)));
83 // body
84 if (typeof b !== 'function' && arguments.length > 1) {
85 this._asserts.push(wrapAssertFn(this._assertBody.bind(this, b)));
86 }
87 return this;
88 }
89
90 // multiple statuses
91 if (Array.isArray(a) && a.length > 0 && a.every(val => typeof val === 'number')) {
92 this._asserts.push(wrapAssertFn(this._assertStatusArray.bind(this, a)));
93 return this;
94 }
95
96 // header field
97 if (typeof b === 'string' || typeof b === 'number' || b instanceof RegExp) {
98 this._asserts.push(wrapAssertFn(this._assertHeader.bind(this, { name: '' + a, value: b })));
99 return this;
100 }
101
102 // body
103 this._asserts.push(wrapAssertFn(this._assertBody.bind(this, a)));
104
105 return this;
106 }
107
108 /**
109 * Defer invoking superagent's `.end()` until
110 * the server is listening.
111 *
112 * @param {Function} fn
113 * @api public
114 */
115 end(fn) {
116 const server = this._server;
117
118 super.end((err, res) => {
119 const localAssert = () => {
120 this.assert(err, res, fn);
121 };
122
123 if (server && server._handle) return server.close(localAssert);
124
125 localAssert();
126 });
127
128 return this;
129 }
130
131 /**
132 * Perform assertions and invoke `fn(err, res)`.
133 *
134 * @param {?Error} resError
135 * @param {Response} res
136 * @param {Function} fn
137 * @api private
138 */
139 assert(resError, res, fn) {
140 let errorObj;
141
142 // check for unexpected network errors or server not running/reachable errors
143 // when there is no response and superagent sends back a System Error
144 // do not check further for other asserts, if any, in such case
145 // https://nodejs.org/api/errors.html#errors_common_system_errors
146 const sysErrors = {
147 ECONNREFUSED: 'Connection refused',
148 ECONNRESET: 'Connection reset by peer',
149 EPIPE: 'Broken pipe',
150 ETIMEDOUT: 'Operation timed out'
151 };
152
153 if (!res && resError) {
154 if (resError instanceof Error && resError.syscall === 'connect'
155 && Object.getOwnPropertyNames(sysErrors).indexOf(resError.code) >= 0) {
156 errorObj = new Error(resError.code + ': ' + sysErrors[resError.code]);
157 } else {
158 errorObj = resError;
159 }
160 }
161
162 // asserts
163 for (let i = 0; i < this._asserts.length && !errorObj; i += 1) {
164 errorObj = this._assertFunction(this._asserts[i], res);
165 }
166
167 // set unexpected superagent error if no other error has occurred.
168 if (!errorObj && resError instanceof Error && (!res || resError.status !== res.status)) {
169 errorObj = resError;
170 }
171
172 fn.call(this, errorObj || null, res);
173 }
174
175 /**
176 * Perform assertions on a response body and return an Error upon failure.
177 *
178 * @param {Mixed} body
179 * @param {Response} res
180 * @return {?Error}
181 * @api private
182 */// eslint-disable-next-line class-methods-use-this
183 _assertBody(body, res) {
184 const isRegexp = body instanceof RegExp;
185
186 // parsed
187 if (typeof body === 'object' && !isRegexp) {
188 try {
189 deepStrictEqual(body, res.body);
190 } catch (err) {
191 const a = inspect(body);
192 const b = inspect(res.body);
193 return error('expected ' + a + ' response body, got ' + b, body, res.body);
194 }
195 } else if (body !== res.text) {
196 // string
197 const a = inspect(body);
198 const b = inspect(res.text);
199
200 // regexp
201 if (isRegexp) {
202 if (!body.test(res.text)) {
203 return error('expected body ' + b + ' to match ' + body, body, res.body);
204 }
205 } else {
206 return error('expected ' + a + ' response body, got ' + b, body, res.body);
207 }
208 }
209 }
210
211 /**
212 * Perform assertions on a response header and return an Error upon failure.
213 *
214 * @param {Object} header
215 * @param {Response} res
216 * @return {?Error}
217 * @api private
218 */// eslint-disable-next-line class-methods-use-this
219 _assertHeader(header, res) {
220 const field = header.name;
221 const actual = res.header[field.toLowerCase()];
222 const fieldExpected = header.value;
223
224 if (typeof actual === 'undefined') return new Error('expected "' + field + '" header field');
225 // This check handles header values that may be a String or single element Array
226 if ((Array.isArray(actual) && actual.toString() === fieldExpected)
227 || fieldExpected === actual) {
228 return;
229 }
230 if (fieldExpected instanceof RegExp) {
231 if (!fieldExpected.test(actual)) {
232 return new Error('expected "' + field + '" matching '
233 + fieldExpected + ', got "' + actual + '"');
234 }
235 } else {
236 return new Error('expected "' + field + '" of "' + fieldExpected + '", got "' + actual + '"');
237 }
238 }
239
240 /**
241 * Perform assertions on the response status and return an Error upon failure.
242 *
243 * @param {Number} status
244 * @param {Response} res
245 * @return {?Error}
246 * @api private
247 */// eslint-disable-next-line class-methods-use-this
248 _assertStatus(status, res) {
249 if (res.status !== status) {
250 const a = STATUS_CODES[status];
251 const b = STATUS_CODES[res.status];
252 return new Error('expected ' + status + ' "' + a + '", got ' + res.status + ' "' + b + '"');
253 }
254 }
255
256 /**
257 * Perform assertions on the response status and return an Error upon failure.
258 *
259 * @param {Array<Number>} statusArray
260 * @param {Response} res
261 * @return {?Error}
262 * @api private
263 */// eslint-disable-next-line class-methods-use-this
264 _assertStatusArray(statusArray, res) {
265 if (!statusArray.includes(res.status)) {
266 const b = STATUS_CODES[res.status];
267 const expectedList = statusArray.join(', ');
268 return new Error(
269 'expected one of "' + expectedList + '", got ' + res.status + ' "' + b + '"'
270 );
271 }
272 }
273
274 /**
275 * Performs an assertion by calling a function and return an Error upon failure.
276 *
277 * @param {Function} fn
278 * @param {Response} res
279 * @return {?Error}
280 * @api private
281 */// eslint-disable-next-line class-methods-use-this
282 _assertFunction(fn, res) {
283 let err;
284 try {
285 err = fn(res);
286 } catch (e) {
287 err = e;
288 }
289 if (err instanceof Error) return err;
290 }
291}
292
293/**
294 * Wraps an assert function into another.
295 * The wrapper function edit the stack trace of any assertion error, prepending a more useful stack to it.
296 *
297 * @param {Function} assertFn
298 * @returns {Function} wrapped assert function
299 */
300
301function wrapAssertFn(assertFn) {
302 const savedStack = new Error().stack.split('\n').slice(3);
303
304 return function(res) {
305 let badStack;
306 const err = assertFn(res);
307 if (err instanceof Error && err.stack) {
308 badStack = err.stack.replace(err.message, '').split('\n').slice(1);
309 err.stack = [err.toString()]
310 .concat(savedStack)
311 .concat('----')
312 .concat(badStack)
313 .join('\n');
314 }
315 return err;
316 };
317}
318
319/**
320 * Return an `Error` with `msg` and results properties.
321 *
322 * @param {String} msg
323 * @param {Mixed} expected
324 * @param {Mixed} actual
325 * @return {Error}
326 * @api private
327 */
328
329function error(msg, expected, actual) {
330 const err = new Error(msg);
331 err.expected = expected;
332 err.actual = actual;
333 err.showDiff = true;
334 return err;
335}
336
337/**
338 * Expose `Test`.
339 */
340
341module.exports = Test;