@kronoslive/codeceptjs
Version:
Supercharged End 2 End Testing Framework for NodeJS
401 lines (355 loc) • 10.8 kB
JavaScript
/* eslint-disable max-classes-per-file */
const { EventEmitter } = require('events');
const path = require('path');
const mkdirp = require('mkdirp');
const { Worker } = require('worker_threads');
const { Suite, Test, reporters: { Base } } = require('mocha');
const Codecept = require('./codecept');
const MochaFactory = require('./mochaFactory');
const Container = require('./container');
const { getTestRoot } = require('./command/utils');
const { isFunction, fileExists } = require('./utils');
const mainConfig = require('./config');
const output = require('./output');
const event = require('./event');
const recorder = require('./recorder');
const runHook = require('./hooks');
const WorkerStorage = require('./workerStorage');
const pathToWorker = path.join(__dirname, 'command', 'workers', 'runTests.js');
const initializeCodecept = (configPath, options = {}) => {
const codecept = new Codecept(mainConfig.load(configPath || '.'), options);
codecept.init(getTestRoot(configPath));
codecept.loadTests();
return codecept;
};
const createOutputDir = (configPath) => {
const config = mainConfig.load(configPath || '.');
const testRoot = getTestRoot(configPath);
const outputDir = path.isAbsolute(config.output) ? config.output : path.join(testRoot, config.output);
if (!fileExists(outputDir)) {
output.print(`creating output directory: ${outputDir}`);
mkdirp.sync(outputDir);
}
};
const populateGroups = (numberOfWorkers) => {
const groups = [];
for (let i = 0; i < numberOfWorkers; i++) {
groups[i] = [];
}
return groups;
};
const createWorker = (workerObject) => {
const worker = new Worker(pathToWorker, {
workerData: {
options: simplifyObject(workerObject.options),
tests: workerObject.tests,
testRoot: workerObject.testRoot,
workerIndex: workerObject.workerIndex + 1,
},
});
worker.on('error', err => output.error(`Worker Error: ${err.stack}`));
WorkerStorage.addWorker(worker);
return worker;
};
const simplifyObject = (object) => {
return Object.keys(object)
.filter((k) => k.indexOf('_') !== 0)
.filter((k) => typeof object[k] !== 'function')
.filter((k) => typeof object[k] !== 'object')
.reduce((obj, key) => {
obj[key] = object[key];
return obj;
}, {});
};
const repackTest = (test) => {
test = Object.assign(new Test(test.title || '', () => { }), test);
test.parent = Object.assign(new Suite(test.parent.title), test.parent);
return test;
};
const createWorkerObjects = (testGroups, config, testRoot, options) => {
return testGroups.map((tests, index) => {
const workerObj = new WorkerObject(index);
workerObj.addConfig(config);
workerObj.addTests(tests);
workerObj.setTestRoot(testRoot);
workerObj.addOptions(options);
return workerObj;
});
};
const indexOfSmallestElement = (groups) => {
let i = 0;
for (let j = 1; j < groups.length; j++) {
if (groups[j - 1].length > groups[j].length) {
i = j;
}
}
return i;
};
const convertToMochaTests = (testGroup) => {
const group = [];
if (testGroup instanceof Array) {
const mocha = MochaFactory.create({}, {});
mocha.files = testGroup;
mocha.loadFiles();
mocha.suite.eachTest((test) => {
const { id } = test;
group.push(id);
});
mocha.unloadFiles();
}
return group;
};
class WorkerObject {
/**
* @param {Number} workerIndex - Unique ID for worker
*/
constructor(workerIndex) {
this.workerIndex = workerIndex;
this.options = {};
this.tests = [];
this.testRoot = getTestRoot();
}
addConfig(config) {
const oldConfig = JSON.parse(this.options.override || '{}');
const newConfig = {
...oldConfig,
...config,
};
this.options.override = JSON.stringify(newConfig);
}
addTestFiles(testGroup) {
this.addTests(convertToMochaTests(testGroup));
}
addTests(tests) {
this.tests = this.tests.concat(tests);
}
setTestRoot(path) {
this.testRoot = getTestRoot(path);
}
addOptions(opts) {
this.options = {
...this.options,
...opts,
};
}
}
class Workers extends EventEmitter {
/**
* @param {Number} numberOfWorkers
* @param {Object} config
*/
constructor(numberOfWorkers, config = { by: 'test' }) {
super();
this.setMaxListeners(50);
this.codecept = initializeCodecept(config.testConfig, config.options);
this.finishedTests = {};
this.errors = [];
this.numberOfWorkers = 0;
this.closedWorkers = 0;
this.workers = [];
this.stats = {
passes: 0,
failures: 0,
tests: 0,
pending: 0,
};
this.testGroups = [];
createOutputDir(config.testConfig);
if (numberOfWorkers) this._initWorkers(numberOfWorkers, config);
}
_initWorkers(numberOfWorkers, config) {
this.splitTestsByGroups(numberOfWorkers, config);
this.workers = createWorkerObjects(this.testGroups, this.codecept.config, config.testConfig, config.options);
this.numberOfWorkers = this.workers.length;
}
/**
* This splits tests by groups.
* Strategy for group split is taken from a constructor's config.by value:
*
* `config.by` can be:
*
* - `suite`
* - `test`
* - function(numberOfWorkers)
*
* This method can be overridden for a better split.
*/
splitTestsByGroups(numberOfWorkers, config) {
if (isFunction(config.by)) {
const createTests = config.by;
const testGroups = createTests(numberOfWorkers);
if (!(testGroups instanceof Array)) {
throw new Error('Test group should be an array');
}
for (const testGroup of testGroups) {
this.testGroups.push(convertToMochaTests(testGroup));
}
} else if (typeof numberOfWorkers === 'number' && numberOfWorkers > 0) {
this.testGroups = config.by === 'suite' ? this.createGroupsOfSuites(numberOfWorkers) : this.createGroupsOfTests(numberOfWorkers);
}
}
/**
* Creates a new worker
*
* @returns {WorkerObject}
*/
spawn() {
const worker = new WorkerObject(this.numberOfWorkers);
this.workers.push(worker);
this.numberOfWorkers += 1;
return worker;
}
/**
* @param {Number} numberOfWorkers
*/
createGroupsOfTests(numberOfWorkers) {
const files = this.codecept.testFiles;
const mocha = Container.mocha();
mocha.files = files;
mocha.loadFiles();
const groups = populateGroups(numberOfWorkers);
let groupCounter = 0;
mocha.suite.eachTest((test) => {
const i = groupCounter % groups.length;
if (test) {
const { id } = test;
groups[i].push(id);
groupCounter++;
}
});
return groups;
}
/**
* @param {Number} numberOfWorkers
*/
createGroupsOfSuites(numberOfWorkers) {
const files = this.codecept.testFiles;
const groups = populateGroups(numberOfWorkers);
const mocha = Container.mocha();
mocha.files = files;
mocha.loadFiles();
mocha.suite.suites.forEach((suite) => {
const i = indexOfSmallestElement(groups);
suite.tests.forEach((test) => {
if (test) {
const { id } = test;
groups[i].push(id);
}
});
});
return groups;
}
/**
* @param {Object} config
*/
overrideConfig(config) {
for (const worker of this.workers) {
worker.addConfig(config);
}
}
async bootstrapAll() {
return runHook(this.codecept.config.bootstrapAll, 'bootstrapAll');
}
async teardownAll() {
return runHook(this.codecept.config.teardownAll, 'teardownAll');
}
run() {
this.stats.start = new Date();
recorder.startUnlessRunning();
event.dispatcher.emit(event.workers.before);
recorder.add('starting workers', () => {
for (const worker of this.workers) {
const workerThread = createWorker(worker);
this._listenWorkerEvents(workerThread);
}
});
return new Promise(resolve => this.on('end', resolve));
}
/**
* @returns {Array<WorkerObject>}
*/
getWorkers() {
return this.workers;
}
/**
* @returns {Boolean}
*/
isFailed() {
return (this.stats.failures || this.errors.length) > 0;
}
_listenWorkerEvents(worker) {
worker.on('message', (message) => {
output.process(message.workerIndex);
switch (message.event) {
case event.hook.failed:
this.errors.push(message.data.err);
break;
case event.test.failed:
this._updateFinishedTests(repackTest(message.data));
this.emit(event.test.failed, repackTest(message.data));
break;
case event.test.passed:
this._updateFinishedTests(repackTest(message.data));
this.emit(event.test.passed, repackTest(message.data));
break;
case event.test.skipped:
this._updateFinishedTests(repackTest(message.data));
this.emit(event.test.skipped, repackTest(message.data));
break;
case event.test.finished: this.emit(event.test.finished, repackTest(message.data)); break;
case event.all.after:
this._appendStats(message.data); break;
}
});
worker.on('error', (err) => {
this.errors.push(err);
});
worker.on('exit', () => {
this.closedWorkers += 1;
if (this.closedWorkers === this.numberOfWorkers) {
this._finishRun();
}
});
}
_finishRun() {
event.dispatcher.emit(event.workers.after);
if (this.isFailed()) {
process.exitCode = 1;
} else {
process.exitCode = 0;
}
this.emit(event.all.result, !this.isFailed(), this.finishedTests, this.stats);
this.emit('end'); // internal event
}
_appendStats(newStats) {
this.stats.passes += newStats.passes;
this.stats.failures += newStats.failures;
this.stats.tests += newStats.tests;
this.stats.pending += newStats.pending;
}
_updateFinishedTests(test) {
const { id } = test;
if (this.finishedTests[id]) {
const stats = { passes: 0, failures: -1, tests: 0 };
this._appendStats(stats);
}
this.finishedTests[id] = test;
}
printResults() {
this.stats.end = new Date();
this.stats.duration = this.stats.end - this.stats.start;
output.print();
if (this.stats.tests === 0 || (this.stats.passes && !this.errors.length)) {
output.result(this.stats.passes, this.stats.failures, this.stats.pending, `${this.stats.duration || 0 / 1000}s`);
}
if (this.stats.failures) {
output.print();
output.print('-- FAILURES:');
const failedList = Object.keys(this.finishedTests)
.filter(key => this.finishedTests[key].err)
.map(key => this.finishedTests[key]);
Base.list(failedList);
}
}
}
module.exports = Workers;