123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349 |
- 'use strict';
- const debug = require('debug')('egg-mock:cluster');
- const path = require('path');
- const childProcess = require('child_process');
- const Coffee = require('coffee').Coffee;
- const ready = require('get-ready');
- const { rimraf } = require('mz-modules');
- const sleep = require('ko-sleep');
- const os = require('os');
- const co = require('co');
- const awaitEvent = require('await-event');
- const supertestRequest = require('./supertest');
- const formatOptions = require('./format_options');
- const clusters = new Map();
- const serverBin = path.join(__dirname, 'start-cluster');
- const requestCallFunctionFile = path.join(__dirname, 'request_call_function.js');
- let masterPort = 17000;
- /**
- * A cluster version of egg.Application, you can test with supertest
- * @example
- * ```js
- * const mm = require('mm');
- * const request = require('supertest');
- *
- * describe('ClusterApplication', () => {
- * let app;
- * before(function (done) {
- * app = mm.cluster({ baseDir });
- * app.ready(done);
- * });
- *
- * after(function () {
- * app.close();
- * });
- *
- * it('should 200', function (done) {
- * request(app.callback())
- * .get('/')
- * .expect(200, done);
- * });
- * });
- */
- class ClusterApplication extends Coffee {
- /**
- * @class
- * @param {Object} options
- * - {String} baseDir - The directory of the application
- * - {Object} plugins - Tustom you plugins
- * - {String} framework - The directory of the egg framework
- * - {Boolean} [cache=true] - Cache application based on baseDir
- * - {Boolean} [coverage=true] - Swtich on process coverage, but it'll be slower
- * - {Boolean} [clean=true] - Remove $baseDir/logs
- * - {Object} [opt] - opt pass to coffee, such as { execArgv: ['--debug'] }
- * ```
- */
- constructor(options) {
- const opt = options.opt;
- delete options.opt;
- // incremental port
- options.port = options.port || ++masterPort;
- // Set 1 worker when test
- if (!options.workers) options.workers = 1;
- const args = [ JSON.stringify(options) ];
- debug('fork %s, args: %s, opt: %j', serverBin, args.join(' '), opt);
- super({
- method: 'fork',
- cmd: serverBin,
- args,
- opt,
- });
- ready.mixin(this);
- this.port = options.port;
- this.baseDir = options.baseDir;
- // print stdout and stderr when DEBUG, otherwise stderr.
- this.debug(process.env.DEBUG ? 0 : 2);
- // disable coverage
- if (options.coverage === false) {
- this.coverage(false);
- }
- process.nextTick(() => {
- this.proc.on('message', msg => {
- // 'egg-ready' and { action: 'egg-ready' }
- const action = msg && msg.action ? msg.action : msg;
- switch (action) {
- case 'egg-ready':
- this.emit('close', 0);
- break;
- case 'app-worker-died':
- case 'agent-worker-died':
- this.emit('close', 1);
- break;
- default:
- // ignore it
- break;
- }
- });
- });
- this.end(() => this.ready(true));
- }
- /**
- * the process that forked
- * @member {ChildProcess}
- */
- get process() {
- return this.proc;
- }
- /**
- * Compatible API for supertest
- * @return {ClusterApplication} return the instance
- */
- callback() {
- return this;
- }
- /**
- * Compatible API for supertest
- * @member {String} url
- * @private
- */
- get url() {
- return 'http://127.0.0.1:' + this.port;
- }
- /**
- * Compatible API for supertest
- * @return {Object}
- * - {Number} port
- * @private
- */
- address() {
- return {
- port: this.port,
- };
- }
- /**
- * Compatible API for supertest
- * @return {ClusterApplication} return the instance
- * @private
- */
- listen() {
- return this;
- }
- /**
- * kill the process
- * @return {Promise} promise
- */
- close() {
- this.closed = true;
- const proc = this.proc;
- const baseDir = this.baseDir;
- return co(function* () {
- if (proc.connected) {
- proc.kill('SIGTERM');
- yield awaitEvent.call(proc, 'exit');
- }
- clusters.delete(baseDir);
- debug('delete cluster cache %s, remain %s', baseDir, [ ...clusters.keys() ]);
- /* istanbul ignore if */
- if (os.platform() === 'win32') yield sleep(1000);
- });
- }
- // mock app.router.pathFor(name) api
- get router() {
- const that = this;
- return {
- pathFor(url) {
- return that._callFunctionOnAppWorker('pathFor', [ url ], 'router', true);
- },
- };
- }
- /**
- * collection logger message, then can be use on `expectLog()`
- * it's different from `app.expectLog()`, only support string params.
- *
- * @param {String} [logger] - logger instance name, default is `logger`
- * @function ClusterApplication#expectLog
- */
- mockLog(logger) {
- logger = logger || 'logger';
- this._callFunctionOnAppWorker('mockLog', [ logger ], null, true);
- }
- /**
- * expect str in the logger
- * it's different from `app.expectLog()`, only support string params.
- *
- * @param {String} str - test str
- * @param {String} [logger] - logger instance name, default is `logger`
- * @function ClusterApplication#expectLog
- */
- expectLog(str, logger) {
- logger = logger || 'logger';
- this._callFunctionOnAppWorker('expectLog', [ str, logger ], null, true);
- }
- /**
- * not expect str in the logger
- * it's different from `app.notExpectLog()`, only support string params.
- *
- * @param {String} str - test str
- * @param {String} [logger] - logger instance name, default is `logger`
- * @function ClusterApplication#notExpectLog
- */
- notExpectLog(str, logger) {
- logger = logger || 'logger';
- this._callFunctionOnAppWorker('notExpectLog', [ str, logger ], null, true);
- }
- httpRequest() {
- return supertestRequest(this);
- }
- _callFunctionOnAppWorker(method, args = [], property = undefined, needResult = false) {
- for (let i = 0; i < args.length; i++) {
- const arg = args[i];
- if (typeof arg === 'function') {
- args[i] = {
- __egg_mock_type: 'function',
- value: arg.toString(),
- };
- } else if (arg instanceof Error) {
- const errObject = {
- __egg_mock_type: 'error',
- name: arg.name,
- message: arg.message,
- stack: arg.stack,
- };
- for (const key in arg) {
- if (key !== 'name' && key !== 'message' && key !== 'stack') {
- errObject[key] = arg[key];
- }
- }
- args[i] = errObject;
- }
- }
- const data = {
- port: this.port,
- method,
- args,
- property,
- needResult,
- };
- const child = childProcess.spawnSync(process.execPath, [
- requestCallFunctionFile,
- JSON.stringify(data),
- ], {
- stdio: 'pipe',
- });
- if (child.stderr && child.stderr.length > 0) {
- console.error(child.stderr.toString());
- }
- let result;
- if (child.stdout && child.stdout.length > 0) {
- if (needResult) {
- result = JSON.parse(child.stdout.toString());
- } else {
- console.error(child.stdout.toString());
- }
- }
- if (child.status !== 0) {
- throw new Error(child.stderr.toString());
- }
- if (child.error) {
- throw child.error;
- }
- return result;
- }
- }
- module.exports = options => {
- options = formatOptions(options);
- if (options.cache && clusters.has(options.baseDir)) {
- const clusterApp = clusters.get(options.baseDir);
- // return cache when it hasn't been killed
- if (!clusterApp.closed) {
- return clusterApp;
- }
- // delete the cache when it's closed
- clusters.delete(options.baseDir);
- }
- if (options.clean !== false) {
- const logDir = path.join(options.baseDir, 'logs');
- try {
- rimraf.sync(logDir);
- } catch (err) {
- /* istanbul ignore next */
- console.error(`remove log dir ${logDir} failed: ${err.stack}`);
- }
- }
- let clusterApp = new ClusterApplication(options);
- clusterApp = new Proxy(clusterApp, {
- get(target, prop) {
- debug('proxy handler.get %s', prop);
- // proxy mockXXX function to app worker
- const method = prop;
- if (typeof method === 'string' && /^mock\w+$/.test(method) && target[method] === undefined) {
- return function mockProxy(...args) {
- return target._callFunctionOnAppWorker(method, args, null, true);
- };
- }
- return target[prop];
- },
- });
- clusters.set(options.baseDir, clusterApp);
- return clusterApp;
- };
- // export to let mm.restore() worked
- module.exports.restore = () => {
- for (const clusterApp of clusters.values()) {
- clusterApp.mockRestore();
- }
- };
- // ensure to close App process on test exit.
- process.on('exit', () => {
- for (const clusterApp of clusters.values()) {
- clusterApp.close();
- }
- });
|