refactor: Detox reporter (#4214)

This commit is contained in:
Yaroslav Serhieiev
2023-10-11 19:45:53 +03:00
committed by GitHub
parent 1243c75aa0
commit 32a6051292
9 changed files with 412 additions and 155 deletions
+18
View File
@@ -0,0 +1,18 @@
const DetoxReporter = require('../jest/reporter');
test('DetoxReporter', () => {
const reporter = new DetoxReporter({
bail: 0,
reporters: [['default', {}]],
});
expect(reporter.getLastError).toBeInstanceOf(Function);
expect(reporter.onRunStart).toBeInstanceOf(Function);
expect(reporter.onTestFileStart).toBeInstanceOf(Function);
expect(reporter.onTestStart).toBeInstanceOf(Function);
expect(reporter.onTestCaseStart).toBeInstanceOf(Function);
expect(reporter.onTestCaseResult).toBeInstanceOf(Function);
expect(reporter.onTestFileResult).toBeInstanceOf(Function);
expect(reporter.onTestResult).toBeInstanceOf(Function);
expect(reporter.onRunComplete).toBeInstanceOf(Function);
});
+21 -1
View File
@@ -1 +1,21 @@
module.exports = require('./reporters/DetoxReporter');
/** @typedef {import('@jest/reporters').Reporter} Reporter */
const {
DetoxIPCReporter,
DetoxReporterDispatcher,
DetoxSummaryReporter,
DetoxVerboseReporter,
} = require('./reporters');
/** @implements {Reporter} */
class DetoxReporter extends DetoxReporterDispatcher {
constructor(globalConfig) {
super(globalConfig, {
DetoxSummaryReporter,
DetoxVerboseReporter,
DetoxIPCReporter,
});
}
}
module.exports = DetoxReporter;
@@ -0,0 +1,34 @@
const { config, reportTestResults } = require('../../../internals');
/** @typedef {import('@jest/reporters').Reporter} Reporter */
/** @implements {Partial<Reporter>} */
class DetoxIPCReporter {
/**
* @param {Set<import('@jest/reporters').TestContext>} testContexts
* @param {import('@jest/reporters').AggregatedResult} aggregatedResult
*/
async onRunComplete(testContexts, aggregatedResult) {
const lostTests = aggregatedResult.numTotalTestSuites - aggregatedResult.testResults.length;
await reportTestResults(aggregatedResult.testResults.map(r => ({
success: !r.failureMessage,
testFilePath: r.testFilePath,
testExecError: r.testExecError,
isPermanentFailure: lostTests > 0 || this._isPermanentFailure(r),
})));
}
/**
* @param {import('@jest/test-result').TestResult} testResult
*/
_isPermanentFailure(testResult) {
if (config.testRunner.jest.retryAfterCircusRetries) {
return false;
}
return testResult.testResults.some(r => r.status === 'failed' && r.invocations > 1);
}
}
module.exports = DetoxIPCReporter;
@@ -1,154 +0,0 @@
const resolveFrom = require('resolve-from');
/** @type {new (globalConfig: any) => import('@jest/reporters').VerboseReporter} */
const JestVerboseReporter = require(resolveFrom(process.cwd(), '@jest/reporters')).VerboseReporter;
/** @type {new (globalConfig: any) => import('@jest/reporters').SummaryReporter} */
const SummaryReporter = require(resolveFrom(process.cwd(), '@jest/reporters')).SummaryReporter;
const { config, reportTestResults, unsafe_conductEarlyTeardown, cleanup } = require('../../../internals');
const Deferred = require('../../../src/utils/Deferred');
class DetoxReporter extends JestVerboseReporter {
constructor(globalConfig) {
super(globalConfig);
/** @type {Deferred | null} */
this._lastRunComplete = null;
/** @type {Set<string>} */
this._pendingTestFiles = new Set();
/** @type {Promise<any> | null} */
this._runCompletePromise = null;
/** @type {import('@jest/reporters').SummaryReporter | null} */
this._summaryReporter = this._initSummaryReporter();
}
onRunStart(aggregatedResults, options) {
super.onRunStart(aggregatedResults, options);
if (this._summaryReporter) {
this._summaryReporter.onRunStart(aggregatedResults, options);
}
}
onTestFileStart(test) {
this._pendingTestFiles.add(test.path);
// @ts-ignore Precaution in case Jest migrates to the new signature
if (typeof super.onTestFileStart === 'function') {
// @ts-ignore
super.onTestFileStart(test);
} else {
super.onTestStart(test);
}
}
onTestFileResult(test, testResult, aggregatedResult) {
this._pendingTestFiles.delete(test.path);
// @ts-ignore Precaution in case Jest migrates to the new signature
if (typeof super.onTestFileResult === 'function') {
// @ts-ignore
super.onTestFileResult(test, testResult, aggregatedResult);
} else {
super.onTestResult(test, testResult, aggregatedResult);
}
if (this._lastRunComplete && this._pendingTestFiles.size === 0) {
this._lastRunComplete.resolve(aggregatedResult);
}
}
/**
* @param {Set<import('@jest/reporters').TestContext>} testContexts
* @param {import('@jest/reporters').AggregatedResult} aggregatedResult
* @returns {Promise<any> | null}
*/
// @ts-ignore We need to use the complete signature, not the one from the base class
onRunComplete(testContexts, aggregatedResult) {
if (!this._runCompletePromise) {
// Both `_lastRunComplete` and `_runCompletePromise` are used to prevent
// a bug in Jest, where `onRunComplete` is called multiple times when
// Jest runs with `--bail` and multiple workers, and `onRunComplete`
// is implemented as an asynchronous long-running operation.
this._lastRunComplete = this._pendingTestFiles.size === 0
? Deferred.resolved(aggregatedResult)
: new Deferred();
this._runCompletePromise = this._onRunComplete(testContexts, aggregatedResult).catch(err => {
console.error(err);
throw err;
});
}
return this._runCompletePromise;
}
/**
* @param {Set<import('@jest/reporters').TestContext>} testContexts
* @param {import('@jest/reporters').AggregatedResult} aggregatedResult
* @returns {Promise<any> | null}
*/
async _onRunComplete(testContexts, { numFailedTests }) {
const bail = this._globalConfig.bail;
const earlyTeardown = bail > 0 && numFailedTests >= bail;
if (earlyTeardown) {
await unsafe_conductEarlyTeardown();
}
const aggregatedResult = await this._lastRunComplete.promise;
const lostTests = aggregatedResult.numTotalTestSuites - aggregatedResult.testResults.length;
// @ts-expect-error TS2554
super.onRunComplete(testContexts, aggregatedResult);
if (earlyTeardown && lostTests > 0 && config.testRunner.retries > 0) {
console.warn(
'Jest aborted the test execution before all scheduled test files have been reported.\n' +
'This brings us to a dilemma: retry the whole test run, or retry only known failed test files.\n' +
'Both options are bad in their own way, this is why we have decided to not support this edge case.\n' +
'If you want to retry the whole test run, please disable Jest\'s --bail option.'
);
}
await reportTestResults(aggregatedResult.testResults.map(r => ({
success: !r.failureMessage,
testFilePath: r.testFilePath,
testExecError: r.testExecError,
isPermanentFailure: lostTests > 0 || this._isPermanentFailure(r),
})));
if (this._summaryReporter) {
this._summaryReporter.onRunComplete(testContexts, aggregatedResult);
}
if (earlyTeardown) {
await cleanup();
}
}
/**
* @returns {import('@jest/reporters').SummaryReporter | null}
* @private
*/
_initSummaryReporter() {
/** @type {(config: import('@jest/types').Config.ReporterConfig) => boolean} */
const isSummaryReporter = (config) => config[0] === 'summary';
if (this._globalConfig.reporters.some(isSummaryReporter)) {
return null;
}
return new SummaryReporter(this._globalConfig);
}
/**
* @param {import('@jest/test-result').TestResult} testResult
*/
_isPermanentFailure(testResult) {
if (config.testRunner.jest.retryAfterCircusRetries) {
return false;
}
return testResult.testResults.some(r => r.status === 'failed' && r.invocations > 1);
}
}
module.exports = DetoxReporter;
@@ -0,0 +1,144 @@
/** @typedef {import('@jest/reporters').Reporter} Reporter */
const { config, unsafe_conductEarlyTeardown } = require('../../../internals');
const Deferred = require('../../../src/utils/Deferred');
/** @implements {Reporter} */
class DetoxReporterDispatcher {
/**
* @param {import('@jest/types').Config.GlobalConfig} globalConfig
* @param {Record<string, new (globalConfig: import('@jest/types').Config.GlobalConfig) => Partial<Reporter>>} reporters
*/
constructor(globalConfig, reporters) {
this._bail = globalConfig.bail;
/** @type {Deferred | null} */
this._lastRunComplete = null;
/** @type {Set<string>} */
this._pendingTestFiles = new Set();
/** @type {Promise<any> | null} */
this._onRunCompletePromise = null;
/** @type {Partial<Reporter>[]} */
this._reporters = Object.values(reporters).map((Reporter) => new Reporter(globalConfig));
}
getLastError() {
for (const reporter of this._reporters) {
let error = typeof reporter.getLastError === 'function'
? reporter.getLastError()
: undefined;
if (error) {
return error;
}
}
return;
}
onRunStart(aggregatedResult, options) {
return this._dispatch('onRunStart', aggregatedResult, options);
}
onTestFileStart(test) {
this._pendingTestFiles.add(test.path);
return this._dispatch(['onTestFileStart', 'onTestStart'], test);
}
onTestStart(test) {
// Legacy method
return this.onTestFileStart(test);
}
// NEW! Supported only since Jest 29.6.0
onTestCaseStart(test, testCaseStartInfo) {
return this._dispatch('onTestCaseStart', test, testCaseStartInfo);
}
onTestCaseResult(test, testCaseResult) {
return this._dispatch('onTestCaseResult', test, testCaseResult);
}
async onTestFileResult(test, testResult, aggregatedResult) {
this._pendingTestFiles.delete(test.path);
await this._dispatch(['onTestFileResult', 'onTestResult'], test, testResult, aggregatedResult);
if (this._lastRunComplete && this._pendingTestFiles.size === 0) {
this._lastRunComplete.resolve(aggregatedResult);
}
}
onTestResult(test, testResult, aggregatedResult) {
// Legacy method
return this.onTestFileResult(test, testResult, aggregatedResult);
}
onRunComplete(testContexts, aggregatedResult) {
if (!this._lastRunComplete) {
// Both `_lastRunComplete` and `_onRunCompletePromise` are used to prevent
// a bug in Jest, where `onRunComplete` is called multiple times when
// Jest runs with `--bail` and multiple workers, and `onRunComplete`
// is implemented as an asynchronous long-running operation.
this._lastRunComplete = this._pendingTestFiles.size === 0
? Deferred.resolved(aggregatedResult)
: new Deferred();
}
if (!this._onRunCompletePromise) {
this._onRunCompletePromise = this._doRunComplete(testContexts, aggregatedResult);
}
return this._onRunCompletePromise;
}
/**
* @private
* @param {Set<import('@jest/reporters').TestContext>} testContexts
* @param {import('@jest/test-result').AggregatedResult} aggregatedResult
* @returns {Promise<void>}
*/
async _doRunComplete(testContexts, aggregatedResult) {
const earlyTeardown = this._bail > 0 && aggregatedResult.numFailedTests >= this._bail;
if (earlyTeardown) {
const lostTests = aggregatedResult.numTotalTestSuites - aggregatedResult.testResults.length;
if (lostTests > 0 && config.testRunner.retries > 0) {
console.warn(
'Jest aborted the test execution before all scheduled test files have been reported.\n' +
'If you want to retry the whole test run, please disable Jest\'s --bail option.'
);
}
await unsafe_conductEarlyTeardown();
}
await this._lastRunComplete.promise;
await this._dispatch('onRunComplete', testContexts, aggregatedResult);
}
/**
* @private
* @param {string | string[]} rawMethodNames
* @param {...any} args
* @returns {Promise<void>}
*/
_dispatch(rawMethodNames, ...args) {
const methodNames = Array.isArray(rawMethodNames)
? rawMethodNames
: [rawMethodNames];
const maybePromises = [];
for (const reporter of this._reporters) {
for (const methodName of methodNames) {
if (typeof reporter[methodName] === 'function') {
maybePromises.push(reporter[methodName](...args));
break;
}
}
}
return Promise.all(maybePromises).then(() => void 0);
}
}
module.exports = DetoxReporterDispatcher;
@@ -0,0 +1,157 @@
jest.mock('../../../internals', () => ({
config: { testRunner: {} },
unsafe_conductEarlyTeardown: jest.fn(),
}));
describe('DetoxReporterDispatcher', () => {
/** @type {import('./DetoxReporterDispatcher')} */
let dispatcher;
/** @type {any} */
let mockGlobalConfig;
/** @type {LegacyReporter} */
let legacyReporter;
/** @type {ModernReporter} */
let modernReporter;
class LegacyReporter {
constructor(globalConfig) {
if (globalConfig) {
legacyReporter = this;
}
}
getLastError = jest.fn();
onRunStart = jest.fn();
onTestStart = jest.fn();
onTestCaseStart = jest.fn();
onTestCaseResult = jest.fn();
onTestResult = jest.fn();
onRunComplete = jest.fn();
}
class ModernReporter extends LegacyReporter {
constructor() {
super(null);
modernReporter = this;
}
onTestFileStart = jest.fn();
onTestFileResult = jest.fn();
}
beforeEach(() => {
mockGlobalConfig = { bail: 2 };
const DetoxReporterDispatcher = require('./DetoxReporterDispatcher');
dispatcher = new DetoxReporterDispatcher(mockGlobalConfig, {
LegacyReporter,
ModernReporter,
});
});
it('should instantiate reporters', () => {
expect(legacyReporter).toBeInstanceOf(LegacyReporter);
expect(modernReporter).toBeInstanceOf(ModernReporter);
});
it('should return first error from getLastError method', () => {
const [error1, error2] = [new Error('1'), new Error('2')];
legacyReporter.getLastError.mockReturnValue(error1);
modernReporter.getLastError.mockReturnValue(error2);
expect(dispatcher.getLastError()).toBe(error1);
});
it.each([
['onRunStart'],
['onTestCaseStart'],
['onTestCaseResult'],
['onRunComplete'],
])('should dispatch %s calls to all reporters', async (methodName) => {
const args = [{ path: 'test' }, { arg: 2 }, { arg: 3 }].slice(0, dispatcher[methodName].length);
await dispatcher[methodName](...args);
expect(legacyReporter[methodName]).toHaveBeenCalledWith(...args);
expect(modernReporter[methodName]).toHaveBeenCalledWith(...args);
});
it.each([
['onTestFileStart', 'onTestStart'],
['onTestFileResult', 'onTestResult'],
])('should dispatch %s/%s calls to all reporters', async (modernMethod, legacyMethod) => {
const args = [{ path: 'test' }, { arg: 2 }, { arg: 3 }].slice(0, dispatcher[modernMethod].length);
for (const methodName of [modernMethod, legacyMethod]) {
await dispatcher[methodName](...args);
expect(legacyReporter[legacyMethod]).toHaveBeenCalledWith(...args);
expect(modernReporter[modernMethod]).toHaveBeenCalledWith(...args);
expect(modernReporter[legacyMethod]).not.toHaveBeenCalled();
legacyReporter[legacyMethod].mockClear();
modernReporter[modernMethod].mockClear();
}
});
describe('onRunComplete', () => {
let unsafe_conductEarlyTeardown;
beforeEach(() => {
jest.spyOn(console, 'warn');
unsafe_conductEarlyTeardown = require('../../../internals').unsafe_conductEarlyTeardown;
});
describe('upon a regular exit', () => {
const testContexts = new Set();
const aggregatedResult = { numFailedTests: 1, numTotalSuites: 0, testResults: [] };
it('should not call unsafe cleanup on regular exit', async () => {
await dispatcher.onRunComplete(testContexts, aggregatedResult);
expect(unsafe_conductEarlyTeardown).not.toHaveBeenCalled();
expect(console.warn).not.toHaveBeenCalled();
});
it('can be called only once per reporter lifecycle', async () => {
const promise1 = dispatcher.onRunComplete(testContexts, aggregatedResult);
const promise2 = dispatcher.onRunComplete(testContexts, aggregatedResult);
expect(promise1).toBe(promise2);
});
});
describe('upon an early exit', () => {
const testContexts = new Set();
let aggregatedResult;
let config;
beforeEach(() => {
// 2 > 1 (numFailedTests > bail)
aggregatedResult = { numFailedTests: 2, numTotalTestSuites: 1, testResults: [] };
config = require('../../../internals').config;
});
it('should call unsafe cleanup on early exit', async () => {
await dispatcher.onRunComplete(testContexts, aggregatedResult);
expect(unsafe_conductEarlyTeardown).toHaveBeenCalled();
expect(console.warn).not.toHaveBeenCalled();
});
it('should wait for all test files to finish', async () => {
await dispatcher.onTestFileStart({ path: 'a' });
const runCompletePromise = dispatcher.onRunComplete(testContexts, aggregatedResult);
const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 100, 'timeout'));
await expect(Promise.race([runCompletePromise, timeoutPromise])).resolves.toBe('timeout');
await dispatcher.onTestFileResult({ path: 'a' }, {});
await expect(runCompletePromise).resolves.toBe(undefined);
});
it('should warn about mismatching number of failed test suites if -R, --retries > 0', async () => {
config.testRunner.retries = 1;
await dispatcher.onRunComplete(testContexts, aggregatedResult);
expect(unsafe_conductEarlyTeardown).toHaveBeenCalled();
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('aborted'));
});
});
});
});
@@ -0,0 +1,16 @@
/** @typedef {import('@jest/reporters').Reporter} Reporter */
const resolveFrom = require('resolve-from');
/** @type {new (globalConfig: any) => import('@jest/reporters').SummaryReporter} */
const SummaryReporter = require(resolveFrom(process.cwd(), '@jest/reporters')).SummaryReporter;
/** @implements {Partial<Reporter>} */
class DetoxSummaryReporter {
constructor(globalConfig) {
if (globalConfig.reporters.every(([name]) => name !== 'default' && name !== 'summary')) {
return new SummaryReporter(globalConfig);
}
}
}
module.exports = DetoxSummaryReporter;
@@ -0,0 +1,16 @@
/** @typedef {import('@jest/reporters').Reporter} Reporter */
const resolveFrom = require('resolve-from');
/** @type {new (globalConfig: any) => import('@jest/reporters').VerboseReporter} */
const VerboseReporter = require(resolveFrom(process.cwd(), '@jest/reporters')).VerboseReporter;
/** @implements {Partial<Reporter>} */
class DetoxVerboseReporter {
constructor(globalConfig) {
if (globalConfig.reporters.every(([name]) => name !== 'default')) {
return new VerboseReporter(globalConfig);
}
}
}
module.exports = DetoxVerboseReporter;
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
DetoxIPCReporter: require('./DetoxIPCReporter'),
DetoxReporterDispatcher: require('./DetoxReporterDispatcher'),
DetoxSummaryReporter: require('./DetoxSummaryReporter'),
DetoxVerboseReporter: require('./DetoxVerboseReporter'),
};