UNPKG

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