Skip to content

Commit

Permalink
Prevent failures caused by promise rejections handled asychronously
Browse files Browse the repository at this point in the history
  • Loading branch information
stekycz committed Apr 25, 2023
1 parent d91ab8e commit 9337d1e
Show file tree
Hide file tree
Showing 5 changed files with 90 additions and 10 deletions.
52 changes: 49 additions & 3 deletions packages/jest-circus/src/eventHandler.ts
Expand Up @@ -269,9 +269,55 @@ const eventHandler: Circus.EventHandler = (event, state) => {
// execution, which will result in one test's error failing another test.
// In any way, it should be possible to track where the error was thrown
// from.
state.currentlyRunningTest
? state.currentlyRunningTest.errors.push(event.error)
: state.unhandledErrors.push(event.error);
if (state.currentlyRunningTest) {
state.currentlyRunningTest.errors.push(event.error);
if (event.promise) {
state.currentlyRunningTest.unhandledRejectionErrorByPromise.set(
event.promise,
event.error,
);
}
} else {
state.unhandledErrors.push(event.error);
if (event.promise) {
state.unhandledRejectionErrorByPromise.set(
event.promise,
event.error,
);
}
}
break;
}
case 'error_handled': {
if (state.currentlyRunningTest) {
const hasMapping =
state.currentlyRunningTest.unhandledRejectionErrorByPromise.has(
event.promise,
);
if (hasMapping) {
const error =
state.currentlyRunningTest.unhandledRejectionErrorByPromise.has(
event.promise,
);
const errorIndex = state.currentlyRunningTest.errors.indexOf(error);
state.currentlyRunningTest.errors.splice(errorIndex, 1);
state.currentlyRunningTest.unhandledRejectionErrorByPromise.delete(
event.promise,
);
}
} else {
const hasMapping = state.unhandledRejectionErrorByPromise.has(
event.promise,
);
if (hasMapping) {
const error = state.unhandledRejectionErrorByPromise.has(
event.promise,
);
const errorIndex = state.unhandledErrors.indexOf(error);
state.unhandledErrors.splice(errorIndex, 1);
state.unhandledRejectionErrorByPromise.delete(event.promise);
}
}
break;
}
}
Expand Down
38 changes: 31 additions & 7 deletions packages/jest-circus/src/globalErrorHandlers.ts
Expand Up @@ -8,34 +8,58 @@
import type {Circus} from '@jest/types';
import {dispatchSync} from './state';

const uncaught: NodeJS.UncaughtExceptionListener &
NodeJS.UnhandledRejectionListener = (error: unknown) => {
const uncaughtExceptionListener: NodeJS.UncaughtExceptionListener = (
error: unknown,
) => {
dispatchSync({error, name: 'error'});
};

const unhandledRejectionListener: NodeJS.UnhandledRejectionListener = (
error: unknown,
promise: Promise<unknown>,
) => {
dispatchSync({error, name: 'error', promise});
};

const rejectionHandledListener: NodeJS.RejectionHandledListener = (
promise: Promise<unknown>,
) => {
dispatchSync({name: 'error_handled', promise});
};

export const injectGlobalErrorHandlers = (
parentProcess: NodeJS.Process,
): Circus.GlobalErrorHandlers => {
const uncaughtException = process.listeners('uncaughtException').slice();
const unhandledRejection = process.listeners('unhandledRejection').slice();
const rejectionHandled = process.listeners('rejectionHandled').slice();
parentProcess.removeAllListeners('uncaughtException');
parentProcess.removeAllListeners('unhandledRejection');
parentProcess.on('uncaughtException', uncaught);
parentProcess.on('unhandledRejection', uncaught);
return {uncaughtException, unhandledRejection};
parentProcess.removeAllListeners('rejectionHandled');
parentProcess.on('uncaughtException', uncaughtExceptionListener);
parentProcess.on('unhandledRejection', unhandledRejectionListener);
parentProcess.on('rejectionHandled', rejectionHandledListener);
return {rejectionHandled, uncaughtException, unhandledRejection};
};

export const restoreGlobalErrorHandlers = (
parentProcess: NodeJS.Process,
originalErrorHandlers: Circus.GlobalErrorHandlers,
): void => {
parentProcess.removeListener('uncaughtException', uncaught);
parentProcess.removeListener('unhandledRejection', uncaught);
parentProcess.removeListener('uncaughtException', uncaughtExceptionListener);
parentProcess.removeListener(
'unhandledRejection',
unhandledRejectionListener,
);
parentProcess.removeListener('rejectionHandled', rejectionHandledListener);

for (const listener of originalErrorHandlers.uncaughtException) {
parentProcess.on('uncaughtException', listener);
}
for (const listener of originalErrorHandlers.unhandledRejection) {
parentProcess.on('unhandledRejection', listener);
}
for (const listener of originalErrorHandlers.rejectionHandled) {
parentProcess.on('rejectionHandled', listener);
}
};
1 change: 1 addition & 0 deletions packages/jest-circus/src/state.ts
Expand Up @@ -34,6 +34,7 @@ const createState = (): Circus.State => {
testNamePattern: null,
testTimeout: 5000,
unhandledErrors: [],
unhandledRejectionErrorByPromise: new Map(),
};
};

Expand Down
1 change: 1 addition & 0 deletions packages/jest-circus/src/utils.ts
Expand Up @@ -85,6 +85,7 @@ export const makeTest = (
startedAt: null,
status: null,
timeout,
unhandledRejectionErrorByPromise: new Map(),
});

// Traverse the tree of describe blocks and return true if at least one describe
Expand Down
8 changes: 8 additions & 0 deletions packages/jest-types/src/Circus.ts
Expand Up @@ -81,6 +81,11 @@ export type SyncEvent =
// an `afterAll` hook)
name: 'error';
error: Exception;
promise?: Promise<unknown>;
}
| {
name: 'error_handled';
promise: Promise<unknown>;
};

export type AsyncEvent =
Expand Down Expand Up @@ -198,6 +203,7 @@ export type RunResult = {
export type TestResults = Array<TestResult>;

export type GlobalErrorHandlers = {
rejectionHandled: Array<(promise: Promise<unknown>) => void>;
uncaughtException: Array<(exception: Exception) => void>;
unhandledRejection: Array<
(exception: Exception, promise: Promise<unknown>) => void
Expand All @@ -223,6 +229,7 @@ export type State = {
unhandledErrors: Array<Exception>;
includeTestLocationInResult: boolean;
maxConcurrency: number;
unhandledRejectionErrorByPromise: Map<Promise<unknown>, Exception>;
};

export type DescribeBlock = {
Expand Down Expand Up @@ -256,4 +263,5 @@ export type TestEntry = {
status?: TestStatus | null; // whether the test has been skipped or run already
timeout?: number;
failing: boolean;
unhandledRejectionErrorByPromise: Map<Promise<unknown>, Exception>;
};

0 comments on commit 9337d1e

Please sign in to comment.