'use strict'; const EventEmitter = require('events'); const muk = require('muk-prop'); const http = require('http'); const https = require('https'); const cp = require('child_process'); const thenify = require('thenify').withCallback; const Readable = require('stream').Readable; const Duplex = require('stream').Duplex; const mock = module.exports = function mock() { return muk.apply(null, arguments); }; exports = mock; function getCallback(args) { let index = args.length - 1; let callback = args[index]; while (typeof callback !== 'function') { index--; if (index < 0) { break; } callback = args[index]; } if (!callback) { throw new TypeError('Can\'t find callback function'); } // support thunk fn(a1, a2, cb, cbThunk) if (typeof args[index - 1] === 'function') { callback = args[index - 1]; } return callback; } exports.isMocked = muk.isMocked; /** * create an error instance * * @param {String|Error} error - error * @param {Object} props - props * @return {Error} error - error */ exports._createError = function(error, props) { if (!error) { error = new Error('mm mock error'); error.name = 'MockError'; } if (typeof error === 'string') { error = new Error(error); error.name = 'MockError'; } props = props || {}; for (const key in props) { error[key] = props[key]; } return error; }; exports._mockError = function(mod, method, error, props, timeout, once) { if (typeof props === 'number') { timeout = props; props = {}; } error = exports._createError(error, props); if (timeout) { timeout = parseInt(timeout, 10); } timeout = timeout || 0; mock(mod, method, thenify(function() { const callback = getCallback(arguments); setTimeout(function() { if (once) { exports.restore(); } callback(error); }, timeout); })); return this; }; /** * Mock async function error. * @param {Object} mod, module object * @param {String} method, mock module object method name. * @param {String|Error} error, error string message or error instance. * @param {Object} props, error properties * @param {Number} timeout, mock async callback timeout, default is 0. * @return {mm} this - mm */ exports.error = function(mod, method, error, props, timeout) { return exports._mockError(mod, method, error, props, timeout); }; /** * Mock async function error once. * @param {Object} mod, module object * @param {String} method, mock module object method name. * @param {String|Error} error, error string message or error instance. * @param {Object} props, error properties * @param {Number} timeout, mock async callback timeout, default is 0. * @return {mm} this - mm */ exports.errorOnce = function(mod, method, error, props, timeout) { return exports._mockError(mod, method, error, props, timeout, true); }; /** * mock return callback(null, data1, data2). * * @param {Object} mod, module object * @param {String} method, mock module object method name. * @param {Array} datas, return datas array. * @param {Number} timeout, mock async callback timeout, default is 10. * @return {mm} this - mm */ exports.datas = function(mod, method, datas, timeout) { if (timeout) { timeout = parseInt(timeout, 10); } timeout = timeout || 0; if (!Array.isArray(datas)) { datas = [ datas ]; } mock(mod, method, thenify(function() { const callback = getCallback(arguments); setTimeout(function() { callback.apply(mod, [ null ].concat(datas)); }, timeout); })); return this; }; /** * mock return callback(null, data). * * @param {Object} mod, module object * @param {String} method, mock module object method name. * @param {Object} data, return data. * @param {Number} timeout, mock async callback timeout, default is 0. * @return {mm} this - mm */ exports.data = function(mod, method, data, timeout) { return exports.datas(mod, method, [ data ], timeout); }; /** * mock return callback(null, null). * * @param {Object} mod, module object * @param {String} method, mock module object method name. * @param {Number} [timeout], mock async callback timeout, default is 0. * @return {mm} this - mm */ exports.empty = function(mod, method, timeout) { return exports.datas(mod, method, null, timeout); }; /** * mock function sync throw error * * @param {Object} mod, module object * @param {String} method, mock module object method name. * @param {String|Error} error, error string message or error instance. * @param {Object} [props], error properties */ exports.syncError = function(mod, method, error, props) { error = exports._createError(error, props); mock(mod, method, function() { throw error; }); }; /** * mock function sync return data * * @param {Object} mod, module object * @param {String} method, mock module object method name. * @param {Object} data, return data. */ exports.syncData = function(mod, method, data) { mock(mod, method, function() { return data; }); }; /** * mock function sync return nothing * * @param {Object} mod, module object * @param {String} method, mock module object method name. */ exports.syncEmpty = function(mod, method) { exports.syncData(mod, method); }; exports.http = {}; exports.https = {}; function matchURL(options, params) { const url = params && params.url || params; const host = params && params.host; const pathname = options.path || options.pathname; const hostname = options.host || options.hostname; let match = false; if (pathname) { if (!url) { match = true; } else if (typeof url === 'string') { match = pathname === url; } else if (url instanceof RegExp) { match = url.test(pathname); } else if (typeof host === 'string') { match = host === hostname; } else if (host instanceof RegExp) { match = host.test(hostname); } } return match; } function mockRequest() { const req = new Duplex({ write() {}, read() {}, }); req.abort = function() { req._aborted = true; process.nextTick(function() { const err = new Error('socket hang up'); err.code = 'ECONNRESET'; req.emit('error', err); }); }; req.socket = {}; return req; } /** * Mock http.request(). * @param {String|RegExp|Object} url, request url path. * If url is Object, should be {url: $url, host: $host} * @param {String|Buffer|ReadStream} data, mock response data. * If data is Array, then res will emit `data` event many times. * @param {Object} headers, mock response headers. * @param {Number} [delay], response delay time, default is 10. * @return {mm} this - mm */ exports.http.request = function(url, data, headers, delay) { backupOriginalRequest(http); return _request.call(this, http, url, data, headers, delay); }; /** * Mock https.request(). * @param {String|RegExp|Object} url, request url path. * If url is Object, should be {url: $url, host: $host} * @param {String|Buffer|ReadStream} data, mock response data. * If data is Array, then res will emit `data` event many times. * @param {Object} headers, mock response headers. * @param {Number} [delay], response delay time, default is 0. * @return {mm} this - mm */ exports.https.request = function(url, data, headers, delay) { backupOriginalRequest(https); return _request.call(this, https, url, data, headers, delay); }; function backupOriginalRequest(mod) { if (!mod.__sourceRequest) { mod.__sourceRequest = mod.request; } if (!mod.__sourceGet) { mod.__sourceGet = mod.get; } } function _request(mod, url, data, headers, delay) { headers = headers || {}; if (delay) { delay = parseInt(delay, 10); } delay = delay || 0; mod.get = function(options, callback) { const req = mod.request(options, callback); req.end(); return req; }; mod.request = function(options, callback) { let datas = []; let stream = null; // read stream if (typeof data.read === 'function') { stream = data; } else if (!Array.isArray(data)) { datas = [ data ]; } else { for (let i = 0; i < data.length; i++) { datas.push(data[i]); } } const match = matchURL(options, url); if (!match) { return mod.__sourceRequest(options, callback); } const req = mockRequest(); if (callback) { req.on('response', callback); } let res; if (stream) { res = stream; } else { res = new Readable({ read() { let chunk = datas.shift(); if (!chunk) { if (!req._aborted) { this.push(null); } return; } if (!req._aborted) { if (typeof chunk === 'string') { chunk = Buffer.from ? Buffer.from(chunk) : new Buffer(chunk); } if (this.charset) { chunk = chunk.toString(this.charset); } this.push(chunk); } }, }); res.setEncoding = function(charset) { res.charset = charset; }; } res.statusCode = headers.statusCode || 200; res.headers = omit(headers, 'statusCode'); res.socket = req.socket; function sendResponse() { if (!req._aborted) { req.emit('response', res); } } if (delay) { setTimeout(sendResponse, delay); } else { setImmediate(sendResponse); } return req; }; return this; } /** * Mock http.request() error. * @param {String|RegExp} url, request url path. * @param {String|Error} reqError, request error. * @param {String|Error} resError, response error. * @param {Number} [delay], request error delay time, default is 0. */ exports.http.requestError = function(url, reqError, resError, delay) { backupOriginalRequest(http); _requestError.call(this, http, url, reqError, resError, delay); }; /** * Mock https.request() error. * @param {String|RegExp} url, request url path. * @param {String|Error} reqError, request error. * @param {String|Error} resError, response error. * @param {Number} [delay], request error delay time, default is 0. */ exports.https.requestError = function(url, reqError, resError, delay) { backupOriginalRequest(https); _requestError.call(this, https, url, reqError, resError, delay); }; function _requestError(mod, url, reqError, resError, delay) { if (delay) { delay = parseInt(delay, 10); } delay = delay || 0; if (reqError && typeof reqError === 'string') { reqError = new Error(reqError); reqError.name = 'MockHttpRequestError'; } if (resError && typeof resError === 'string') { resError = new Error(resError); resError.name = 'MockHttpResponseError'; } mod.get = function(options, callback) { const req = mod.request(options, callback); req.end(); return req; }; mod.request = function(options, callback) { const match = matchURL(options, url); if (!match) { return mod.__sourceRequest(options, callback); } const req = mockRequest(); if (callback) { req.on('response', callback); } setTimeout(function() { if (reqError) { return req.emit('error', reqError); } const res = new Duplex({ read() {}, write() {}, }); res.socket = req.socket; res.statusCode = 200; res.headers = { server: 'MockMateServer', }; process.nextTick(function() { if (!req._aborted) { req.emit('error', resError); } }); if (!req._aborted) { req.emit('response', res); } }, delay); return req; }; return this; } /** * mock child_process spawn * @param {Integer} code exit code * @param {String} stdout stdout * @param {String} stderr stderr * @param {Integer} timeout stdout/stderr/close event emit timeout */ exports.spawn = function(code, stdout, stderr, timeout) { const evt = new EventEmitter(); mock(cp, 'spawn', function() { return evt; }); setTimeout(function() { stdout && evt.emit('stdout', stdout); stderr && evt.emit('stderr', stderr); evt.emit('close', code); evt.emit('exit', code); }, timeout); }; /** * remove all mock effects. * @return {mm} this - mm */ exports.restore = function() { if (http.__sourceRequest) { http.request = http.__sourceRequest; http.__sourceRequest = null; } if (http.__sourceGet) { http.get = http.__sourceGet; http.__sourceGet = null; } if (https.__sourceRequest) { https.request = https.__sourceRequest; https.__sourceRequest = null; } muk.restore(); return this; }; function omit(obj, key) { const newObj = {}; for (const k in obj) { if (k !== key) { newObj[k] = obj[k]; } } return newObj; }