Prevent false test failures caused by promise rejections handled asynchronously (#14315)

This commit is contained in:
Martin Štekl 2023-09-20 17:10:09 +02:00 committed by GitHub
parent e7b1e757f7
commit db42fe5071
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 772 additions and 19 deletions

View File

@ -14,6 +14,7 @@
### Fixes
- `[babel-plugin-jest-hoist]` Use `denylist` instead of the deprecated `blacklist` for Babel 8 support ([#14109](https://github.com/jestjs/jest/pull/14109))
- `[jest-circus]` [**BREAKING**] Prevent false test failures caused by promise rejections handled asynchronously ([#14315](https://github.com/jestjs/jest/pull/14315))
- `[@jest/expect-utils]` Fix comparison of `DataView` ([#14408](https://github.com/jestjs/jest/pull/14408))
- `[jest-leak-detector]` Make leak-detector more aggressive when running GC ([#14526](https://github.com/jestjs/jest/pull/14526))
- `[jest-util]` Make sure `isInteractive` works in a browser ([#14552](https://github.com/jestjs/jest/pull/14552))

View File

@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`prints useful error for environment methods after test is done 1`] = `
"ReferenceError: You are trying to access a property or method of the Jest environment after it has been torn down. From __tests__/afterTeardown.test.js.
" ReferenceError: You are trying to access a property or method of the Jest environment outside of the scope of the test code.
9 | test('access environment methods after done', () => {
10 | setTimeout(() => {

View File

@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`prints useful error for environment methods after test is done 1`] = `
"ReferenceError: You are trying to access a property or method of the Jest environment after it has been torn down. From __tests__/afterTeardown.test.js.
9 | test('access environment methods after done', () => {
10 | setTimeout(() => {
> 11 | jest.clearAllTimers();
| ^
12 | }, 0);
13 | });
14 |"
`;

View File

@ -0,0 +1,236 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`fails because of unhandled promise rejection in afterAll hook 1`] = `
Object {
"rest": "FAIL __tests__/unhandledRejectionAfterAll.test.js
● Test suite failed to run
REJECTED
11 |
12 | afterAll(async () => {
> 13 | Promise.reject(new Error('REJECTED'));
| ^
14 |
15 | await promisify(setTimeout)(0);
16 | });
at Object.<anonymous> (__tests__/unhandledRejectionAfterAll.test.js:13:18)",
"summary": "Test Suites: 1 failed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: <<REPLACED>>
Ran all test suites matching /unhandledRejectionAfterAll.test.js/i.",
}
`;
exports[`fails because of unhandled promise rejection in afterEach hook 1`] = `
Object {
"rest": "FAIL __tests__/unhandledRejectionAfterEach.test.js
✕ foo #1
✕ foo #2
● foo #1
REJECTED
11 |
12 | afterEach(async () => {
> 13 | Promise.reject(new Error('REJECTED'));
| ^
14 |
15 | await promisify(setTimeout)(0);
16 | });
at Object.<anonymous> (__tests__/unhandledRejectionAfterEach.test.js:13:18)
● foo #2
REJECTED
11 |
12 | afterEach(async () => {
> 13 | Promise.reject(new Error('REJECTED'));
| ^
14 |
15 | await promisify(setTimeout)(0);
16 | });
at Object.<anonymous> (__tests__/unhandledRejectionAfterEach.test.js:13:18)",
"summary": "Test Suites: 1 failed, 1 total
Tests: 2 failed, 2 total
Snapshots: 0 total
Time: <<REPLACED>>
Ran all test suites matching /unhandledRejectionAfterEach.test.js/i.",
}
`;
exports[`fails because of unhandled promise rejection in beforeAll hook 1`] = `
Object {
"rest": "FAIL __tests__/unhandledRejectionBeforeAll.test.js
✕ foo
● foo
REJECTED
11 |
12 | beforeAll(async () => {
> 13 | Promise.reject(new Error('REJECTED'));
| ^
14 |
15 | await promisify(setTimeout)(0);
16 | });
at Object.<anonymous> (__tests__/unhandledRejectionBeforeAll.test.js:13:18)",
"summary": "Test Suites: 1 failed, 1 total
Tests: 1 failed, 1 total
Snapshots: 0 total
Time: <<REPLACED>>
Ran all test suites matching /unhandledRejectionBeforeAll.test.js/i.",
}
`;
exports[`fails because of unhandled promise rejection in beforeEach hook 1`] = `
Object {
"rest": "FAIL __tests__/unhandledRejectionBeforeEach.test.js
✕ foo #1
✕ foo #2
● foo #1
REJECTED
11 |
12 | beforeEach(async () => {
> 13 | Promise.reject(new Error('REJECTED'));
| ^
14 |
15 | await promisify(setTimeout)(0);
16 | });
at Object.<anonymous> (__tests__/unhandledRejectionBeforeEach.test.js:13:18)
● foo #2
REJECTED
11 |
12 | beforeEach(async () => {
> 13 | Promise.reject(new Error('REJECTED'));
| ^
14 |
15 | await promisify(setTimeout)(0);
16 | });
at Object.<anonymous> (__tests__/unhandledRejectionBeforeEach.test.js:13:18)",
"summary": "Test Suites: 1 failed, 1 total
Tests: 2 failed, 2 total
Snapshots: 0 total
Time: <<REPLACED>>
Ran all test suites matching /unhandledRejectionBeforeEach.test.js/i.",
}
`;
exports[`fails because of unhandled promise rejection in test 1`] = `
Object {
"rest": "FAIL __tests__/unhandledRejectionTest.test.js
✕ w/o event loop turn after rejection
✕ w/ event loop turn after rejection in async function
✕ w/ event loop turn after rejection in sync function
✕ combined w/ another failure _after_ promise rejection
● w/o event loop turn after rejection
REJECTED
11 |
12 | test('w/o event loop turn after rejection', () => {
> 13 | Promise.reject(new Error('REJECTED'));
| ^
14 | });
15 |
16 | test('w/ event loop turn after rejection in async function', async () => {
at Object.<anonymous> (__tests__/unhandledRejectionTest.test.js:13:18)
● w/ event loop turn after rejection in async function
REJECTED
15 |
16 | test('w/ event loop turn after rejection in async function', async () => {
> 17 | Promise.reject(new Error('REJECTED'));
| ^
18 |
19 | await promisify(setTimeout)(0);
20 | });
at Object.<anonymous> (__tests__/unhandledRejectionTest.test.js:17:18)
● w/ event loop turn after rejection in sync function
REJECTED
21 |
22 | test('w/ event loop turn after rejection in sync function', done => {
> 23 | Promise.reject(new Error('REJECTED'));
| ^
24 |
25 | setTimeout(done, 0);
26 | });
at Object.<anonymous> (__tests__/unhandledRejectionTest.test.js:23:18)
● combined w/ another failure _after_ promise rejection
expect(received).toBe(expected) // Object.is equality
Expected: false
Received: true
31 | await promisify(setTimeout)(0);
32 |
> 33 | expect(true).toBe(false);
| ^
34 | });
35 |
at Object.toBe (__tests__/unhandledRejectionTest.test.js:33:16)
● combined w/ another failure _after_ promise rejection
REJECTED
27 |
28 | test('combined w/ another failure _after_ promise rejection', async () => {
> 29 | Promise.reject(new Error('REJECTED'));
| ^
30 |
31 | await promisify(setTimeout)(0);
32 |
at Object.<anonymous> (__tests__/unhandledRejectionTest.test.js:29:18)",
"summary": "Test Suites: 1 failed, 1 total
Tests: 4 failed, 4 total
Snapshots: 0 total
Time: <<REPLACED>>
Ran all test suites matching /unhandledRejectionTest.test.js/i.",
}
`;
exports[`succeeds for async handled promise rejections 1`] = `
Object {
"rest": "PASS __tests__/rejectionHandled.test.js
✓ async function succeeds because the promise is eventually awaited by assertion
✓ async function succeeds because the promise is eventually directly awaited
✓ sync function succeeds because the promise is eventually handled by \`.catch\` handler",
"summary": "Test Suites: 1 passed, 1 total
Tests: 3 passed, 3 total
Snapshots: 0 total
Time: <<REPLACED>>
Ran all test suites matching /rejectionHandled.test.js/i.",
}
`;

View File

@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`prints useful error for requires after test is done 1`] = `
"ReferenceError: You are trying to \`import\` a file after the Jest environment has been torn down. From __tests__/lateRequire.test.js.
" ReferenceError: You are trying to \`import\` a file outside of the scope of the test code.
9 | test('require after done', () => {
10 | setTimeout(() => {

View File

@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`prints useful error for requires after test is done 1`] = `
"ReferenceError: You are trying to \`import\` a file after the Jest environment has been torn down. From __tests__/lateRequire.test.js.
9 | test('require after done', () => {
10 | setTimeout(() => {
> 11 | const double = require('../');
| ^
12 |
13 | expect(double(5)).toBe(10);
14 | }, 0);"
`;

View File

@ -5,14 +5,17 @@
* LICENSE file in the root directory of this source tree.
*/
import {skipSuiteOnJasmine} from '@jest/test-utils';
import runJest from '../runJest';
skipSuiteOnJasmine();
test('prints useful error for environment methods after test is done', () => {
const {stderr} = runJest('environment-after-teardown');
const interestingLines = stderr.split('\n').slice(9, 18).join('\n');
const interestingLines = stderr.split('\n').slice(5, 14).join('\n');
expect(interestingLines).toMatchSnapshot();
expect(stderr.split('\n')[9]).toBe(
'ReferenceError: You are trying to access a property or method of the Jest environment after it has been torn down. From __tests__/afterTeardown.test.js.',
expect(stderr.split('\n')[5]).toMatch(
'ReferenceError: You are trying to access a property or method of the Jest environment outside of the scope of the test code.',
);
});

View File

@ -0,0 +1,21 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {skipSuiteOnJestCircus} from '@jest/test-utils';
import runJest from '../runJest';
skipSuiteOnJestCircus();
test('prints useful error for environment methods after test is done', () => {
const {stderr} = runJest('environment-after-teardown');
const interestingLines = stderr.split('\n').slice(9, 18).join('\n');
expect(interestingLines).toMatchSnapshot();
expect(stderr.split('\n')[9]).toBe(
'ReferenceError: You are trying to access a property or method of the Jest environment after it has been torn down. From __tests__/afterTeardown.test.js.',
);
});

View File

@ -5,6 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/
import {isJestJasmineRun} from '@jest/test-utils';
import runJest from '../runJest';
describe('enableGlobally', () => {
@ -39,10 +40,13 @@ describe('requestAnimationFrame', () => {
describe('setImmediate', () => {
test('fakes setImmediate', () => {
// Jasmine runner does not handle unhandled promise rejections that are causing the test to fail in Jest circus
const expectedExitCode = isJestJasmineRun() ? 0 : 1;
const result = runJest('fake-timers-legacy/set-immediate');
expect(result.stderr).toMatch('setImmediate test');
expect(result.exitCode).toBe(0);
expect(result.exitCode).toBe(expectedExitCode);
});
});

View File

@ -0,0 +1,71 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import * as path from 'path';
import {skipSuiteOnJasmine} from '@jest/test-utils';
import {extractSortedSummary} from '../Utils';
import runJest from '../runJest';
const dir = path.resolve(__dirname, '../promise-async-handling');
skipSuiteOnJasmine();
test('fails because of unhandled promise rejection in test', () => {
const {stderr, exitCode} = runJest(dir, ['unhandledRejectionTest.test.js']);
expect(exitCode).toBe(1);
const sortedSummary = extractSortedSummary(stderr);
expect(sortedSummary).toMatchSnapshot();
});
test('fails because of unhandled promise rejection in beforeAll hook', () => {
const {stderr, exitCode} = runJest(dir, [
'unhandledRejectionBeforeAll.test.js',
]);
expect(exitCode).toBe(1);
const sortedSummary = extractSortedSummary(stderr);
expect(sortedSummary).toMatchSnapshot();
});
test('fails because of unhandled promise rejection in beforeEach hook', () => {
const {stderr, exitCode} = runJest(dir, [
'unhandledRejectionBeforeEach.test.js',
]);
expect(exitCode).toBe(1);
const sortedSummary = extractSortedSummary(stderr);
expect(sortedSummary).toMatchSnapshot();
});
test('fails because of unhandled promise rejection in afterEach hook', () => {
const {stderr, exitCode} = runJest(dir, [
'unhandledRejectionAfterEach.test.js',
]);
expect(exitCode).toBe(1);
const sortedSummary = extractSortedSummary(stderr);
expect(sortedSummary).toMatchSnapshot();
});
test('fails because of unhandled promise rejection in afterAll hook', () => {
const {stderr, exitCode} = runJest(dir, [
'unhandledRejectionAfterAll.test.js',
]);
expect(exitCode).toBe(1);
const sortedSummary = extractSortedSummary(stderr);
expect(sortedSummary).toMatchSnapshot();
});
test('succeeds for async handled promise rejections', () => {
const {stderr, exitCode} = runJest(dir, ['rejectionHandled.test.js']);
expect(exitCode).toBe(0);
const sortedSummary = extractSortedSummary(stderr);
expect(sortedSummary).toMatchSnapshot();
});

View File

@ -5,15 +5,18 @@
* LICENSE file in the root directory of this source tree.
*/
import {skipSuiteOnJasmine} from '@jest/test-utils';
import runJest from '../runJest';
skipSuiteOnJasmine();
test('prints useful error for requires after test is done', () => {
const {stderr} = runJest('require-after-teardown');
const interestingLines = stderr.split('\n').slice(9, 18).join('\n');
const interestingLines = stderr.split('\n').slice(5, 14).join('\n');
expect(interestingLines).toMatchSnapshot();
expect(stderr.split('\n')[19]).toMatch(
new RegExp('(__tests__/lateRequire.test.js:11:20)'),
expect(stderr.split('\n')[16]).toMatch(
'(__tests__/lateRequire.test.js:11:20)',
);
});

View File

@ -0,0 +1,22 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {skipSuiteOnJestCircus} from '@jest/test-utils';
import runJest from '../runJest';
skipSuiteOnJestCircus();
test('prints useful error for requires after test is done', () => {
const {stderr} = runJest('require-after-teardown');
const interestingLines = stderr.split('\n').slice(9, 18).join('\n');
expect(interestingLines).toMatchSnapshot();
expect(stderr.split('\n')[19]).toMatch(
'(__tests__/lateRequire.test.js:11:20)',
);
});

View File

@ -0,0 +1,74 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
'use strict';
const {promisify} = require('util');
beforeAll(async () => {
const promise = Promise.reject(new Error('REJECTED'));
await promisify(setTimeout)(0);
await expect(promise).rejects.toThrow(/REJECTED/);
});
beforeEach(async () => {
const promise = Promise.reject(new Error('REJECTED'));
await promisify(setTimeout)(0);
await expect(promise).rejects.toThrow(/REJECTED/);
});
afterEach(async () => {
const promise = Promise.reject(new Error('REJECTED'));
await promisify(setTimeout)(0);
await expect(promise).rejects.toThrow(/REJECTED/);
});
afterAll(async () => {
const promise = Promise.reject(new Error('REJECTED'));
await promisify(setTimeout)(0);
await expect(promise).rejects.toThrow(/REJECTED/);
});
test('async function succeeds because the promise is eventually awaited by assertion', async () => {
const promise = Promise.reject(new Error('REJECTED'));
await promisify(setTimeout)(0);
await expect(promise).rejects.toThrow(/REJECTED/);
});
test('async function succeeds because the promise is eventually directly awaited', async () => {
const promise = Promise.reject(new Error('REJECTED'));
await promisify(setTimeout)(0);
try {
await promise;
} catch (error) {
expect(error).toEqual(new Error('REJECTED'));
}
});
test('sync function succeeds because the promise is eventually handled by `.catch` handler', done => {
const promise = Promise.reject(new Error('REJECTED'));
setTimeout(() => {
promise
.catch(error => {
expect(error).toEqual(new Error('REJECTED'));
})
.finally(done);
}, 0);
});

View File

@ -0,0 +1,18 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
'use strict';
const {promisify} = require('util');
afterAll(async () => {
Promise.reject(new Error('REJECTED'));
await promisify(setTimeout)(0);
});
test('foo', () => {});

View File

@ -0,0 +1,20 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
'use strict';
const {promisify} = require('util');
afterEach(async () => {
Promise.reject(new Error('REJECTED'));
await promisify(setTimeout)(0);
});
test('foo #1', () => {});
test('foo #2', () => {});

View File

@ -0,0 +1,18 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
'use strict';
const {promisify} = require('util');
beforeAll(async () => {
Promise.reject(new Error('REJECTED'));
await promisify(setTimeout)(0);
});
test('foo', () => {});

View File

@ -0,0 +1,20 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
'use strict';
const {promisify} = require('util');
beforeEach(async () => {
Promise.reject(new Error('REJECTED'));
await promisify(setTimeout)(0);
});
test('foo #1', () => {});
test('foo #2', () => {});

View File

@ -0,0 +1,34 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
'use strict';
const {promisify} = require('util');
test('w/o event loop turn after rejection', () => {
Promise.reject(new Error('REJECTED'));
});
test('w/ event loop turn after rejection in async function', async () => {
Promise.reject(new Error('REJECTED'));
await promisify(setTimeout)(0);
});
test('w/ event loop turn after rejection in sync function', done => {
Promise.reject(new Error('REJECTED'));
setTimeout(done, 0);
});
test('combined w/ another failure _after_ promise rejection', async () => {
Promise.reject(new Error('REJECTED'));
await promisify(setTimeout)(0);
expect(true).toBe(false);
});

View File

@ -0,0 +1,5 @@
{
"jest": {
"testEnvironment": "node"
}
}

View File

@ -272,9 +272,35 @@ 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) {
if (event.promise) {
state.currentlyRunningTest.unhandledRejectionErrorByPromise.set(
event.promise,
event.error,
);
} else {
state.currentlyRunningTest.errors.push(event.error);
}
} else {
if (event.promise) {
state.unhandledRejectionErrorByPromise.set(
event.promise,
event.error,
);
} else {
state.unhandledErrors.push(event.error);
}
}
break;
}
case 'error_handled': {
if (state.currentlyRunningTest) {
state.currentlyRunningTest.unhandledRejectionErrorByPromise.delete(
event.promise,
);
} else {
state.unhandledRejectionErrorByPromise.delete(event.promise);
}
break;
}
}

View File

@ -9,29 +9,50 @@ import type * as Process from 'process';
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: typeof 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: typeof 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);
@ -39,4 +60,7 @@ export const restoreGlobalErrorHandlers = (
for (const listener of originalErrorHandlers.unhandledRejection) {
parentProcess.on('unhandledRejection', listener);
}
for (const listener of originalErrorHandlers.rejectionHandled) {
parentProcess.on('rejectionHandled', listener);
}
};

View File

@ -33,6 +33,7 @@ const jestAdapter = async (
globalConfig,
localRequire: runtime.requireModule.bind(runtime),
parentProcess: process,
runtime,
sendMessageToJest,
setGlobalsForRuntime: runtime.setGlobalsForRuntime.bind(runtime),
testPath,

View File

@ -17,6 +17,7 @@ import {
} from '@jest/test-result';
import type {Circus, Config, Global} from '@jest/types';
import {formatExecError, formatResultsErrors} from 'jest-message-util';
import type Runtime from 'jest-runtime';
import {
SnapshotState,
addSerializer,
@ -31,6 +32,7 @@ import {
getState as getRunnerState,
} from '../state';
import testCaseReportHandler from '../testCaseReportHandler';
import {unhandledRejectionHandler} from '../unhandledRejectionHandler';
import {getTestID} from '../utils';
interface RuntimeGlobals extends Global.TestFrameworkGlobals {
@ -40,6 +42,7 @@ interface RuntimeGlobals extends Global.TestFrameworkGlobals {
export const initialize = async ({
config,
environment,
runtime,
globalConfig,
localRequire,
parentProcess,
@ -49,6 +52,7 @@ export const initialize = async ({
}: {
config: Config.ProjectConfig;
environment: JestEnvironment;
runtime: Runtime;
globalConfig: Config.GlobalConfig;
localRequire: <T = unknown>(path: string) => T;
testPath: string;
@ -128,6 +132,8 @@ export const initialize = async ({
addEventHandler(testCaseReportHandler(testPath, sendMessageToJest));
}
addEventHandler(unhandledRejectionHandler(runtime));
// Return it back to the outer scope (test runner outside the VM).
return {globals: globalsObject, snapshotState};
};

View File

@ -34,6 +34,7 @@ const createState = (): Circus.State => {
testNamePattern: null,
testTimeout: 5000,
unhandledErrors: [],
unhandledRejectionErrorByPromise: new Map(),
};
};

View File

@ -0,0 +1,81 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import type {Circus} from '@jest/types';
import type Runtime from 'jest-runtime';
import {invariant} from 'jest-util';
import {addErrorToEachTestUnderDescribe} from './utils';
// Global values can be overwritten by mocks or tests. We'll capture
// the original values in the variables before we require any files.
const {setTimeout} = globalThis;
const untilNextEventLoopTurn = async () => {
return new Promise(resolve => {
setTimeout(resolve, 0);
});
};
export const unhandledRejectionHandler = (
runtime: Runtime,
): Circus.EventHandler => {
return async (event, state) => {
if (event.name === 'hook_start') {
runtime.enterTestCode();
} else if (event.name === 'hook_success' || event.name === 'hook_failure') {
runtime.leaveTestCode();
// We need to give event loop the time to actually execute `rejectionHandled`, `uncaughtException` or `unhandledRejection` events
await untilNextEventLoopTurn();
const {test, describeBlock, hook} = event;
const {asyncError, type} = hook;
if (type === 'beforeAll') {
invariant(describeBlock, 'always present for `*All` hooks');
for (const error of state.unhandledRejectionErrorByPromise.values()) {
addErrorToEachTestUnderDescribe(describeBlock, error, asyncError);
}
} else if (type === 'afterAll') {
// Attaching `afterAll` errors to each test makes execution flow
// too complicated, so we'll consider them to be global.
for (const error of state.unhandledRejectionErrorByPromise.values()) {
state.unhandledErrors.push([error, asyncError]);
}
} else {
invariant(test, 'always present for `*Each` hooks');
for (const error of test.unhandledRejectionErrorByPromise.values()) {
test.errors.push([error, asyncError]);
}
}
} else if (event.name === 'test_fn_start') {
runtime.enterTestCode();
} else if (
event.name === 'test_fn_success' ||
event.name === 'test_fn_failure'
) {
runtime.leaveTestCode();
// We need to give event loop the time to actually execute `rejectionHandled`, `uncaughtException` or `unhandledRejection` events
await untilNextEventLoopTurn();
const {test} = event;
invariant(test, 'always present for `*Each` hooks');
for (const error of test.unhandledRejectionErrorByPromise.values()) {
test.errors.push([error, event.test.asyncError]);
}
} else if (event.name === 'teardown') {
// We need to give event loop the time to actually execute `rejectionHandled`, `uncaughtException` or `unhandledRejection` events
await untilNextEventLoopTurn();
state.unhandledErrors.push(
...state.unhandledRejectionErrorByPromise.values(),
);
}
};
};

View File

@ -86,6 +86,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

View File

@ -213,6 +213,7 @@ export default class Runtime {
private readonly esmConditions: Array<string>;
private readonly cjsConditions: Array<string>;
private isTornDown = false;
private isInsideTestCode: boolean | undefined;
constructor(
config: Config.ProjectConfig,
@ -570,6 +571,11 @@ export default class Runtime {
// @ts-expect-error - exiting
return;
}
if (this.isInsideTestCode === false) {
throw new ReferenceError(
'You are trying to `import` a file outside of the scope of the test code.',
);
}
const registry = this._isolatedModuleRegistry
? this._isolatedModuleRegistry
@ -718,6 +724,11 @@ export default class Runtime {
process.exitCode = 1;
return;
}
if (this.isInsideTestCode === false) {
throw new ReferenceError(
'You are trying to `import` a file outside of the scope of the test code.',
);
}
if (module.status === 'unlinked') {
// since we might attempt to link the same module in parallel, stick the promise in a weak map so every call to
@ -1355,6 +1366,14 @@ export default class Runtime {
this._moduleMocker.clearAllMocks();
}
enterTestCode(): void {
this.isInsideTestCode = true;
}
leaveTestCode(): void {
this.isInsideTestCode = false;
}
teardown(): void {
this.restoreAllMocks();
this.resetModules();
@ -1498,6 +1517,11 @@ export default class Runtime {
process.exitCode = 1;
return;
}
if (this.isInsideTestCode === false) {
throw new ReferenceError(
'You are trying to `import` a file outside of the scope of the test code.',
);
}
// If the environment was disposed, prevent this module from being executed.
if (!this._environment.global) {
@ -2166,6 +2190,11 @@ export default class Runtime {
);
process.exitCode = 1;
}
if (this.isInsideTestCode === false) {
throw new ReferenceError(
'You are trying to access a property or method of the Jest environment outside of the scope of the test code.',
);
}
return this._fakeTimersImplementation!;
};

View File

@ -82,6 +82,11 @@ export type SyncEvent =
// an `afterAll` hook)
name: 'error';
error: Exception;
promise?: Promise<unknown>;
}
| {
name: 'error_handled';
promise: Promise<unknown>;
};
export type AsyncEvent =
@ -214,6 +219,7 @@ export type RunResult = {
export type TestResults = Array<TestResult>;
export type GlobalErrorHandlers = {
rejectionHandled: Array<(promise: Promise<unknown>) => void>;
uncaughtException: Array<NodeJS.UncaughtExceptionListener>;
unhandledRejection: Array<NodeJS.UnhandledRejectionListener>;
};
@ -237,6 +243,7 @@ export type State = {
unhandledErrors: Array<Exception>;
includeTestLocationInResult: boolean;
maxConcurrency: number;
unhandledRejectionErrorByPromise: Map<Promise<unknown>, Exception>;
};
export type DescribeBlock = {
@ -270,4 +277,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>;
};