diff --git a/CHANGELOG.md b/CHANGELOG.md index 7aaa13347453..79a416b988a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ - `[jest-resolve, jest-runtime]` Add support for async resolver ([#11540](https://github.com/facebook/jest/pull/11540)) - `[jest-runner]` Allow `setupFiles` module to export an async function ([#12042](https://github.com/facebook/jest/pull/12042)) - `[jest-runner]` Allow passing `testEnvironmentOptions` via docblocks ([#12470](https://github.com/facebook/jest/pull/12470)) +- `[jest-runner]` Exposing `CallbackTestRunner`, `EmittingTestRunner` abstract classes to help typing third party runners ([#12646](https://github.com/facebook/jest/pull/12646)) - `[jest-runtime]` [**BREAKING**] `Runtime.createHasteMap` now returns a promise ([#12008](https://github.com/facebook/jest/pull/12008)) - `[jest-runtime]` Calling `jest.resetModules` function will clear FS and transform cache ([#12531](https://github.com/facebook/jest/pull/12531)) - `[@jest/schemas]` New module for JSON schemas for Jest's config ([#12384](https://github.com/facebook/jest/pull/12384)) diff --git a/packages/jest-core/src/TestScheduler.ts b/packages/jest-core/src/TestScheduler.ts index 4e5101cf4041..2a8872edd507 100644 --- a/packages/jest-core/src/TestScheduler.ts +++ b/packages/jest-core/src/TestScheduler.ts @@ -29,7 +29,7 @@ import { import {createScriptTransformer} from '@jest/transform'; import type {Config} from '@jest/types'; import {formatExecError} from 'jest-message-util'; -import type TestRunner from 'jest-runner'; +import type {JestTestRunner, TestRunnerContext} from 'jest-runner'; import type {Context} from 'jest-runtime'; import { buildSnapshotResolver, @@ -40,6 +40,11 @@ import ReporterDispatcher from './ReporterDispatcher'; import type TestWatcher from './TestWatcher'; import {shouldRunInBand} from './testSchedulerHelper'; +type TestRunnerConstructor = new ( + globalConfig: Config.GlobalConfig, + context: TestRunnerContext, +) => JestTestRunner; + export type TestSchedulerOptions = { startRun: (globalConfig: Config.GlobalConfig) => void; }; @@ -206,14 +211,14 @@ class TestScheduler { showStatus: !runInBand, }); - const testRunners: {[key: string]: TestRunner} = Object.create(null); - const contextsByTestRunner = new WeakMap(); + const testRunners: Record = Object.create(null); + const contextsByTestRunner = new WeakMap(); await Promise.all( Array.from(contexts).map(async context => { const {config} = context; if (!testRunners[config.runner]) { const transformer = await createScriptTransformer(config); - const Runner: typeof TestRunner = + const Runner: TestRunnerConstructor = await transformer.requireAndTranspileModule(config.runner); const runner = new Runner(this._globalConfig, { changedFiles: this._context.changedFiles, @@ -262,14 +267,7 @@ class TestScheduler { ), ]; - await testRunner.runTests( - tests, - watcher, - undefined, - undefined, - undefined, - testRunnerOptions, - ); + await testRunner.runTests(tests, watcher, testRunnerOptions); unsubscribes.forEach(sub => sub()); } else { @@ -310,7 +308,7 @@ class TestScheduler { } private _partitionTests( - testRunners: Record, + testRunners: Record, tests: Array, ): Record> | null { if (Object.keys(testRunners).length > 1) { diff --git a/packages/jest-runner/__typetests__/jest-runner.test.ts b/packages/jest-runner/__typetests__/jest-runner.test.ts new file mode 100644 index 000000000000..95ec032693b5 --- /dev/null +++ b/packages/jest-runner/__typetests__/jest-runner.test.ts @@ -0,0 +1,73 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {expectType} from 'tsd-lite'; +import type {Test, TestEvents} from '@jest/test-result'; +import type {Config} from '@jest/types'; +import {CallbackTestRunner, EmittingTestRunner} from 'jest-runner'; +import type { + OnTestFailure, + OnTestStart, + OnTestSuccess, + TestRunnerContext, + TestRunnerOptions, + TestWatcher, + UnsubscribeFn, +} from 'jest-runner'; + +const globalConfig = {} as Config.GlobalConfig; +const runnerContext = {} as TestRunnerContext; + +// CallbackRunner + +class CallbackRunner extends CallbackTestRunner { + async runTests( + tests: Array, + watcher: TestWatcher, + onStart: OnTestStart, + onResult: OnTestSuccess, + onFailure: OnTestFailure, + options: TestRunnerOptions, + ): Promise { + expectType(this._globalConfig); + expectType(this._context); + + return; + } +} + +const callbackRunner = new CallbackRunner(globalConfig, runnerContext); + +expectType(callbackRunner.isSerial); +expectType(callbackRunner.supportsEventEmitters); + +// EmittingRunner + +class EmittingRunner extends EmittingTestRunner { + async runTests( + tests: Array, + watcher: TestWatcher, + options: TestRunnerOptions, + ): Promise { + expectType(this._globalConfig); + expectType(this._context); + + return; + } + + on( + eventName: string, + listener: (eventData: TestEvents[Name]) => void | Promise, + ): UnsubscribeFn { + return () => {}; + } +} + +const emittingRunner = new EmittingRunner(globalConfig, runnerContext); + +expectType(emittingRunner.isSerial); +expectType(emittingRunner.supportsEventEmitters); diff --git a/packages/jest-runner/__typetests__/tsconfig.json b/packages/jest-runner/__typetests__/tsconfig.json new file mode 100644 index 000000000000..fe8eab794254 --- /dev/null +++ b/packages/jest-runner/__typetests__/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "noUnusedLocals": false, + "noUnusedParameters": false, + "skipLibCheck": true, + + "types": [] + }, + "include": ["./**/*"] +} diff --git a/packages/jest-runner/package.json b/packages/jest-runner/package.json index 6a9d5137755d..7a5756434c36 100644 --- a/packages/jest-runner/package.json +++ b/packages/jest-runner/package.json @@ -39,10 +39,12 @@ "throat": "^6.0.1" }, "devDependencies": { + "@tsd/typescript": "~4.6.2", "@types/exit": "^0.1.30", "@types/graceful-fs": "^4.1.2", "@types/source-map-support": "^0.5.0", - "jest-jasmine2": "^28.0.0-alpha.8" + "jest-jasmine2": "^28.0.0-alpha.8", + "tsd-lite": "^0.5.1" }, "engines": { "node": "^12.13.0 || ^14.15.0 || ^16.13.0 || >=17.0.0" diff --git a/packages/jest-runner/src/__tests__/testRunner.test.ts b/packages/jest-runner/src/__tests__/testRunner.test.ts index b86345a44f71..b71f179afa3d 100644 --- a/packages/jest-runner/src/__tests__/testRunner.test.ts +++ b/packages/jest-runner/src/__tests__/testRunner.test.ts @@ -7,6 +7,7 @@ */ import {TestWatcher} from '@jest/core'; +import type {TestContext} from '@jest/test-result'; import {makeGlobalConfig, makeProjectConfig} from '@jest/test-utils'; import TestRunner from '../index'; @@ -29,56 +30,46 @@ jest.mock('../testWorker', () => {}); test('injects the serializable module map into each worker in watch mode', async () => { const globalConfig = makeGlobalConfig({maxWorkers: 2, watch: true}); const config = makeProjectConfig({rootDir: '/path/'}); - const serializableModuleMap = jest.fn(); const runContext = {}; - const context = { + const mockTestContext = { config, - moduleMap: {toJSON: () => serializableModuleMap}, - }; + moduleMap: {toJSON: jest.fn()}, + } as unknown as TestContext; - await new TestRunner(globalConfig, {}).runTests( + await new TestRunner(globalConfig, runContext).runTests( [ - {context, path: './file.test.js'}, - {context, path: './file2.test.js'}, + {context: mockTestContext, path: './file.test.js'}, + {context: mockTestContext, path: './file2.test.js'}, ], new TestWatcher({isWatchMode: globalConfig.watch}), - undefined, - undefined, - undefined, {serial: false}, ); - expect(mockWorkerFarm.worker.mock.calls).toEqual([ - [ - { - config, - context: runContext, - globalConfig, - path: './file.test.js', - }, - ], - [ - { - config, - context: runContext, - globalConfig, - path: './file2.test.js', - }, - ], - ]); + expect(mockWorkerFarm.worker).toBeCalledTimes(2); + + expect(mockWorkerFarm.worker).nthCalledWith(1, { + config, + context: runContext, + globalConfig, + path: './file.test.js', + }); + + expect(mockWorkerFarm.worker).nthCalledWith(2, { + config, + context: runContext, + globalConfig, + path: './file2.test.js', + }); }); test('assign process.env.JEST_WORKER_ID = 1 when in runInBand mode', async () => { const globalConfig = makeGlobalConfig({maxWorkers: 1, watch: false}); const config = makeProjectConfig({rootDir: '/path/'}); - const context = {config}; + const context = {config} as TestContext; await new TestRunner(globalConfig, {}).runTests( [{context, path: './file.test.js'}], new TestWatcher({isWatchMode: globalConfig.watch}), - undefined, - undefined, - undefined, {serial: true}, ); diff --git a/packages/jest-runner/src/index.ts b/packages/jest-runner/src/index.ts index a753d16a8b91..c5b7dcfc0584 100644 --- a/packages/jest-runner/src/index.ts +++ b/packages/jest-runner/src/index.ts @@ -14,18 +14,15 @@ import type { TestFileEvent, TestResult, } from '@jest/test-result'; -import type {Config} from '@jest/types'; import {deepCyclicCopy} from 'jest-util'; import {PromiseWithCustomMessage, Worker} from 'jest-worker'; import runTest from './runTest'; import type {SerializableResolver, worker} from './testWorker'; -import type { - OnTestFailure, - OnTestStart, - OnTestSuccess, - TestRunnerContext, +import { + EmittingTestRunner, TestRunnerOptions, TestWatcher, + UnsubscribeFn, } from './types'; const TEST_WORKER_PATH = require.resolve('./testWorker'); @@ -34,6 +31,7 @@ interface WorkerInterface extends Worker { worker: typeof worker; } +export {CallbackTestRunner, EmittingTestRunner} from './types'; export type { OnTestFailure, OnTestStart, @@ -41,37 +39,24 @@ export type { TestWatcher, TestRunnerContext, TestRunnerOptions, + JestTestRunner, + UnsubscribeFn, } from './types'; -export default class TestRunner { - private readonly _globalConfig: Config.GlobalConfig; - private readonly _context: TestRunnerContext; - private readonly eventEmitter = new Emittery(); - readonly supportsEventEmitters: boolean = true; - - readonly isSerial?: boolean; - - constructor(globalConfig: Config.GlobalConfig, context: TestRunnerContext) { - this._globalConfig = globalConfig; - this._context = context; - } +export default class TestRunner extends EmittingTestRunner { + readonly #eventEmitter = new Emittery(); async runTests( tests: Array, watcher: TestWatcher, - // keep these three as they're still passed and should be in the types, - // even if this particular runner doesn't use them - _onStart: OnTestStart | undefined, - _onResult: OnTestSuccess | undefined, - _onFailure: OnTestFailure | undefined, options: TestRunnerOptions, ): Promise { return await (options.serial - ? this._createInBandTestRun(tests, watcher) - : this._createParallelTestRun(tests, watcher)); + ? this.#createInBandTestRun(tests, watcher) + : this.#createParallelTestRun(tests, watcher)); } - private async _createInBandTestRun(tests: Array, watcher: TestWatcher) { + async #createInBandTestRun(tests: Array, watcher: TestWatcher) { process.env.JEST_WORKER_ID = '1'; const mutex = throat(1); return tests.reduce( @@ -85,12 +70,12 @@ export default class TestRunner { // `deepCyclicCopy` used here to avoid mem-leak const sendMessageToJest: TestFileEvent = (eventName, args) => - this.eventEmitter.emit( + this.#eventEmitter.emit( eventName, deepCyclicCopy(args, {keepPrototype: false}), ); - await this.eventEmitter.emit('test-file-start', [test]); + await this.#eventEmitter.emit('test-file-start', [test]); return runTest( test.path, @@ -103,18 +88,16 @@ export default class TestRunner { }) .then( result => - this.eventEmitter.emit('test-file-success', [test, result]), - err => this.eventEmitter.emit('test-file-failure', [test, err]), + this.#eventEmitter.emit('test-file-success', [test, result]), + error => + this.#eventEmitter.emit('test-file-failure', [test, error]), ), ), Promise.resolve(), ); } - private async _createParallelTestRun( - tests: Array, - watcher: TestWatcher, - ) { + async #createParallelTestRun(tests: Array, watcher: TestWatcher) { const resolvers: Map = new Map(); for (const test of tests) { if (!resolvers.has(test.context.config.name)) { @@ -146,7 +129,7 @@ export default class TestRunner { return Promise.reject(); } - await this.eventEmitter.emit('test-file-start', [test]); + await this.#eventEmitter.emit('test-file-start', [test]); const promise = worker.worker({ config: test.context.config, @@ -166,7 +149,7 @@ export default class TestRunner { if (promise.UNSTABLE_onCustomMessage) { // TODO: Get appropriate type for `onCustomMessage` promise.UNSTABLE_onCustomMessage(([event, payload]: any) => - this.eventEmitter.emit(event, payload), + this.#eventEmitter.emit(event, payload), ); } @@ -184,8 +167,9 @@ export default class TestRunner { const runAllTests = Promise.all( tests.map(test => runTestInWorker(test).then( - result => this.eventEmitter.emit('test-file-success', [test, result]), - error => this.eventEmitter.emit('test-file-failure', [test, error]), + result => + this.#eventEmitter.emit('test-file-success', [test, result]), + error => this.#eventEmitter.emit('test-file-failure', [test, error]), ), ), ); @@ -210,8 +194,8 @@ export default class TestRunner { on( eventName: Name, listener: (eventData: TestEvents[Name]) => void | Promise, - ): Emittery.UnsubscribeFn { - return this.eventEmitter.on(eventName, listener); + ): UnsubscribeFn { + return this.#eventEmitter.on(eventName, listener); } } diff --git a/packages/jest-runner/src/types.ts b/packages/jest-runner/src/types.ts index 4bb5f2c58879..71da90742fab 100644 --- a/packages/jest-runner/src/types.ts +++ b/packages/jest-runner/src/types.ts @@ -10,6 +10,7 @@ import type {JestEnvironment} from '@jest/environment'; import type { SerializableError, Test, + TestEvents, TestFileEvent, TestResult, } from '@jest/test-result'; @@ -19,10 +20,12 @@ import type RuntimeType from 'jest-runtime'; export type ErrorWithCode = Error & {code?: string}; export type OnTestStart = (test: Test) => Promise; + export type OnTestFailure = ( test: Test, serializableError: SerializableError, ) => Promise; + export type OnTestSuccess = ( test: Test, testResult: TestResult, @@ -52,6 +55,48 @@ export type TestRunnerSerializedContext = { sourcesRelatedToTestsInChangedFiles?: Array; }; +export type UnsubscribeFn = () => void; + +abstract class BaseTestRunner { + readonly isSerial?: boolean; + abstract readonly supportsEventEmitters: boolean; + + constructor( + protected readonly _globalConfig: Config.GlobalConfig, + protected readonly _context: TestRunnerContext, + ) {} +} + +export abstract class CallbackTestRunner extends BaseTestRunner { + readonly supportsEventEmitters = false; + + abstract runTests( + tests: Array, + watcher: TestWatcher, + onStart: OnTestStart, + onResult: OnTestSuccess, + onFailure: OnTestFailure, + options: TestRunnerOptions, + ): Promise; +} + +export abstract class EmittingTestRunner extends BaseTestRunner { + readonly supportsEventEmitters = true; + + abstract runTests( + tests: Array, + watcher: TestWatcher, + options: TestRunnerOptions, + ): Promise; + + abstract on( + eventName: Name, + listener: (eventData: TestEvents[Name]) => void | Promise, + ): UnsubscribeFn; +} + +export type JestTestRunner = CallbackTestRunner | EmittingTestRunner; + // TODO: Should live in `@jest/core` or `jest-watcher` type WatcherState = {interrupted: boolean}; export interface TestWatcher extends Emittery<{change: WatcherState}> { diff --git a/packages/jest-test-result/src/index.ts b/packages/jest-test-result/src/index.ts index 26edbc397fa6..0ffa340c2c03 100644 --- a/packages/jest-test-result/src/index.ts +++ b/packages/jest-test-result/src/index.ts @@ -25,6 +25,7 @@ export type { Status, Suite, Test, + TestContext, TestEvents, TestFileEvent, TestResult, diff --git a/packages/jest-test-result/src/types.ts b/packages/jest-test-result/src/types.ts index 40abef5f541a..2494a47f7c65 100644 --- a/packages/jest-test-result/src/types.ts +++ b/packages/jest-test-result/src/types.ts @@ -183,12 +183,12 @@ export type SnapshotSummary = { }; export type Test = { - context: Context; + context: TestContext; duration?: number; path: string; }; -type Context = { +export type TestContext = { config: Config.ProjectConfig; hasteFS: HasteFS; moduleMap: ModuleMap; diff --git a/yarn.lock b/yarn.lock index 7e1c7a58f9a2..238c2a73760f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13542,6 +13542,7 @@ __metadata: "@jest/test-result": ^28.0.0-alpha.8 "@jest/transform": ^28.0.0-alpha.8 "@jest/types": ^28.0.0-alpha.8 + "@tsd/typescript": ~4.6.2 "@types/exit": ^0.1.30 "@types/graceful-fs": ^4.1.2 "@types/node": "*" @@ -13561,6 +13562,7 @@ __metadata: jest-worker: ^28.0.0-alpha.8 source-map-support: ^0.5.6 throat: ^6.0.1 + tsd-lite: ^0.5.1 languageName: unknown linkType: soft