1 | 'use strict';
|
2 |
|
3 | const EventEmitter = require('events').EventEmitter;
|
4 | const Bluebird = require('bluebird');
|
5 | const Path = require('path');
|
6 | const log = require('npmlog');
|
7 | const StyledString = require('styled_string');
|
8 | const find = require('lodash.find');
|
9 |
|
10 | const Server = require('./server');
|
11 | const BrowserTestRunner = require('./runners/browser_test_runner');
|
12 | const ProcessTestRunner = require('./runners/process_test_runner');
|
13 | const TapProcessTestRunner = require('./runners/tap_process_test_runner');
|
14 | const HookRunner = require('./runners/hook_runner');
|
15 | const cleanExit = require('./utils/clean_exit');
|
16 | const FileWatcher = require('./file_watcher');
|
17 | const LauncherFactory = require('./launcher-factory');
|
18 |
|
19 | const RunTimeout = require('./utils/run-timeout');
|
20 | const Reporter = require('./utils/reporter');
|
21 | const SignalListeners = require('./utils/signal-listeners');
|
22 |
|
23 | module.exports = class App extends EventEmitter {
|
24 | constructor(config, finalizer) {
|
25 | super();
|
26 |
|
27 | this.exited = false;
|
28 | this.paused = false;
|
29 | this.config = config;
|
30 | this.stdoutStream = config.get('stdout_stream') || process.stdout;
|
31 | this.server = new Server(this.config);
|
32 | this.results = [];
|
33 | this.runnerIndex = 0;
|
34 | this.runners = [];
|
35 | this.timeoutID = undefined;
|
36 | this.testSuiteTimedOut = null;
|
37 | this.testSuiteTimedOut = false;
|
38 |
|
39 | this.reportFileName = this.config.get('report_file');
|
40 |
|
41 | let alreadyExit = false;
|
42 |
|
43 | this.cleanExit = err => {
|
44 | if (!alreadyExit) {
|
45 | alreadyExit = true;
|
46 |
|
47 | let exitCode = err ? 1 : 0;
|
48 |
|
49 | if (err && err.hideFromReporter) {
|
50 | err = null;
|
51 | }
|
52 |
|
53 | if (this.testSuiteTimedOut === true) {
|
54 | let timeoutSeconds = this.testSuiteTimeout.timeout;
|
55 | err = new Error(`Test suite execution has timed out (config.timeout = ${timeoutSeconds} seconds). Terminated all test runners.`);
|
56 | exitCode = 1;
|
57 | }
|
58 |
|
59 | (finalizer || cleanExit)(exitCode, err);
|
60 | }
|
61 | };
|
62 | }
|
63 |
|
64 | start(cb) {
|
65 | log.info('Starting ' + this.config.appMode);
|
66 |
|
67 | return Bluebird.using(SignalListeners.with(), signalListeners => {
|
68 | signalListeners.on('signal', err => this.exit(err));
|
69 |
|
70 | return Bluebird.using(Reporter.with(this, this.stdoutStream, this.reportFileName), reporter => {
|
71 | this.reporter = reporter;
|
72 |
|
73 | return Bluebird.using(this.fileWatch(), () => {
|
74 | return Bluebird.using(this.getServer(), () => {
|
75 | return Bluebird.using(this.getRunners(), () => {
|
76 | return Bluebird.using(this.runHook('on_start'), () => {
|
77 | let w = this.waitForTests();
|
78 |
|
79 | if (cb) {
|
80 | cb();
|
81 | }
|
82 |
|
83 | return w;
|
84 | }).then(() => {
|
85 | log.info('Stopping ' + this.config.appMode);
|
86 |
|
87 | this.emit('tests-finish');
|
88 |
|
89 | return Bluebird.using(this.runHook('on_exit'), () => {});
|
90 | }).catch(error => {
|
91 | log.error(error);
|
92 | log.info('Stopping ' + this.config.appMode);
|
93 |
|
94 | this.emit('tests-error');
|
95 |
|
96 | return new Bluebird((resolve, reject) => {
|
97 | Bluebird.using(this.runHook('on_exit'), () => {}).then(() => {
|
98 | reject(error);
|
99 | });
|
100 | });
|
101 | });
|
102 | });
|
103 | });
|
104 | });
|
105 | });
|
106 | }).asCallback(this.cleanExit);
|
107 | }
|
108 |
|
109 | waitForTests() {
|
110 | log.info('Waiting for tests.');
|
111 |
|
112 | if (this.exited) {
|
113 | return Bluebird.reject(this.exitErr || new Error('Testem exited before running any tests.'));
|
114 | }
|
115 |
|
116 | let run = this.triggerRun('Start');
|
117 |
|
118 | if (this.config.get('single_run')) {
|
119 | run.then(() => this.exit());
|
120 | }
|
121 |
|
122 | return new Bluebird.Promise((resolve, reject) => {
|
123 | this.on('testFinish', resolve);
|
124 | this.on('testError', reject);
|
125 | });
|
126 | }
|
127 |
|
128 | triggerRun(src) {
|
129 | log.info(src + ' triggered test run.');
|
130 |
|
131 | if (this.restarting) {
|
132 | return;
|
133 | }
|
134 | this.restarting = true;
|
135 |
|
136 | return this.stopCurrentRun().catch(this.exit.bind(this)).then(() => {
|
137 | this.restarting = false;
|
138 |
|
139 | return this.runTests();
|
140 | });
|
141 | }
|
142 |
|
143 | stopCurrentRun() {
|
144 | if (!this.currentRun) {
|
145 | return Bluebird.resolve();
|
146 | }
|
147 |
|
148 | return Bluebird.all([ this.stopRunners(), this.currentRun ]);
|
149 | }
|
150 |
|
151 | runTests() {
|
152 | if (this.paused) {
|
153 | return Bluebird.resolve();
|
154 | }
|
155 |
|
156 | log.info('Running tests...');
|
157 |
|
158 | this.reporter.onStart('testem', { launcherId: 0 });
|
159 |
|
160 | return Bluebird.using(this.runHook('before_tests'), () => {
|
161 | return Bluebird.using(RunTimeout.with(this.config.get('timeout')), timeout => {
|
162 | this.testSuiteTimeout = timeout;
|
163 |
|
164 | timeout.on('timeout', () => {
|
165 | let timeoutSeconds = timeout.timeout;
|
166 |
|
167 | log.info(`Test suite execution has timed out (config.timeout = ${timeoutSeconds} seconds). Terminating all test runners`);
|
168 | this.testSuiteTimedOut = true;
|
169 | this.killRunners();
|
170 | });
|
171 | this.timeoutID = timeout.timeoutID;
|
172 | this.currentRun = this.singleRun(timeout);
|
173 | this.emit('testRun');
|
174 |
|
175 | log.info('Tests running.');
|
176 |
|
177 | return this.currentRun;
|
178 | }).then(() => {
|
179 | return Bluebird.using(this.runHook('after_tests'), () => {});
|
180 | });
|
181 | }).catch(err => {
|
182 | if (err.hideFromReporter) {
|
183 | return;
|
184 | }
|
185 |
|
186 | let result = {
|
187 | failed: 1,
|
188 | passed: 0,
|
189 | name: 'testem',
|
190 | launcherId: 0,
|
191 | error: {
|
192 | message: err.toString()
|
193 | }
|
194 | };
|
195 |
|
196 | this.reporter.report('testem', result);
|
197 | }).finally(() => this.reporter.onEnd('testem', { launcherId: 0 }));
|
198 | }
|
199 |
|
200 | exit(err, cb) {
|
201 | err = err || this.getExitCode();
|
202 |
|
203 | if (this.exited) {
|
204 | if (cb) {
|
205 | cb(err);
|
206 | }
|
207 | return;
|
208 | }
|
209 | this.exited = true;
|
210 | this.exitErr = err;
|
211 |
|
212 | if (err) {
|
213 | this.emit('testError', err);
|
214 | } else {
|
215 | this.emit('testFinish');
|
216 | }
|
217 |
|
218 | if (cb) {
|
219 | cb(err);
|
220 | }
|
221 | return;
|
222 | }
|
223 |
|
224 | startServer(callback) {
|
225 | log.info('Starting server');
|
226 | this.server = new Server(this.config);
|
227 | this.server.on('file-requested', this.onFileRequested.bind(this));
|
228 | this.server.on('browser-login', this.onBrowserLogin.bind(this));
|
229 | this.server.on('browser-relogin', this.onBrowserRelogin.bind(this));
|
230 | this.server.on('server-error', this.onServerError.bind(this));
|
231 |
|
232 | return this.server.start().asCallback(callback);
|
233 | }
|
234 |
|
235 | getServer() {
|
236 | return this.startServer().disposer(() => this.stopServer());
|
237 | }
|
238 |
|
239 | onFileRequested(filepath) {
|
240 | if (this.fileWatcher && !this.config.get('serve_files')) {
|
241 | this.fileWatcher.add(filepath);
|
242 | }
|
243 | }
|
244 |
|
245 | onServerError(err) {
|
246 | this.exit(err);
|
247 | }
|
248 |
|
249 | runHook(hook, data) {
|
250 | return HookRunner.with(this.config, hook, data);
|
251 | }
|
252 |
|
253 | onBrowserLogin(browserName, id, socket) {
|
254 | let browser = find(this.runners, runner => {
|
255 | return runner.launcherId === id && (!runner.socket || !runner.socket.connected);
|
256 | });
|
257 |
|
258 | if (!browser) {
|
259 | let launcher = new LauncherFactory(browserName, {
|
260 | id: id,
|
261 | protocol: 'browser'
|
262 | }, this.config).create();
|
263 | const singleRun = this.config.get('single_run');
|
264 |
|
265 | browser = new BrowserTestRunner(launcher, this.reporter, this.runnerIndex++, singleRun, this.config);
|
266 | this.addRunner(browser);
|
267 | }
|
268 |
|
269 | browser.tryAttach(browserName, id, socket);
|
270 | }
|
271 |
|
272 | onBrowserRelogin(browserName, id, socket) {
|
273 | let browser = find(this.runners, runner => {
|
274 |
|
275 |
|
276 | return runner.launcherId === id && (runner.socket || runner.socket === null);
|
277 | });
|
278 |
|
279 | if (!browser) {
|
280 | throw new Error(`Relogin from an unknown browser ${browserName} with id ${id}`);
|
281 | }
|
282 |
|
283 | browser.tryAttach(browserName, id, socket);
|
284 | }
|
285 |
|
286 | addRunner(runner) {
|
287 | this.runners.push(runner);
|
288 | this.emit('runnerAdded', runner);
|
289 | }
|
290 |
|
291 | fileWatch() {
|
292 | return this.configureFileWatch().disposer(() => {});
|
293 | }
|
294 |
|
295 | configureFileWatch(cb) {
|
296 | if (this.config.get('disable_watching')) {
|
297 | return Bluebird.resolve().asCallback(cb);
|
298 | }
|
299 |
|
300 | this.fileWatcher = new FileWatcher(this.config);
|
301 | this.fileWatcher.on('fileChanged', filepath => {
|
302 | log.info(filepath + ' changed (' + (this.disableFileWatch ? 'disabled' : 'enabled') + ').');
|
303 | if (this.disableFileWatch || this.paused) {
|
304 | return;
|
305 | }
|
306 | let configFile = this.config.get('file');
|
307 | if ((configFile && filepath === Path.resolve(configFile)) ||
|
308 | (this.config.isCwdMode() && filepath === process.cwd())) {
|
309 |
|
310 | this.configure(() => {
|
311 | this.triggerRun('Config changed');
|
312 | });
|
313 | } else {
|
314 | Bluebird.using(this.runHook('on_change', {file: filepath}), () => {
|
315 | this.triggerRun('File changed: ' + filepath);
|
316 | });
|
317 | }
|
318 | });
|
319 | this.fileWatcher.on('EMFILE', () => {
|
320 | let view = this.view;
|
321 | let text = [
|
322 | 'The file watcher received a EMFILE system error, which means that ',
|
323 | 'it has hit the maximum number of files that can be open at a time. ',
|
324 | 'Luckily, you can increase this limit as a workaround. See the directions below \n \n',
|
325 | 'Linux: http://stackoverflow.com/a/34645/5304\n',
|
326 | 'Mac OS: http://serverfault.com/a/15575/47234'
|
327 | ].join('');
|
328 | view.setErrorPopupMessage(new StyledString(text + '\n ').foreground('megenta'));
|
329 | });
|
330 |
|
331 | return Bluebird.resolve().asCallback(cb);
|
332 | }
|
333 |
|
334 | getRunners() {
|
335 | return Bluebird.fromCallback(callback => {
|
336 | this.createRunners(callback);
|
337 | }).disposer(() => {
|
338 | return this.killRunners();
|
339 | });
|
340 | }
|
341 |
|
342 | createRunners(callback) {
|
343 | let reporter = this.reporter;
|
344 | this.config.getLaunchers((err, launchers) => {
|
345 | if (err) {
|
346 | return callback(err);
|
347 | }
|
348 |
|
349 | let testPages = this.config.get('test_page');
|
350 | launchers.forEach((launcher) => {
|
351 | for (let i = 0; i < testPages.length; i++) {
|
352 | let launcherInstance = launcher.create({ test_page: testPages[i] });
|
353 | let runner = this.createTestRunner(launcherInstance, reporter);
|
354 | this.addRunner(runner);
|
355 | }
|
356 | });
|
357 |
|
358 | callback(null);
|
359 | });
|
360 | }
|
361 |
|
362 | getRunnerFactory(launcher) {
|
363 | let protocol = launcher.protocol();
|
364 | switch (protocol) {
|
365 | case 'process':
|
366 | return ProcessTestRunner;
|
367 | case 'browser':
|
368 | return BrowserTestRunner;
|
369 | case 'tap':
|
370 | return TapProcessTestRunner;
|
371 | default:
|
372 | throw new Error('Don\'t know about ' + protocol + ' protocol.');
|
373 | }
|
374 | }
|
375 |
|
376 | createTestRunner(launcher, reporter) {
|
377 | let singleRun = this.config.get('single_run');
|
378 |
|
379 | return new (this.getRunnerFactory(launcher))(launcher, reporter, this.runnerIndex++, singleRun, this.config);
|
380 | }
|
381 |
|
382 | withTestTimeout() {
|
383 | return this.startClock().disposer(() => {
|
384 | return this.cancelExistingTimeout();
|
385 | });
|
386 | }
|
387 |
|
388 | singleRun(timeout) {
|
389 | let limit = this.config.get('parallel');
|
390 |
|
391 | let options = {};
|
392 |
|
393 | if (limit && limit >= 1) {
|
394 | options.concurrency = parseInt(limit);
|
395 | } else {
|
396 | options.concurrency = Infinity;
|
397 | }
|
398 |
|
399 | return Bluebird.map(this.runners, (runner) => {
|
400 | if (this.exited) {
|
401 | let e = new Error('Run canceled.');
|
402 | e.hideFromReporter = true;
|
403 | return Bluebird.reject(e);
|
404 | }
|
405 | if (this.restarting) {
|
406 | return Bluebird.resolve();
|
407 | }
|
408 | return timeout.try(() => runner.start());
|
409 | }, options);
|
410 | }
|
411 |
|
412 | wrapUp(err) {
|
413 | this.exit(err);
|
414 | }
|
415 |
|
416 | stopServer(callback) {
|
417 | if (!this.server) {
|
418 | return Bluebird.resolve().asCallback(callback);
|
419 | }
|
420 |
|
421 | return this.server.stop().asCallback(callback);
|
422 | }
|
423 |
|
424 | getExitCode() {
|
425 | if (!this.reporter) {
|
426 | return new Error('Failed to initialize.');
|
427 | }
|
428 | if (!this.reporter.hasPassed()) {
|
429 | let e = new Error('Not all tests passed.');
|
430 | e.hideFromReporter = true;
|
431 | return e;
|
432 | }
|
433 | if (!this.reporter.hasTests() && this.config.get('fail_on_zero_tests')) {
|
434 | return new Error('No tests found.');
|
435 | }
|
436 | return null;
|
437 | }
|
438 |
|
439 | stopRunners() {
|
440 | return Bluebird.each(this.runners, runner => {
|
441 | if (typeof runner.stop === 'function') {
|
442 | return runner.stop();
|
443 | }
|
444 |
|
445 | return runner.exit();
|
446 | });
|
447 | }
|
448 |
|
449 | killRunners() {
|
450 | return Bluebird.each(this.runners, runner => runner.exit());
|
451 | }
|
452 |
|
453 | launchers() {
|
454 | return this.runners.map(runner => runner.launcher);
|
455 | }
|
456 | };
|