From a5a27f749c5e05e202c28a48d80fa474c822e857 Mon Sep 17 00:00:00 2001 From: Tom Mrazauskas Date: Wed, 27 Apr 2022 09:33:48 +0300 Subject: [PATCH] feat(jest-worker): add `JestWorkerFarm` helper type (#12753) --- CHANGELOG.md | 2 + packages/jest-haste-map/src/index.ts | 30 +++---- .../jest-reporters/src/CoverageReporter.ts | 12 ++- packages/jest-reporters/src/types.ts | 3 - packages/jest-runner/src/index.ts | 16 ++-- .../__typetests__/jest-worker.test.ts | 80 +++++++++++++++++++ .../jest-worker/__typetests__/testWorker.ts | 26 ++++++ .../jest-worker/__typetests__/tsconfig.json | 12 +++ packages/jest-worker/package.json | 2 + packages/jest-worker/src/Farm.ts | 6 +- packages/jest-worker/src/index.ts | 25 ++++-- packages/jest-worker/src/types.ts | 24 +++++- .../jest-worker/src/workers/processChild.ts | 7 +- .../jest-worker/src/workers/threadChild.ts | 7 +- yarn.lock | 2 + 15 files changed, 202 insertions(+), 52 deletions(-) create mode 100644 packages/jest-worker/__typetests__/jest-worker.test.ts create mode 100644 packages/jest-worker/__typetests__/testWorker.ts create mode 100644 packages/jest-worker/__typetests__/tsconfig.json diff --git a/CHANGELOG.md b/CHANGELOG.md index fe7e23570d53..3e21f0ca50d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ### Features +- `[jest-worker]` Add `JestWorkerFarm` helper type ([#12753](https://github.com/facebook/jest/pull/12753)) + ### Fixes - `[*]` Lower Node 16 requirement to 16.10 from 16.13 due to a [Node bug](https://github.com/nodejs/node/issues/40014) that causes memory and performance issues ([#12754](https://github.com/facebook/jest/pull/12754)) diff --git a/packages/jest-haste-map/src/index.ts b/packages/jest-haste-map/src/index.ts index f3762703050c..82f6549cbd1c 100644 --- a/packages/jest-haste-map/src/index.ts +++ b/packages/jest-haste-map/src/index.ts @@ -16,7 +16,7 @@ import {Stats, readFileSync, writeFileSync} from 'graceful-fs'; import type {Config} from '@jest/types'; import {escapePathForRegex} from 'jest-regex-util'; import {requireOrImportModule} from 'jest-util'; -import {Worker} from 'jest-worker'; +import {JestWorkerFarm, Worker} from 'jest-worker'; import HasteFS from './HasteFS'; import HasteModuleMap from './ModuleMap'; import H from './constants'; @@ -108,13 +108,16 @@ type Watcher = { close(): Promise; }; -type WorkerInterface = {worker: typeof worker; getSha1: typeof getSha1}; +type HasteWorker = typeof import('./worker'); -export {default as ModuleMap} from './ModuleMap'; -export type {SerializableModuleMap} from './types'; -export type {IModuleMap} from './types'; export type {default as FS} from './HasteFS'; -export type {ChangeEvent, HasteMap as HasteMapObject} from './types'; +export {default as ModuleMap} from './ModuleMap'; +export type { + ChangeEvent, + HasteMap as HasteMapObject, + IModuleMap, + SerializableModuleMap, +} from './types'; const CHANGE_INTERVAL = 30; const MAX_WAIT_TIME = 240000; @@ -216,7 +219,7 @@ export default class HasteMap extends EventEmitter { private _isWatchmanInstalledPromise: Promise | null = null; private _options: InternalOptions; private _watchers: Array; - private _worker: WorkerInterface | null; + private _worker: JestWorkerFarm | HasteWorker | null; static getStatic(config: Config.ProjectConfig): HasteMapStatic { if (config.haste.hasteMapModulePath) { @@ -690,7 +693,7 @@ export default class HasteMap extends EventEmitter { this._recoverDuplicates(hasteMap, relativeFilePath, fileMetadata[H.ID]); } - const promises = []; + const promises: Array> = []; for (const relativeFilePath of filesToProcess.keys()) { if ( this._options.skipPackageJson && @@ -726,9 +729,7 @@ export default class HasteMap extends EventEmitter { private _cleanup() { const worker = this._worker; - // @ts-expect-error - if (worker && typeof worker.end === 'function') { - // @ts-expect-error + if (worker && 'end' in worker && typeof worker.end === 'function') { worker.end(); } @@ -745,19 +746,20 @@ export default class HasteMap extends EventEmitter { /** * Creates workers or parses files and extracts metadata in-process. */ - private _getWorker(options?: {forceInBand: boolean}): WorkerInterface { + private _getWorker(options?: { + forceInBand: boolean; + }): JestWorkerFarm | HasteWorker { if (!this._worker) { if ((options && options.forceInBand) || this._options.maxWorkers <= 1) { this._worker = {getSha1, worker}; } else { - // @ts-expect-error: assignment of a worker with custom properties. this._worker = new Worker(require.resolve('./worker'), { exposedMethods: ['getSha1', 'worker'], // @ts-expect-error: option does not exist on the node 12 types forkOptions: {serialization: 'json'}, maxRetries: 3, numWorkers: this._options.maxWorkers, - }) as WorkerInterface; + }) as JestWorkerFarm; } } diff --git a/packages/jest-reporters/src/CoverageReporter.ts b/packages/jest-reporters/src/CoverageReporter.ts index 203435f08f23..5e0c74af22fe 100644 --- a/packages/jest-reporters/src/CoverageReporter.ts +++ b/packages/jest-reporters/src/CoverageReporter.ts @@ -26,10 +26,12 @@ import type { } from '@jest/test-result'; import type {Config} from '@jest/types'; import {clearLine, isInteractive} from 'jest-util'; -import {Worker} from 'jest-worker'; +import {JestWorkerFarm, Worker} from 'jest-worker'; import BaseReporter from './BaseReporter'; import getWatermarks from './getWatermarks'; -import type {CoverageWorker, ReporterContext} from './types'; +import type {ReporterContext} from './types'; + +type CoverageWorker = typeof import('./CoverageWorker'); const FAIL_COLOR = chalk.bold.red; const RUNNING_TEST_COLOR = chalk.bold.dim; @@ -137,7 +139,9 @@ export default class CoverageReporter extends BaseReporter { ); } - let worker: CoverageWorker | Worker; + let worker: + | JestWorkerFarm + | typeof import('./CoverageWorker'); if (this._globalConfig.maxWorkers <= 1) { worker = require('./CoverageWorker'); @@ -148,7 +152,7 @@ export default class CoverageReporter extends BaseReporter { forkOptions: {serialization: 'json'}, maxRetries: 2, numWorkers: this._globalConfig.maxWorkers, - }); + }) as JestWorkerFarm; } const instrumentation = files.map(async fileObj => { diff --git a/packages/jest-reporters/src/types.ts b/packages/jest-reporters/src/types.ts index 0eb323ff334d..16567d30fc33 100644 --- a/packages/jest-reporters/src/types.ts +++ b/packages/jest-reporters/src/types.ts @@ -13,15 +13,12 @@ import type { TestResult, } from '@jest/test-result'; import type {Config} from '@jest/types'; -import type {worker} from './CoverageWorker'; export type ReporterOnStartOptions = { estimatedTime: number; showStatus: boolean; }; -export type CoverageWorker = {worker: typeof worker}; - export interface Reporter { readonly onTestResult?: ( test: Test, diff --git a/packages/jest-runner/src/index.ts b/packages/jest-runner/src/index.ts index 4f1bb6bf074e..15273df93dbe 100644 --- a/packages/jest-runner/src/index.ts +++ b/packages/jest-runner/src/index.ts @@ -16,17 +16,11 @@ import type { } from '@jest/test-result'; import {deepCyclicCopy} from 'jest-util'; import type {TestWatcher} from 'jest-watcher'; -import {PromiseWithCustomMessage, Worker} from 'jest-worker'; +import {JestWorkerFarm, PromiseWithCustomMessage, Worker} from 'jest-worker'; import runTest from './runTest'; -import type {SerializableResolver, worker} from './testWorker'; +import type {SerializableResolver} from './testWorker'; import {EmittingTestRunner, TestRunnerOptions, UnsubscribeFn} from './types'; -const TEST_WORKER_PATH = require.resolve('./testWorker'); - -interface WorkerInterface extends Worker { - worker: typeof worker; -} - export type {Test, TestEvents} from '@jest/test-result'; export type {Config} from '@jest/types'; export type {TestWatcher} from 'jest-watcher'; @@ -43,6 +37,8 @@ export type { UnsubscribeFn, } from './types'; +type TestWorker = typeof import('./testWorker'); + export default class TestRunner extends EmittingTestRunner { readonly #eventEmitter = new Emittery(); @@ -108,14 +104,14 @@ export default class TestRunner extends EmittingTestRunner { } } - const worker = new Worker(TEST_WORKER_PATH, { + const worker = new Worker(require.resolve('./testWorker'), { exposedMethods: ['worker'], // @ts-expect-error: option does not exist on the node 12 types forkOptions: {serialization: 'json', stdio: 'pipe'}, maxRetries: 3, numWorkers: this._globalConfig.maxWorkers, setupArgs: [{serializableResolvers: Array.from(resolvers.values())}], - }) as WorkerInterface; + }) as JestWorkerFarm; if (worker.getStdout()) worker.getStdout().pipe(process.stdout); if (worker.getStderr()) worker.getStderr().pipe(process.stderr); diff --git a/packages/jest-worker/__typetests__/jest-worker.test.ts b/packages/jest-worker/__typetests__/jest-worker.test.ts new file mode 100644 index 000000000000..95dbf7086b26 --- /dev/null +++ b/packages/jest-worker/__typetests__/jest-worker.test.ts @@ -0,0 +1,80 @@ +/** + * 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 {expectError, expectType} from 'tsd-lite'; +import type {JestWorkerFarm} from 'jest-worker'; +import type * as testWorker from './testWorker'; + +type TestWorker = { + runTest: () => void; + isResult: boolean; + end: () => void; // reserved keys should be excluded from returned type + getStderr: () => string; + getStdout: () => string; + setup: () => void; + teardown: () => void; +}; + +// unknown JestWorkerFarm + +declare const unknownWorkerFarm: JestWorkerFarm>; + +expectError(unknownWorkerFarm.runTest()); +expectError(unknownWorkerFarm.runTestAsync()); + +expectError(unknownWorkerFarm.getResult()); +expectError(unknownWorkerFarm.isResult); + +expectError(unknownWorkerFarm.setup()); +expectError(unknownWorkerFarm.teardown()); + +expectType>(unknownWorkerFarm.end()); +expectType(unknownWorkerFarm.getStderr()); +expectType(unknownWorkerFarm.getStdout()); + +// detected JestWorkerFarm + +declare const detectedWorkerFarm: JestWorkerFarm; + +expectType>(detectedWorkerFarm.runTest()); +expectType>(detectedWorkerFarm.runTestAsync()); + +expectError(detectedWorkerFarm.getResult()); +expectError(detectedWorkerFarm.isResult); + +expectError(detectedWorkerFarm.setup()); +expectError(detectedWorkerFarm.teardown()); + +expectError>(detectedWorkerFarm.end()); +expectType>(detectedWorkerFarm.end()); + +expectError>(detectedWorkerFarm.getStderr()); +expectType(detectedWorkerFarm.getStderr()); + +expectError>(detectedWorkerFarm.getStdout()); +expectType(detectedWorkerFarm.getStdout()); + +// typed JestWorkerFarm + +declare const typedWorkerFarm: JestWorkerFarm; + +expectType>(typedWorkerFarm.runTest()); + +expectError(typedWorkerFarm.isResult); +expectError(typedWorkerFarm.runTestAsync()); + +expectError(typedWorkerFarm.setup()); +expectError(typedWorkerFarm.teardown()); + +expectError>(typedWorkerFarm.end()); +expectType>(typedWorkerFarm.end()); + +expectError>(typedWorkerFarm.getStderr()); +expectType(typedWorkerFarm.getStderr()); + +expectError>(typedWorkerFarm.getStdout()); +expectType(typedWorkerFarm.getStdout()); diff --git a/packages/jest-worker/__typetests__/testWorker.ts b/packages/jest-worker/__typetests__/testWorker.ts new file mode 100644 index 000000000000..5d0b527f9207 --- /dev/null +++ b/packages/jest-worker/__typetests__/testWorker.ts @@ -0,0 +1,26 @@ +/** + * 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. + */ + +export function runTest(): void {} +export async function runTestAsync(): Promise {} + +function getResult(): string { + return 'result'; +} +export const isResult = true; + +// reserved keys should be excluded from returned type + +export function end(): void {} +export function getStderr(): string { + return 'get-err'; +} +export function getStdout(): string { + return 'get-out'; +} +export function setup(): void {} +export function teardown(): void {} diff --git a/packages/jest-worker/__typetests__/tsconfig.json b/packages/jest-worker/__typetests__/tsconfig.json new file mode 100644 index 000000000000..d1974ed987b7 --- /dev/null +++ b/packages/jest-worker/__typetests__/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "composite": false, + "noUnusedLocals": false, + "noUnusedParameters": false, + "skipLibCheck": true, + + "types": ["node"] + }, + "include": ["./**/*"] +} diff --git a/packages/jest-worker/package.json b/packages/jest-worker/package.json index b9706cb3ce7a..7890d203854f 100644 --- a/packages/jest-worker/package.json +++ b/packages/jest-worker/package.json @@ -22,10 +22,12 @@ "supports-color": "^8.0.0" }, "devDependencies": { + "@tsd/typescript": "~4.6.2", "@types/merge-stream": "^1.1.2", "@types/supports-color": "^8.1.0", "get-stream": "^6.0.0", "jest-leak-detector": "^28.0.1", + "tsd-lite": "^0.5.1", "worker-farm": "^1.6.0" }, "engines": { diff --git a/packages/jest-worker/src/Farm.ts b/packages/jest-worker/src/Farm.ts index e3e1a84870fd..a490443e6035 100644 --- a/packages/jest-worker/src/Farm.ts +++ b/packages/jest-worker/src/Farm.ts @@ -9,7 +9,6 @@ import FifoQueue from './FifoQueue'; import { CHILD_MESSAGE_CALL, ChildMessage, - FarmOptions, OnCustomMessage, OnEnd, OnStart, @@ -17,12 +16,13 @@ import { QueueChildMessage, TaskQueue, WorkerCallback, + WorkerFarmOptions, WorkerInterface, WorkerSchedulingPolicy, } from './types'; export default class Farm { - private readonly _computeWorkerKey: FarmOptions['computeWorkerKey']; + private readonly _computeWorkerKey: WorkerFarmOptions['computeWorkerKey']; private readonly _workerSchedulingPolicy: WorkerSchedulingPolicy; private readonly _cacheKeys: Record = Object.create(null); @@ -33,7 +33,7 @@ export default class Farm { constructor( private _numOfWorkers: number, private _callback: WorkerCallback, - options: FarmOptions = {}, + options: WorkerFarmOptions = {}, ) { this._computeWorkerKey = options.computeWorkerKey; this._workerSchedulingPolicy = diff --git a/packages/jest-worker/src/index.ts b/packages/jest-worker/src/index.ts index 7d2e25af8447..c540c6a5fabe 100644 --- a/packages/jest-worker/src/index.ts +++ b/packages/jest-worker/src/index.ts @@ -10,10 +10,9 @@ import {isAbsolute} from 'path'; import Farm from './Farm'; import WorkerPool from './WorkerPool'; import type { - FarmOptions, PoolExitResult, - PromiseWithCustomMessage, - TaskQueue, + WorkerFarmOptions, + WorkerModule, WorkerPoolInterface, WorkerPoolOptions, } from './types'; @@ -21,11 +20,21 @@ import type { export {default as PriorityQueue} from './PriorityQueue'; export {default as FifoQueue} from './FifoQueue'; export {default as messageParent} from './workers/messageParent'; -export type {PromiseWithCustomMessage, TaskQueue}; + +export type { + PromiseWithCustomMessage, + TaskQueue, + WorkerFarmOptions, + WorkerPoolInterface, + WorkerPoolOptions, +} from './types'; + +export type JestWorkerFarm> = Worker & + WorkerModule; function getExposedMethods( workerPath: string, - options: FarmOptions, + options: WorkerFarmOptions, ): ReadonlyArray { let exposedMethods = options.exposedMethods; @@ -73,10 +82,10 @@ function getExposedMethods( export class Worker { private _ending: boolean; private _farm: Farm; - private _options: FarmOptions; + private _options: WorkerFarmOptions; private _workerPool: WorkerPoolInterface; - constructor(workerPath: string, options?: FarmOptions) { + constructor(workerPath: string, options?: WorkerFarmOptions) { this._options = {...options}; this._ending = false; @@ -117,7 +126,7 @@ export class Worker { private _bindExposedWorkerMethods( workerPath: string, - options: FarmOptions, + options: WorkerFarmOptions, ): void { getExposedMethods(workerPath, options).forEach(name => { if (name.startsWith('_')) { diff --git a/packages/jest-worker/src/types.ts b/packages/jest-worker/src/types.ts index 208db8c75ae7..7b93f82ca3a0 100644 --- a/packages/jest-worker/src/types.ts +++ b/packages/jest-worker/src/types.ts @@ -9,9 +9,25 @@ import type {ForkOptions} from 'child_process'; import type {EventEmitter} from 'events'; import type {ResourceLimits} from 'worker_threads'; -export type FunctionLike = ( - ...args: Array -) => unknown | Promise; +type ReservedKeys = 'end' | 'getStderr' | 'getStdout' | 'setup' | 'teardown'; +type ExcludeReservedKeys = Exclude; + +type FunctionLike = (args: any) => unknown; + +type MethodLikeKeys = { + [K in keyof T]: T[K] extends FunctionLike ? K : never; +}[keyof T]; + +type Promisify = ReturnType extends Promise + ? (...args: Parameters) => Promise + : (...args: Parameters) => Promise>; + +export type WorkerModule = { + [K in keyof T as Extract< + ExcludeReservedKeys, + MethodLikeKeys + >]: T[K] extends FunctionLike ? Promisify : never; +}; // Because of the dynamic nature of a worker communication process, all messages // coming from any of the other processes cannot be typed. Thus, many types @@ -91,7 +107,7 @@ export interface TaskQueue { export type WorkerSchedulingPolicy = 'round-robin' | 'in-order'; -export type FarmOptions = { +export type WorkerFarmOptions = { computeWorkerKey?: (method: string, ...args: Array) => string | null; enableWorkerThreads?: boolean; exposedMethods?: ReadonlyArray; diff --git a/packages/jest-worker/src/workers/processChild.ts b/packages/jest-worker/src/workers/processChild.ts index 7a8ff6729960..dda7938aa415 100644 --- a/packages/jest-worker/src/workers/processChild.ts +++ b/packages/jest-worker/src/workers/processChild.ts @@ -11,13 +11,14 @@ import { CHILD_MESSAGE_INITIALIZE, ChildMessageCall, ChildMessageInitialize, - FunctionLike, PARENT_MESSAGE_CLIENT_ERROR, PARENT_MESSAGE_ERROR, PARENT_MESSAGE_OK, PARENT_MESSAGE_SETUP_ERROR, } from '../types'; +type UnknownFunction = (...args: Array) => unknown | Promise; + let file: string | null = null; let setupArgs: Array = []; let initialized = false; @@ -114,7 +115,7 @@ function exitProcess(): void { function execMethod(method: string, args: Array): void { const main = require(file!); - let fn: FunctionLike; + let fn: UnknownFunction; if (method === 'default') { fn = main.__esModule ? main['default'] : main; @@ -143,7 +144,7 @@ const isPromise = (obj: any): obj is PromiseLike => typeof obj.then === 'function'; function execFunction( - fn: FunctionLike, + fn: UnknownFunction, ctx: unknown, args: Array, onResult: (result: unknown) => void, diff --git a/packages/jest-worker/src/workers/threadChild.ts b/packages/jest-worker/src/workers/threadChild.ts index 05518ea48fe9..f0c41563cf15 100644 --- a/packages/jest-worker/src/workers/threadChild.ts +++ b/packages/jest-worker/src/workers/threadChild.ts @@ -12,13 +12,14 @@ import { CHILD_MESSAGE_INITIALIZE, ChildMessageCall, ChildMessageInitialize, - FunctionLike, PARENT_MESSAGE_CLIENT_ERROR, PARENT_MESSAGE_ERROR, PARENT_MESSAGE_OK, PARENT_MESSAGE_SETUP_ERROR, } from '../types'; +type UnknownFunction = (...args: Array) => unknown | Promise; + let file: string | null = null; let setupArgs: Array = []; let initialized = false; @@ -116,7 +117,7 @@ function exitProcess(): void { function execMethod(method: string, args: Array): void { const main = require(file!); - let fn: FunctionLike; + let fn: UnknownFunction; if (method === 'default') { fn = main.__esModule ? main['default'] : main; @@ -145,7 +146,7 @@ const isPromise = (obj: any): obj is PromiseLike => typeof obj.then === 'function'; function execFunction( - fn: FunctionLike, + fn: UnknownFunction, ctx: unknown, args: Array, onResult: (result: unknown) => void, diff --git a/yarn.lock b/yarn.lock index 921b6b4b86cc..1f3919d9a894 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13677,6 +13677,7 @@ __metadata: version: 0.0.0-use.local resolution: "jest-worker@workspace:packages/jest-worker" dependencies: + "@tsd/typescript": ~4.6.2 "@types/merge-stream": ^1.1.2 "@types/node": "*" "@types/supports-color": ^8.1.0 @@ -13684,6 +13685,7 @@ __metadata: jest-leak-detector: ^28.0.1 merge-stream: ^2.0.0 supports-color: ^8.0.0 + tsd-lite: ^0.5.1 worker-farm: ^1.6.0 languageName: unknown linkType: soft