519 lines
14 KiB
JavaScript
519 lines
14 KiB
JavaScript
import Promise from 'bluebird';
|
|
import RunStatus from './RunStatus';
|
|
|
|
const EVENTS = {
|
|
TEST_SUITE_STATUS: 'TEST_SUITE_STATUS',
|
|
TEST_STATUS: 'TEST_STATUS',
|
|
};
|
|
|
|
if (!console.groupCollapsed) {
|
|
console.groupCollapsed = console.log;
|
|
console.groupEnd = () => console.log('');
|
|
}
|
|
|
|
const locationRegex = /\(?http:.*:([0-9]+):([0-9]+)\)?/g;
|
|
|
|
function cleanStack(stack, maxLines = 5) {
|
|
const lines = stack.split('\n').slice(0, maxLines + 1);
|
|
const out = [];
|
|
|
|
for (let i = 0, len = lines.length; i < len; i++) {
|
|
const srcLine = lines[i].trim();
|
|
out.push(srcLine.replace(locationRegex, '()'));
|
|
}
|
|
|
|
return out.join('\r\n');
|
|
}
|
|
|
|
/**
|
|
* Class that encapsulates synchronously running a suite's tests.
|
|
*/
|
|
class TestRun {
|
|
/**
|
|
* The number of tests that have been executed so far
|
|
* @type {number}
|
|
*/
|
|
completedTests = 0;
|
|
|
|
/**
|
|
* Creates a new TestRun
|
|
* @param {TestSuite} testSuite - Test suite that tests belong to
|
|
* @param {Test[]} tests - List of test to run
|
|
* @param {TestSuiteDefinition} testDefinitions - Definition of tests and contexts
|
|
*/
|
|
constructor(testSuite, tests, testDefinitions) {
|
|
this.testSuite = testSuite;
|
|
|
|
this.tests = tests;
|
|
|
|
this.rootContextId = testDefinitions.rootTestContextId;
|
|
|
|
this.testContexts = tests.reduce((memo, test) => {
|
|
const { testContextId } = test;
|
|
|
|
this._recursivelyAddContextsTo(
|
|
memo,
|
|
testContextId,
|
|
testDefinitions.testContexts
|
|
);
|
|
|
|
memo[testContextId].tests.unshift(test);
|
|
|
|
return memo;
|
|
}, {});
|
|
|
|
this.listeners = {
|
|
[EVENTS.TEST_STATUS]: [],
|
|
[EVENTS.TEST_SUITE_STATUS]: [],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Registers a listener for a change event
|
|
* @param {String} action - one of the actions in EVENTS
|
|
* @param {Function} callback - Callback that accepts event object
|
|
*/
|
|
onChange(action, callback) {
|
|
this.listeners[action].push(callback);
|
|
}
|
|
|
|
/**
|
|
* Walks up a context tree, copying test contexts from a source object to a target one.
|
|
* Used for ensuring all of a test's parent contexts are added to the target object.
|
|
* @param {Object<Number,TestContext>} target - Object to put test contexts in
|
|
* @param {Number} id - Id of current context to add to target
|
|
* @param {Object<Number,TestContext>} source - Object to get complete list of test contexts
|
|
* from.
|
|
* @param {Number} childContextId - id of child of current context
|
|
* @private
|
|
*/
|
|
_recursivelyAddContextsTo(target, id, source, childContextId = null) {
|
|
const testContext = source[id];
|
|
|
|
if (!target[id]) {
|
|
// eslint-disable-next-line no-param-reassign
|
|
target[id] = {
|
|
...testContext,
|
|
tests: [],
|
|
childContextIds: {},
|
|
};
|
|
}
|
|
|
|
if (childContextId) {
|
|
// eslint-disable-next-line no-param-reassign
|
|
target[id].childContextIds[childContextId] = true;
|
|
}
|
|
|
|
const { parentContextId } = testContext;
|
|
|
|
if (parentContextId) {
|
|
this._recursivelyAddContextsTo(target, parentContextId, source, id);
|
|
}
|
|
}
|
|
|
|
_updateStatus(action, values) {
|
|
const listeners = this.listeners[action];
|
|
|
|
listeners.forEach(listener => listener(values));
|
|
}
|
|
|
|
/**
|
|
* Execute the tests TestRun was initialised with.
|
|
* @returns {Promise.<void>} Resolves once all the tests in the test suite have
|
|
* completed running.
|
|
*/
|
|
async execute() {
|
|
const store = this.testSuite.reduxStore;
|
|
|
|
if (!store) {
|
|
testRuntimeError(
|
|
`Failed to run ${
|
|
this.testSuite.name
|
|
} tests as no Redux store has been provided`
|
|
);
|
|
}
|
|
|
|
this._updateStatus(EVENTS.TEST_SUITE_STATUS, {
|
|
suiteId: this.testSuite.id,
|
|
status: RunStatus.RUNNING,
|
|
|
|
progress: 0,
|
|
time: 0,
|
|
});
|
|
|
|
// Start timing
|
|
|
|
this.runStartTime = Date.now();
|
|
|
|
const rootContext = this.testContexts[this.rootContextId];
|
|
|
|
if (rootContext) {
|
|
await this._runTestsInContext(rootContext);
|
|
|
|
const errors = this.tests.filter(test => test.status === RunStatus.ERR);
|
|
|
|
if (errors.length) {
|
|
this._updateStatus(EVENTS.TEST_SUITE_STATUS, {
|
|
suiteId: this.testSuite.id,
|
|
status: RunStatus.ERR,
|
|
progress: 100,
|
|
|
|
time: Date.now() - this.runStartTime,
|
|
message: `${errors.length} test${
|
|
errors.length > 1 ? 's' : ''
|
|
} has error(s).`,
|
|
});
|
|
} else {
|
|
this._updateStatus(EVENTS.TEST_SUITE_STATUS, {
|
|
suiteId: this.testSuite.id,
|
|
status: RunStatus.OK,
|
|
progress: 100,
|
|
|
|
time: Date.now() - this.runStartTime,
|
|
message: '',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recursively enter a test context and run its before, beforeEach hooks where
|
|
* appropriate; execute the test and then run afterEach and after hooks where
|
|
* appropriate.
|
|
* @param {TestContext} testContext - context to run hooks for
|
|
* @param {Function[][]} beforeEachHooks - stack of beforeEach hooks defined
|
|
* in parent contexts that should be run beforeEach test in child contexts
|
|
* @param {Function[][]} afterEachHooks - stack of afterEach hooks defined
|
|
* in parent contexts that should be run afterEach test in child contexts
|
|
* @returns {Promise.<void>} Resolves once all tests and their hooks have run
|
|
* @private
|
|
*/
|
|
async _runTestsInContext(
|
|
testContext,
|
|
beforeEachHooks = [],
|
|
afterEachHooks = []
|
|
) {
|
|
const beforeHookRan = await this._runContextHooks(testContext, 'before');
|
|
|
|
if (beforeHookRan) {
|
|
beforeEachHooks.push(testContext.beforeEachHooks || []);
|
|
afterEachHooks.unshift(testContext.afterEachHooks || []);
|
|
|
|
await this._runTests(
|
|
testContext,
|
|
testContext.tests,
|
|
flatten(beforeEachHooks),
|
|
flatten(afterEachHooks)
|
|
);
|
|
|
|
await Promise.each(
|
|
Object.keys(testContext.childContextIds),
|
|
childContextId => {
|
|
const childContext = this.testContexts[childContextId];
|
|
return this._runTestsInContext(
|
|
childContext,
|
|
beforeEachHooks,
|
|
afterEachHooks
|
|
);
|
|
}
|
|
);
|
|
|
|
beforeEachHooks.pop();
|
|
afterEachHooks.shift();
|
|
|
|
await this._runContextHooks(testContext, 'after');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Synchronously run hooks in context's (before|after) hooks, starting from the first
|
|
* hook
|
|
* @param {TestContext} testContext - context containing hooks
|
|
* @param {('before'|'after')} hookName - name of hooks to run callbacks for
|
|
* @returns {Promise.<*>} Resolves when last hook in list has been executed
|
|
* @private
|
|
*/
|
|
async _runContextHooks(testContext, hookName) {
|
|
const hooks = testContext[`${hookName}Hooks`] || [];
|
|
|
|
return this._runHookChain(null, Date.now(), testContext, hookName, hooks);
|
|
}
|
|
|
|
_runHookChain(test, testStart, testContext, hookName, hooks) {
|
|
return Promise.each(hooks, async hook => {
|
|
const error = await this._safelyRunFunction(
|
|
hook.callback,
|
|
hook.timeout,
|
|
`${hookName} hook`
|
|
);
|
|
|
|
if (error) {
|
|
const errorPrefix = `Error occurred in "${
|
|
testContext.name
|
|
}" ${hookName} Hook: `;
|
|
|
|
if (test) {
|
|
this._reportTestError(
|
|
test,
|
|
error,
|
|
Date.now() - testStart,
|
|
errorPrefix
|
|
);
|
|
} else {
|
|
this._reportAllTestsAsFailed(
|
|
testContext,
|
|
error,
|
|
testStart,
|
|
errorPrefix
|
|
);
|
|
}
|
|
|
|
throw new Error();
|
|
}
|
|
})
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param testContext
|
|
* @param error
|
|
* @param testStart
|
|
* @param errorPrefix
|
|
* @private
|
|
*/
|
|
_reportAllTestsAsFailed(testContext, error, testStart, errorPrefix) {
|
|
testContext.tests.forEach(test => {
|
|
this._reportTestError(test, error, Date.now() - testStart, errorPrefix);
|
|
});
|
|
|
|
testContext.childContextIds.forEach(contextId => {
|
|
this._reportAllTestsAsFailed(
|
|
this.testContext[contextId],
|
|
error,
|
|
testStart,
|
|
errorPrefix
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Synchronously run a list of tests
|
|
* @param {TestContext} testContext - Test context to run beforeEach and AfterEach hooks
|
|
* for
|
|
* @param {Test[]} tests - List of tests to run
|
|
* @param {Function[]} beforeEachHooks - list of functions to run before each test
|
|
* @param {Function[]} afterEachHooks - list of functions to run after each test
|
|
* @returns {Promise.<void>} - Resolves once all tests and their afterEach hooks have
|
|
* been run
|
|
* @private
|
|
*/
|
|
async _runTests(testContext, tests, beforeEachHooks, afterEachHooks) {
|
|
return Promise.each(tests, async test => {
|
|
this._updateStatus(EVENTS.TEST_STATUS, {
|
|
testId: test.id,
|
|
status: RunStatus.RUNNING,
|
|
time: 0,
|
|
message: '',
|
|
});
|
|
|
|
const testStart = Date.now();
|
|
|
|
const beforeEachRan = await this._runHookChain(
|
|
test,
|
|
testStart,
|
|
testContext,
|
|
'beforeEach',
|
|
beforeEachHooks
|
|
);
|
|
|
|
if (beforeEachRan) {
|
|
const error = await this._safelyRunFunction(
|
|
test.func.bind(null, [test, this.testSuite.reduxStore.getState()]),
|
|
test.timeout,
|
|
'Test'
|
|
);
|
|
|
|
// Update test status
|
|
|
|
if (error) {
|
|
this._reportTestError(test, error, Date.now() - testStart);
|
|
console.groupCollapsed(
|
|
`%c ❌ Test Failed: ${test.description} (${this.testSuite.name})`,
|
|
'color: #f44336;'
|
|
);
|
|
console.log(`Test Description: ${test.description}`);
|
|
console.log(`Test Time Taken: ${Date.now() - testStart}`);
|
|
console.log(`Suite Name: ${this.testSuite.name}`);
|
|
console.log(`Suite Description: ${this.testSuite.description}`);
|
|
console.log(error);
|
|
console.groupEnd();
|
|
} else {
|
|
// eslint-disable-next-line no-param-reassign
|
|
test.status = RunStatus.OK;
|
|
|
|
this._updateStatus(EVENTS.TEST_STATUS, {
|
|
testId: test.id,
|
|
status: RunStatus.OK,
|
|
time: Date.now() - testStart,
|
|
message: '',
|
|
});
|
|
|
|
console.groupCollapsed(
|
|
`%c ✅ Test Passed: ${test.description} (${this.testSuite.name})`,
|
|
'color: #4CAF50;'
|
|
);
|
|
console.log(`Test Description: ${test.description}`);
|
|
console.log(`Test Time Taken: ${Date.now() - testStart}`);
|
|
console.log(`Suite Name: ${this.testSuite.name}`);
|
|
console.log(`Suite Description: ${this.testSuite.description}`);
|
|
console.groupEnd();
|
|
}
|
|
|
|
// Update suite progress
|
|
|
|
this.completedTests += 1;
|
|
|
|
this._updateStatus(EVENTS.TEST_SUITE_STATUS, {
|
|
suiteId: this.testSuite.id,
|
|
status: RunStatus.RUNNING,
|
|
progress: this.completedTests / this.tests.length * 100,
|
|
time: Date.now() - this.runStartTime,
|
|
message: '',
|
|
});
|
|
|
|
await this._runHookChain(
|
|
test,
|
|
testStart,
|
|
testContext,
|
|
'afterEach',
|
|
afterEachHooks
|
|
);
|
|
}
|
|
}).catch(error => {
|
|
this._updateStatus(EVENTS.TEST_SUITE_STATUS, {
|
|
suiteId: this.testSuite.id,
|
|
status: RunStatus.ERR,
|
|
time: Date.now() - this.runStartTime,
|
|
message: `Test suite failed: ${error.message}`,
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param test
|
|
* @param error
|
|
* @param time
|
|
* @param errorPrefix
|
|
* @private
|
|
*/
|
|
_reportTestError(test, error, time, errorPrefix = '') {
|
|
// eslint-disable-next-line no-param-reassign
|
|
test.status = RunStatus.ERR;
|
|
this._updateStatus(EVENTS.TEST_STATUS, {
|
|
testId: test.id,
|
|
status: RunStatus.ERR,
|
|
time,
|
|
message: `${errorPrefix}${
|
|
error.message ? `${error.name}: ${error.message}` : error
|
|
}`,
|
|
stackTrace: cleanStack(error.stack),
|
|
});
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param func
|
|
* @param timeOutDuration
|
|
* @param description
|
|
* @return {Promise.<*>}
|
|
* @private
|
|
*/
|
|
async _safelyRunFunction(func, timeOutDuration, description) {
|
|
const syncResultOrPromise = captureThrownErrors(func);
|
|
|
|
if (syncResultOrPromise.error) {
|
|
// Synchronous Error
|
|
return syncResultOrPromise.error;
|
|
}
|
|
|
|
// Asynchronous Error
|
|
return capturePromiseErrors(
|
|
syncResultOrPromise.result,
|
|
timeOutDuration,
|
|
description
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Call a function and capture any errors that are immediately thrown.
|
|
* @returns {Object} Object containing result of executing the function, or the error
|
|
* message that was captured
|
|
* @private
|
|
*/
|
|
|
|
function captureThrownErrors(func) {
|
|
const result = {};
|
|
|
|
try {
|
|
result.result = func();
|
|
} catch (error) {
|
|
result.error = error;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Wraps a promise so that if it's rejected or an error is thrown while it's being
|
|
* evaluated, it's captured and thrown no further
|
|
* @param {*} target - Target to wrap. If a thenable object, it's wrapped so if it's
|
|
* rejected or an error is thrown, it will be captured. If a non-thenable object,
|
|
* wrapped in resolved promise and returned.
|
|
* @param {Number} timeoutDuration - Number of milliseconds the promise is allowed
|
|
* to pend before it's considered timed out
|
|
* @param {String} description - Description of the context the promises is defined
|
|
* in, used for reporting where a timeout occurred in the resulting error message.
|
|
* @private
|
|
*/
|
|
|
|
function capturePromiseErrors(target, timeoutDuration, description) {
|
|
let returnValue = null;
|
|
|
|
try {
|
|
returnValue = Promise.resolve(target)
|
|
.then(() => null, error => Promise.resolve(error))
|
|
.catch(error => Promise.resolve(error))
|
|
.timeout(
|
|
timeoutDuration,
|
|
`${description} took longer than ${timeoutDuration}ms. This can be extended with the timeout option.`
|
|
);
|
|
} catch (error) {
|
|
returnValue = Promise.resolve(error);
|
|
}
|
|
|
|
return returnValue;
|
|
}
|
|
|
|
/**
|
|
* Flatten a two dimensional array to a single dimensional array
|
|
* @param {*[]} list - two dimensional array
|
|
* @returns {*[]} One-dimensional array
|
|
*/
|
|
function flatten(list) {
|
|
return list.reduce((memo, contextHooks) => memo.concat(contextHooks), []);
|
|
}
|
|
|
|
/**
|
|
* Log a runtime error to the console
|
|
* @param {String} error - Message to log to the console
|
|
*/
|
|
function testRuntimeError(error) {
|
|
console.error(`ReactNativeFirebaseTests.TestRuntimeError: ${error}`);
|
|
}
|
|
|
|
export default TestRun;
|