diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f6424a496c5..0cb832be6296 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Fixes +- `[jest-circus]` Prevent false test failures caused by promise rejections handled asynchronously ([#14110](https://github.com/jestjs/jest/pull/14110)) - `[jest-config]` Handle frozen config object ([#14054](https://github.com/facebook/jest/pull/14054)) - `[jest-core]` Always use workers in watch mode to avoid crashes ([#14059](https://github.com/facebook/jest/pull/14059)). - `[jest-environment-jsdom, jest-environment-node]` Fix assignment of `customExportConditions` via `testEnvironmentOptions` when custom env subclass defines a default value ([#13989](https://github.com/facebook/jest/pull/13989)) diff --git a/e2e/__tests__/__snapshots__/environmentAfterTeardown.test.ts.snap b/e2e/__tests__/__snapshots__/environmentAfterTeardown.test.ts.snap index e851be178ae8..ced63eb48256 100644 --- a/e2e/__tests__/__snapshots__/environmentAfterTeardown.test.ts.snap +++ b/e2e/__tests__/__snapshots__/environmentAfterTeardown.test.ts.snap @@ -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(() => { diff --git a/e2e/__tests__/__snapshots__/environmentAfterTeardownJasmine.test.ts.snap b/e2e/__tests__/__snapshots__/environmentAfterTeardownJasmine.test.ts.snap new file mode 100644 index 000000000000..e851be178ae8 --- /dev/null +++ b/e2e/__tests__/__snapshots__/environmentAfterTeardownJasmine.test.ts.snap @@ -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 |" +`; diff --git a/e2e/__tests__/__snapshots__/promiseAsyncHandling.test.ts.snap b/e2e/__tests__/__snapshots__/promiseAsyncHandling.test.ts.snap new file mode 100644 index 000000000000..402a65e570c7 --- /dev/null +++ b/e2e/__tests__/__snapshots__/promiseAsyncHandling.test.ts.snap @@ -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. (__tests__/unhandledRejectionAfterAll.test.js:13:18)", + "summary": "Test Suites: 1 failed, 1 total +Tests: 1 passed, 1 total +Snapshots: 0 total +Time: <> +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. (__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. (__tests__/unhandledRejectionAfterEach.test.js:13:18)", + "summary": "Test Suites: 1 failed, 1 total +Tests: 2 failed, 2 total +Snapshots: 0 total +Time: <> +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. (__tests__/unhandledRejectionBeforeAll.test.js:13:18)", + "summary": "Test Suites: 1 failed, 1 total +Tests: 1 failed, 1 total +Snapshots: 0 total +Time: <> +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. (__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. (__tests__/unhandledRejectionBeforeEach.test.js:13:18)", + "summary": "Test Suites: 1 failed, 1 total +Tests: 2 failed, 2 total +Snapshots: 0 total +Time: <> +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. (__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. (__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. (__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. (__tests__/unhandledRejectionTest.test.js:29:18)", + "summary": "Test Suites: 1 failed, 1 total +Tests: 4 failed, 4 total +Snapshots: 0 total +Time: <> +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: <> +Ran all test suites matching /rejectionHandled.test.js/i.", +} +`; diff --git a/e2e/__tests__/__snapshots__/requireAfterTeardown.test.ts.snap b/e2e/__tests__/__snapshots__/requireAfterTeardown.test.ts.snap index 1a3d3fd9b988..afa6ff3749e2 100644 --- a/e2e/__tests__/__snapshots__/requireAfterTeardown.test.ts.snap +++ b/e2e/__tests__/__snapshots__/requireAfterTeardown.test.ts.snap @@ -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(() => { diff --git a/e2e/__tests__/__snapshots__/requireAfterTeardownJasmine.test.ts.snap b/e2e/__tests__/__snapshots__/requireAfterTeardownJasmine.test.ts.snap new file mode 100644 index 000000000000..1a3d3fd9b988 --- /dev/null +++ b/e2e/__tests__/__snapshots__/requireAfterTeardownJasmine.test.ts.snap @@ -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);" +`; diff --git a/e2e/__tests__/environmentAfterTeardown.test.ts b/e2e/__tests__/environmentAfterTeardown.test.ts index 1216014a2af4..7e71888b5ddb 100644 --- a/e2e/__tests__/environmentAfterTeardown.test.ts +++ b/e2e/__tests__/environmentAfterTeardown.test.ts @@ -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.', ); }); diff --git a/e2e/__tests__/environmentAfterTeardownJasmine.test.ts b/e2e/__tests__/environmentAfterTeardownJasmine.test.ts new file mode 100644 index 000000000000..36e769478e0f --- /dev/null +++ b/e2e/__tests__/environmentAfterTeardownJasmine.test.ts @@ -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.', + ); +}); diff --git a/e2e/__tests__/fakeTimersLegacy.test.ts b/e2e/__tests__/fakeTimersLegacy.test.ts index 268ce5aa80c2..3ad3f3a7fb3f 100644 --- a/e2e/__tests__/fakeTimersLegacy.test.ts +++ b/e2e/__tests__/fakeTimersLegacy.test.ts @@ -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); }); }); diff --git a/e2e/__tests__/promiseAsyncHandling.test.ts b/e2e/__tests__/promiseAsyncHandling.test.ts new file mode 100644 index 000000000000..606bae071097 --- /dev/null +++ b/e2e/__tests__/promiseAsyncHandling.test.ts @@ -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(); +}); diff --git a/e2e/__tests__/requireAfterTeardown.test.ts b/e2e/__tests__/requireAfterTeardown.test.ts index cb9607549b85..764a593db864 100644 --- a/e2e/__tests__/requireAfterTeardown.test.ts +++ b/e2e/__tests__/requireAfterTeardown.test.ts @@ -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)', ); }); diff --git a/e2e/__tests__/requireAfterTeardownJasmine.test.ts b/e2e/__tests__/requireAfterTeardownJasmine.test.ts new file mode 100644 index 000000000000..3eeb390ad8ea --- /dev/null +++ b/e2e/__tests__/requireAfterTeardownJasmine.test.ts @@ -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)', + ); +}); diff --git a/e2e/promise-async-handling/__tests__/rejectionHandled.test.js b/e2e/promise-async-handling/__tests__/rejectionHandled.test.js new file mode 100644 index 000000000000..c36e9b8d6f0f --- /dev/null +++ b/e2e/promise-async-handling/__tests__/rejectionHandled.test.js @@ -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); +}); diff --git a/e2e/promise-async-handling/__tests__/unhandledRejectionAfterAll.test.js b/e2e/promise-async-handling/__tests__/unhandledRejectionAfterAll.test.js new file mode 100644 index 000000000000..e6fdf75760ca --- /dev/null +++ b/e2e/promise-async-handling/__tests__/unhandledRejectionAfterAll.test.js @@ -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', () => {}); diff --git a/e2e/promise-async-handling/__tests__/unhandledRejectionAfterEach.test.js b/e2e/promise-async-handling/__tests__/unhandledRejectionAfterEach.test.js new file mode 100644 index 000000000000..6c77b8159630 --- /dev/null +++ b/e2e/promise-async-handling/__tests__/unhandledRejectionAfterEach.test.js @@ -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', () => {}); diff --git a/e2e/promise-async-handling/__tests__/unhandledRejectionBeforeAll.test.js b/e2e/promise-async-handling/__tests__/unhandledRejectionBeforeAll.test.js new file mode 100644 index 000000000000..af4654197346 --- /dev/null +++ b/e2e/promise-async-handling/__tests__/unhandledRejectionBeforeAll.test.js @@ -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', () => {}); diff --git a/e2e/promise-async-handling/__tests__/unhandledRejectionBeforeEach.test.js b/e2e/promise-async-handling/__tests__/unhandledRejectionBeforeEach.test.js new file mode 100644 index 000000000000..28f3186d736c --- /dev/null +++ b/e2e/promise-async-handling/__tests__/unhandledRejectionBeforeEach.test.js @@ -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', () => {}); diff --git a/e2e/promise-async-handling/__tests__/unhandledRejectionTest.test.js b/e2e/promise-async-handling/__tests__/unhandledRejectionTest.test.js new file mode 100644 index 000000000000..f63e5772c2e6 --- /dev/null +++ b/e2e/promise-async-handling/__tests__/unhandledRejectionTest.test.js @@ -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); +}); diff --git a/e2e/promise-async-handling/package.json b/e2e/promise-async-handling/package.json new file mode 100644 index 000000000000..148788b25446 --- /dev/null +++ b/e2e/promise-async-handling/package.json @@ -0,0 +1,5 @@ +{ + "jest": { + "testEnvironment": "node" + } +} diff --git a/packages/jest-circus/src/eventHandler.ts b/packages/jest-circus/src/eventHandler.ts index 0a437df1ed9a..f051bf1c1588 100644 --- a/packages/jest-circus/src/eventHandler.ts +++ b/packages/jest-circus/src/eventHandler.ts @@ -269,9 +269,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; } } diff --git a/packages/jest-circus/src/globalErrorHandlers.ts b/packages/jest-circus/src/globalErrorHandlers.ts index 27146fc4a1f2..b46b17881e96 100644 --- a/packages/jest-circus/src/globalErrorHandlers.ts +++ b/packages/jest-circus/src/globalErrorHandlers.ts @@ -8,29 +8,50 @@ 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, +) => { + dispatchSync({error, name: 'error', promise}); +}; + +const rejectionHandledListener: NodeJS.RejectionHandledListener = ( + promise: Promise, +) => { + 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); @@ -38,4 +59,7 @@ export const restoreGlobalErrorHandlers = ( for (const listener of originalErrorHandlers.unhandledRejection) { parentProcess.on('unhandledRejection', listener); } + for (const listener of originalErrorHandlers.rejectionHandled) { + parentProcess.on('rejectionHandled', listener); + } }; diff --git a/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts b/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts index 80b4503621b7..1aae32296423 100644 --- a/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts +++ b/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts @@ -33,6 +33,7 @@ const jestAdapter = async ( globalConfig, localRequire: runtime.requireModule.bind(runtime), parentProcess: process, + runtime, sendMessageToJest, setGlobalsForRuntime: runtime.setGlobalsForRuntime.bind(runtime), testPath, diff --git a/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapterInit.ts b/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapterInit.ts index f9ac8076bd55..416d6555ddbf 100644 --- a/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapterInit.ts +++ b/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapterInit.ts @@ -16,6 +16,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, @@ -30,6 +31,7 @@ import { getState as getRunnerState, } from '../state'; import testCaseReportHandler from '../testCaseReportHandler'; +import {unhandledRejectionHandler} from '../unhandledRejectionHandler'; import {getTestID} from '../utils'; interface RuntimeGlobals extends Global.TestFrameworkGlobals { @@ -39,6 +41,7 @@ interface RuntimeGlobals extends Global.TestFrameworkGlobals { export const initialize = async ({ config, environment, + runtime, globalConfig, localRequire, parentProcess, @@ -48,6 +51,7 @@ export const initialize = async ({ }: { config: Config.ProjectConfig; environment: JestEnvironment; + runtime: Runtime; globalConfig: Config.GlobalConfig; localRequire: (path: string) => T; testPath: string; @@ -129,6 +133,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}; }; diff --git a/packages/jest-circus/src/state.ts b/packages/jest-circus/src/state.ts index 0540dfd41116..5dbc152f8399 100644 --- a/packages/jest-circus/src/state.ts +++ b/packages/jest-circus/src/state.ts @@ -34,6 +34,7 @@ const createState = (): Circus.State => { testNamePattern: null, testTimeout: 5000, unhandledErrors: [], + unhandledRejectionErrorByPromise: new Map(), }; }; diff --git a/packages/jest-circus/src/unhandledRejectionHandler.ts b/packages/jest-circus/src/unhandledRejectionHandler.ts new file mode 100644 index 000000000000..309f9e0b2e10 --- /dev/null +++ b/packages/jest-circus/src/unhandledRejectionHandler.ts @@ -0,0 +1,80 @@ +/** + * 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 {addErrorToEachTestUnderDescribe, invariant} 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(), + ); + } + }; +}; diff --git a/packages/jest-circus/src/utils.ts b/packages/jest-circus/src/utils.ts index 13cf3b321e05..a098017c9833 100644 --- a/packages/jest-circus/src/utils.ts +++ b/packages/jest-circus/src/utils.ts @@ -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 diff --git a/packages/jest-runtime/src/index.ts b/packages/jest-runtime/src/index.ts index f9945f8e7f72..b2e1735fb488 100644 --- a/packages/jest-runtime/src/index.ts +++ b/packages/jest-runtime/src/index.ts @@ -208,6 +208,7 @@ export default class Runtime { private readonly esmConditions: Array; private readonly cjsConditions: Array; private isTornDown = false; + private isInsideTestCode: boolean | undefined; constructor( config: Config.ProjectConfig, @@ -562,6 +563,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.', + ); + } if (specifier === '@jest/globals') { const fromCache = this._esmoduleRegistry.get('@jest/globals'); @@ -706,6 +712,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 @@ -1342,6 +1353,14 @@ export default class Runtime { this._moduleMocker.clearAllMocks(); } + enterTestCode(): void { + this.isInsideTestCode = true; + } + + leaveTestCode(): void { + this.isInsideTestCode = false; + } + teardown(): void { this.restoreAllMocks(); this.resetModules(); @@ -1485,6 +1504,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) { @@ -2169,6 +2193,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!; }; diff --git a/packages/jest-types/src/Circus.ts b/packages/jest-types/src/Circus.ts index f7d1c0374923..3ea5f01c81bf 100644 --- a/packages/jest-types/src/Circus.ts +++ b/packages/jest-types/src/Circus.ts @@ -81,6 +81,11 @@ export type SyncEvent = // an `afterAll` hook) name: 'error'; error: Exception; + promise?: Promise; + } + | { + name: 'error_handled'; + promise: Promise; }; export type AsyncEvent = @@ -198,6 +203,7 @@ export type RunResult = { export type TestResults = Array; export type GlobalErrorHandlers = { + rejectionHandled: Array<(promise: Promise) => void>; uncaughtException: Array<(exception: Exception) => void>; unhandledRejection: Array< (exception: Exception, promise: Promise) => void @@ -223,6 +229,7 @@ export type State = { unhandledErrors: Array; includeTestLocationInResult: boolean; maxConcurrency: number; + unhandledRejectionErrorByPromise: Map, Exception>; }; export type DescribeBlock = { @@ -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, Exception>; };