cluster.js 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. 'use strict';
  2. const debug = require('debug')('egg-mock:cluster');
  3. const path = require('path');
  4. const childProcess = require('child_process');
  5. const Coffee = require('coffee').Coffee;
  6. const ready = require('get-ready');
  7. const { rimraf } = require('mz-modules');
  8. const sleep = require('ko-sleep');
  9. const os = require('os');
  10. const co = require('co');
  11. const awaitEvent = require('await-event');
  12. const supertestRequest = require('./supertest');
  13. const formatOptions = require('./format_options');
  14. const clusters = new Map();
  15. const serverBin = path.join(__dirname, 'start-cluster');
  16. const requestCallFunctionFile = path.join(__dirname, 'request_call_function.js');
  17. let masterPort = 17000;
  18. /**
  19. * A cluster version of egg.Application, you can test with supertest
  20. * @example
  21. * ```js
  22. * const mm = require('mm');
  23. * const request = require('supertest');
  24. *
  25. * describe('ClusterApplication', () => {
  26. * let app;
  27. * before(function (done) {
  28. * app = mm.cluster({ baseDir });
  29. * app.ready(done);
  30. * });
  31. *
  32. * after(function () {
  33. * app.close();
  34. * });
  35. *
  36. * it('should 200', function (done) {
  37. * request(app.callback())
  38. * .get('/')
  39. * .expect(200, done);
  40. * });
  41. * });
  42. */
  43. class ClusterApplication extends Coffee {
  44. /**
  45. * @class
  46. * @param {Object} options
  47. * - {String} baseDir - The directory of the application
  48. * - {Object} plugins - Tustom you plugins
  49. * - {String} framework - The directory of the egg framework
  50. * - {Boolean} [cache=true] - Cache application based on baseDir
  51. * - {Boolean} [coverage=true] - Swtich on process coverage, but it'll be slower
  52. * - {Boolean} [clean=true] - Remove $baseDir/logs
  53. * - {Object} [opt] - opt pass to coffee, such as { execArgv: ['--debug'] }
  54. * ```
  55. */
  56. constructor(options) {
  57. const opt = options.opt;
  58. delete options.opt;
  59. // incremental port
  60. options.port = options.port || ++masterPort;
  61. // Set 1 worker when test
  62. if (!options.workers) options.workers = 1;
  63. const args = [ JSON.stringify(options) ];
  64. debug('fork %s, args: %s, opt: %j', serverBin, args.join(' '), opt);
  65. super({
  66. method: 'fork',
  67. cmd: serverBin,
  68. args,
  69. opt,
  70. });
  71. ready.mixin(this);
  72. this.port = options.port;
  73. this.baseDir = options.baseDir;
  74. // print stdout and stderr when DEBUG, otherwise stderr.
  75. this.debug(process.env.DEBUG ? 0 : 2);
  76. // disable coverage
  77. if (options.coverage === false) {
  78. this.coverage(false);
  79. }
  80. process.nextTick(() => {
  81. this.proc.on('message', msg => {
  82. // 'egg-ready' and { action: 'egg-ready' }
  83. const action = msg && msg.action ? msg.action : msg;
  84. switch (action) {
  85. case 'egg-ready':
  86. this.emit('close', 0);
  87. break;
  88. case 'app-worker-died':
  89. case 'agent-worker-died':
  90. this.emit('close', 1);
  91. break;
  92. default:
  93. // ignore it
  94. break;
  95. }
  96. });
  97. });
  98. this.end(() => this.ready(true));
  99. }
  100. /**
  101. * the process that forked
  102. * @member {ChildProcess}
  103. */
  104. get process() {
  105. return this.proc;
  106. }
  107. /**
  108. * Compatible API for supertest
  109. * @return {ClusterApplication} return the instance
  110. */
  111. callback() {
  112. return this;
  113. }
  114. /**
  115. * Compatible API for supertest
  116. * @member {String} url
  117. * @private
  118. */
  119. get url() {
  120. return 'http://127.0.0.1:' + this.port;
  121. }
  122. /**
  123. * Compatible API for supertest
  124. * @return {Object}
  125. * - {Number} port
  126. * @private
  127. */
  128. address() {
  129. return {
  130. port: this.port,
  131. };
  132. }
  133. /**
  134. * Compatible API for supertest
  135. * @return {ClusterApplication} return the instance
  136. * @private
  137. */
  138. listen() {
  139. return this;
  140. }
  141. /**
  142. * kill the process
  143. * @return {Promise} promise
  144. */
  145. close() {
  146. this.closed = true;
  147. const proc = this.proc;
  148. const baseDir = this.baseDir;
  149. return co(function* () {
  150. if (proc.connected) {
  151. proc.kill('SIGTERM');
  152. yield awaitEvent.call(proc, 'exit');
  153. }
  154. clusters.delete(baseDir);
  155. debug('delete cluster cache %s, remain %s', baseDir, [ ...clusters.keys() ]);
  156. /* istanbul ignore if */
  157. if (os.platform() === 'win32') yield sleep(1000);
  158. });
  159. }
  160. // mock app.router.pathFor(name) api
  161. get router() {
  162. const that = this;
  163. return {
  164. pathFor(url) {
  165. return that._callFunctionOnAppWorker('pathFor', [ url ], 'router', true);
  166. },
  167. };
  168. }
  169. /**
  170. * collection logger message, then can be use on `expectLog()`
  171. * it's different from `app.expectLog()`, only support string params.
  172. *
  173. * @param {String} [logger] - logger instance name, default is `logger`
  174. * @function ClusterApplication#expectLog
  175. */
  176. mockLog(logger) {
  177. logger = logger || 'logger';
  178. this._callFunctionOnAppWorker('mockLog', [ logger ], null, true);
  179. }
  180. /**
  181. * expect str in the logger
  182. * it's different from `app.expectLog()`, only support string params.
  183. *
  184. * @param {String} str - test str
  185. * @param {String} [logger] - logger instance name, default is `logger`
  186. * @function ClusterApplication#expectLog
  187. */
  188. expectLog(str, logger) {
  189. logger = logger || 'logger';
  190. this._callFunctionOnAppWorker('expectLog', [ str, logger ], null, true);
  191. }
  192. /**
  193. * not expect str in the logger
  194. * it's different from `app.notExpectLog()`, only support string params.
  195. *
  196. * @param {String} str - test str
  197. * @param {String} [logger] - logger instance name, default is `logger`
  198. * @function ClusterApplication#notExpectLog
  199. */
  200. notExpectLog(str, logger) {
  201. logger = logger || 'logger';
  202. this._callFunctionOnAppWorker('notExpectLog', [ str, logger ], null, true);
  203. }
  204. httpRequest() {
  205. return supertestRequest(this);
  206. }
  207. _callFunctionOnAppWorker(method, args = [], property = undefined, needResult = false) {
  208. for (let i = 0; i < args.length; i++) {
  209. const arg = args[i];
  210. if (typeof arg === 'function') {
  211. args[i] = {
  212. __egg_mock_type: 'function',
  213. value: arg.toString(),
  214. };
  215. } else if (arg instanceof Error) {
  216. const errObject = {
  217. __egg_mock_type: 'error',
  218. name: arg.name,
  219. message: arg.message,
  220. stack: arg.stack,
  221. };
  222. for (const key in arg) {
  223. if (key !== 'name' && key !== 'message' && key !== 'stack') {
  224. errObject[key] = arg[key];
  225. }
  226. }
  227. args[i] = errObject;
  228. }
  229. }
  230. const data = {
  231. port: this.port,
  232. method,
  233. args,
  234. property,
  235. needResult,
  236. };
  237. const child = childProcess.spawnSync(process.execPath, [
  238. requestCallFunctionFile,
  239. JSON.stringify(data),
  240. ], {
  241. stdio: 'pipe',
  242. });
  243. if (child.stderr && child.stderr.length > 0) {
  244. console.error(child.stderr.toString());
  245. }
  246. let result;
  247. if (child.stdout && child.stdout.length > 0) {
  248. if (needResult) {
  249. result = JSON.parse(child.stdout.toString());
  250. } else {
  251. console.error(child.stdout.toString());
  252. }
  253. }
  254. if (child.status !== 0) {
  255. throw new Error(child.stderr.toString());
  256. }
  257. if (child.error) {
  258. throw child.error;
  259. }
  260. return result;
  261. }
  262. }
  263. module.exports = options => {
  264. options = formatOptions(options);
  265. if (options.cache && clusters.has(options.baseDir)) {
  266. const clusterApp = clusters.get(options.baseDir);
  267. // return cache when it hasn't been killed
  268. if (!clusterApp.closed) {
  269. return clusterApp;
  270. }
  271. // delete the cache when it's closed
  272. clusters.delete(options.baseDir);
  273. }
  274. if (options.clean !== false) {
  275. const logDir = path.join(options.baseDir, 'logs');
  276. try {
  277. rimraf.sync(logDir);
  278. } catch (err) {
  279. /* istanbul ignore next */
  280. console.error(`remove log dir ${logDir} failed: ${err.stack}`);
  281. }
  282. }
  283. let clusterApp = new ClusterApplication(options);
  284. clusterApp = new Proxy(clusterApp, {
  285. get(target, prop) {
  286. debug('proxy handler.get %s', prop);
  287. // proxy mockXXX function to app worker
  288. const method = prop;
  289. if (typeof method === 'string' && /^mock\w+$/.test(method) && target[method] === undefined) {
  290. return function mockProxy(...args) {
  291. return target._callFunctionOnAppWorker(method, args, null, true);
  292. };
  293. }
  294. return target[prop];
  295. },
  296. });
  297. clusters.set(options.baseDir, clusterApp);
  298. return clusterApp;
  299. };
  300. // export to let mm.restore() worked
  301. module.exports.restore = () => {
  302. for (const clusterApp of clusters.values()) {
  303. clusterApp.mockRestore();
  304. }
  305. };
  306. // ensure to close App process on test exit.
  307. process.on('exit', () => {
  308. for (const clusterApp of clusters.values()) {
  309. clusterApp.close();
  310. }
  311. });