UNPKG

testem

Version:

Test'em 'scripts! Javascript Unit testing made easy.

462 lines (376 loc) 12.7 kB
'use strict'; const EventEmitter = require('events').EventEmitter; const Bluebird = require('bluebird'); const Path = require('path'); const log = require('npmlog'); const StyledString = require('styled_string'); const _ = require('lodash'); const Server = require('./server'); const BrowserTestRunner = require('./runners/browser_test_runner'); const ProcessTestRunner = require('./runners/process_test_runner'); const TapProcessTestRunner = require('./runners/tap_process_test_runner'); const HookRunner = require('./runners/hook_runner'); const cleanExit = require('./utils/clean_exit'); const FileWatcher = require('./file_watcher'); const LauncherFactory = require('./launcher-factory'); const RunTimeout = require('./utils/run-timeout'); const Reporter = require('./utils/reporter'); const SignalListeners = require('./utils/signal-listeners'); module.exports = class App extends EventEmitter { constructor(config, finalizer) { super(); this.exited = false; this.paused = false; this.config = config; this.stdoutStream = config.get('stdout_stream') || process.stdout; this.server = new Server(this.config); this.results = []; this.runnerIndex = 0; this.runners = []; this.timeoutID = undefined; this.testSuiteTimedOut = null; this.testSuiteTimedOut = false; this.reportFileName = this.config.get('report_file'); let alreadyExit = false; this.cleanExit = err => { if (!alreadyExit) { alreadyExit = true; let exitCode = err ? 1 : 0; if (err && err.hideFromReporter) { err = null; } if (this.testSuiteTimedOut === true) { let timeoutSeconds = this.testSuiteTimeout.timeout; err = new Error(`Test suite execution has timed out (config.timeout = ${timeoutSeconds} seconds). Terminated all test runners.`); exitCode = 1; } (finalizer || cleanExit)(exitCode, err); } }; } start(cb) { log.info('Starting ' + this.config.appMode); return Bluebird.using(SignalListeners.with(), signalListeners => { signalListeners.on('signal', err => this.exit(err)); return Bluebird.using(Reporter.with(this, this.stdoutStream, this.reportFileName), reporter => { this.reporter = reporter; return Bluebird.using(this.fileWatch(), () => { return Bluebird.using(this.getServer(), () => { return Bluebird.using(this.getRunners(), () => { return Bluebird.using(this.runHook('on_start'), () => { let w = this.waitForTests(); if (cb) { cb(); } return w; }).then(() => { log.info('Stopping ' + this.config.appMode); this.emit('tests-finish'); return Bluebird.using(this.runHook('on_exit'), () => {}); }).catch(error => { log.error(error); log.info('Stopping ' + this.config.appMode); this.emit('tests-error'); return new Bluebird((resolve, reject) => { Bluebird.using(this.runHook('on_exit'), () => {}).then(() => { reject(error); }); }); }); }); }); }); }); }).asCallback(this.cleanExit); } waitForTests() { log.info('Waiting for tests.'); if (this.exited) { return Bluebird.reject(this.exitErr || new Error('Testem exited before running any tests.')); } let run = this.triggerRun('Start'); if (this.config.get('single_run')) { run.then(() => this.exit()); } return new Bluebird.Promise((resolve, reject) => { this.on('testFinish', resolve); this.on('testError', reject); }); } triggerRun(src) { log.info(src + ' triggered test run.'); if (this.restarting) { return; } this.restarting = true; return this.stopCurrentRun().catch(this.exit.bind(this)).then(() => { this.restarting = false; return this.runTests(); }); } stopCurrentRun() { if (!this.currentRun) { return Bluebird.resolve(); } return Bluebird.all([ this.stopRunners(), this.currentRun ]); } runTests() { if (this.paused) { return Bluebird.resolve(); } log.info('Running tests...'); this.reporter.onStart('testem', { launcherId: 0 }); return Bluebird.using(this.runHook('before_tests'), () => { return Bluebird.using(RunTimeout.with(this.config.get('timeout')), timeout => { this.testSuiteTimeout = timeout; timeout.on('timeout', () => { let timeoutSeconds = timeout.timeout; log.info(`Test suite execution has timed out (config.timeout = ${timeoutSeconds} seconds). Terminating all test runners`); this.testSuiteTimedOut = true; this.killRunners(); }); this.timeoutID = timeout.timeoutID; // TODO Remove, just for the tests this.currentRun = this.singleRun(timeout); this.emit('testRun'); log.info('Tests running.'); return this.currentRun; }).then(() => { return Bluebird.using(this.runHook('after_tests'), () => {}); }); }).catch(err => { if (err.hideFromReporter) { return; } let result = { failed: 1, passed: 0, name: 'testem', launcherId: 0, error: { message: err.toString() } }; this.reporter.report('testem', result); }).finally(() => this.reporter.onEnd('testem', { launcherId: 0 })); } exit(err, cb) { err = err || this.getExitCode(); if (this.exited) { if (cb) { cb(err); } return; } this.exited = true; this.exitErr = err; if (err) { this.emit('testError', err); } else { this.emit('testFinish'); } if (cb) { cb(err); } return; } startServer(callback) { log.info('Starting server'); this.server = new Server(this.config); this.server.on('file-requested', this.onFileRequested.bind(this)); this.server.on('browser-login', this.onBrowserLogin.bind(this)); this.server.on('browser-relogin', this.onBrowserRelogin.bind(this)); this.server.on('server-error', this.onServerError.bind(this)); return this.server.start().asCallback(callback); } getServer() { return this.startServer().disposer(() => this.stopServer()); } onFileRequested(filepath) { if (this.fileWatcher && !this.config.get('serve_files')) { this.fileWatcher.add(filepath); } } onServerError(err) { this.exit(err); } runHook(hook, data) { return HookRunner.with(this.config, hook, data); } onBrowserLogin(browserName, id, socket) { let browser = _.find(this.runners, runner => { return runner.launcherId === id && (!runner.socket || !runner.socket.connected); }); if (!browser) { let launcher = new LauncherFactory(browserName, { id: id, protocol: 'browser' }, this.config).create(); const singleRun = this.config.get('single_run'); browser = new BrowserTestRunner(launcher, this.reporter, this.runnerIndex++, singleRun, this.config); this.addRunner(browser); } browser.tryAttach(browserName, id, socket); } onBrowserRelogin(browserName, id, socket) { let browser = _.find(this.runners, runner => { // a browser relogin can happen if a client socket was disconnected, which may not be reflected in runner.socket's connected state // or if the socket was nulled by 'onDisconnect' return runner.launcherId === id && (runner.socket || runner.socket === null); }); if (!browser) { log.warn(`Relogin from an unknown browser ${browserName} with id ${id}`); return; } if (browser.socket !== null) { browser.clearTimeouts(); } else { browser.tryAttach(browserName, id, socket); } } addRunner(runner) { this.runners.push(runner); this.emit('runnerAdded', runner); } fileWatch() { return this.configureFileWatch().disposer(() => {}); } configureFileWatch(cb) { if (this.config.get('disable_watching')) { return Bluebird.resolve().asCallback(cb); } this.fileWatcher = new FileWatcher(this.config); this.fileWatcher.on('fileChanged', filepath => { log.info(filepath + ' changed (' + (this.disableFileWatch ? 'disabled' : 'enabled') + ').'); if (this.disableFileWatch || this.paused) { return; } let configFile = this.config.get('file'); if ((configFile && filepath === Path.resolve(configFile)) || (this.config.isCwdMode() && filepath === process.cwd())) { // config changed this.configure(() => { this.triggerRun('Config changed'); }); } else { Bluebird.using(this.runHook('on_change', {file: filepath}), () => { this.triggerRun('File changed: ' + filepath); }); } }); this.fileWatcher.on('EMFILE', () => { let view = this.view; let text = [ 'The file watcher received a EMFILE system error, which means that ', 'it has hit the maximum number of files that can be open at a time. ', 'Luckily, you can increase this limit as a workaround. See the directions below \n \n', 'Linux: http://stackoverflow.com/a/34645/5304\n', 'Mac OS: http://serverfault.com/a/15575/47234' ].join(''); view.setErrorPopupMessage(new StyledString(text + '\n ').foreground('megenta')); }); return Bluebird.resolve().asCallback(cb); } getRunners() { return Bluebird.fromCallback(callback => { this.createRunners(callback); }).disposer(() => { return this.killRunners(); }); } createRunners(callback) { let reporter = this.reporter; this.config.getLaunchers((err, launchers) => { if (err) { return callback(err); } let testPages = this.config.get('test_page'); launchers.forEach((launcher) => { for (let i = 0; i < testPages.length; i++) { let launcherInstance = launcher.create({ test_page: testPages[i] }); let runner = this.createTestRunner(launcherInstance, reporter); this.addRunner(runner); } }); callback(null); }); } getRunnerFactory(launcher) { let protocol = launcher.protocol(); switch (protocol) { case 'process': return ProcessTestRunner; case 'browser': return BrowserTestRunner; case 'tap': return TapProcessTestRunner; default: throw new Error('Don\'t know about ' + protocol + ' protocol.'); } } createTestRunner(launcher, reporter) { let singleRun = this.config.get('single_run'); return new (this.getRunnerFactory(launcher))(launcher, reporter, this.runnerIndex++, singleRun, this.config); } withTestTimeout() { return this.startClock().disposer(() => { return this.cancelExistingTimeout(); }); } singleRun(timeout) { let limit = this.config.get('parallel'); let options = {}; if (limit && limit >= 1) { options.concurrency = parseInt(limit); } else { options.concurrency = Infinity; } return Bluebird.map(this.runners, (runner) => { if (this.exited) { let e = new Error('Run canceled.'); e.hideFromReporter = true; return Bluebird.reject(e); } if (this.restarting) { return Bluebird.resolve(); } return timeout.try(() => runner.start()); }, options); } wrapUp(err) { this.exit(err); } stopServer(callback) { if (!this.server) { return Bluebird.resolve().asCallback(callback); } return this.server.stop().asCallback(callback); } getExitCode() { if (!this.reporter) { return new Error('Failed to initialize.'); } if (!this.reporter.hasPassed()) { let e = new Error('Not all tests passed.'); e.hideFromReporter = true; return e; } if (!this.reporter.hasTests() && this.config.get('fail_on_zero_tests')) { return new Error('No tests found.'); } return null; } stopRunners() { return Bluebird.each(this.runners, runner => { if (typeof runner.stop === 'function') { return runner.stop(); } return runner.exit(); }); } killRunners() { return Bluebird.each(this.runners, runner => runner.exit()); } launchers() { return this.runners.map(runner => runner.launcher); } };