diff --git a/docs/jest-environment.md b/docs/jest-environment.md index 7332571..1a45db6 100644 --- a/docs/jest-environment.md +++ b/docs/jest-environment.md @@ -29,36 +29,52 @@ If you already have a custom test environment, you can extend it via composition ## Manual integration -If your use case is not covered by the above, you can call `jest-metadata` lifecycle methods manually: +If your use case is not covered by the above, you can rewrite your test environment +into a listener of `jest-environment-emit` events. Here is an example of how to do it: + +```js title="jest.config.js" +/** @type {import('jest').Config} */ +module.exports = { + testEnvironment: 'jest-environment-emit', + testEnvironmentOptions: { + listeners: [ + 'jest-metadata/environment-listener', + ['your-project/listener', { /* options */ }], + ], + }, +}; +``` -```diff -+ import * as jmHooks from 'jest-metadata/environment-hooks'; - -class MyCustomEnvironment extends NodeEnvironment { - constructor(config, context) { - super(config, context); -+ jmHooks.onTestEnvironmentCreate(this, config, context); - } - - async setup() { - await super.setup(); -+ await jmHooks.onTestEnvironmentSetup(this); - } - - async teardown() { -+ await jmHooks.onTestEnvironmentTeardown(this); - await super.teardown(); - } - - async handleTestEvent(event, state) { -+ await jmHooks.onTestEnvironmentHandleTestEvent(this, event, state); - // ... - } +where `your-project/listener` file contains the following code: + +```js title="your-project/listeners.js" +import * as jee from 'jest-environment-emit'; + +const listener: jee.EnvironmentListenerFn = (context, yourOptions) => { + context.testEvents + .on('test_environment_setup', ({ env }: jee.TestEnvironmentSetupEvent) => { + // ... + }) + .on('test_start', ({ event, state }: jee.TestEnvironmentCircusEvent) => { + // ... + }) + .on('test_done', ({ event, state }: jee.TestEnvironmentCircusEvent) => { + // ... + }) + .on('test_environment_teardown', ({ env }: jee.TestEnvironmentTeardownEvent) => { + // ... + }); +}; + +export default listener; ``` +## Lifecycle of `jest-metadata` test environment + Here is a brief description of each lifecycle method: -* `onTestEnvironmentCreate` - called when the test environment is created. This is the first lifecycle method to be called. It injects `__JEST_METADATA__` context into `this.global` object to eliminate sandboxing issues. -* `onTestEnvironmentSetup` - called when the test environment is set up. This is the second lifecycle method to be called. It initializes `jest-metadata` IPC client if Jest is running in a multi-worker mode to enable communication with the main process where the reporters reside. -* `onTestEnvironmentHandleTestEvent` - called when the test environment receives a test event from `jest-circus`. This method is called many times during the test run. It is responsible for building a metadata tree, parallel to `jest-circus` State tree, and for sending test events to the main process. -* `onTestEnvironmentTeardown` - called when the test environment is torn down. This is the last lifecycle method to be called. It is responsible for shutting down the IPC client and for sending the final test event to the main process. \ No newline at end of file +* `test_environment_setup`: + * injects `__JEST_METADATA__` context into `this.global` object to eliminate sandboxing issues; + * initializes `jest-metadata` IPC client if Jest is running in a multi-worker mode to enable communication with the main process where the reporters reside; +* all `jest-circus` events coming to `handleTestEvent` – building a metadata tree, parallel to `jest-circus` State tree, and for sending test events to the main process. +* `test_environment_teardown` – responsible for shutting down the IPC client and for sending the final test event to the main process. diff --git a/environment-hooks.js b/environment-hooks.js deleted file mode 100644 index ab5934e..0000000 --- a/environment-hooks.js +++ /dev/null @@ -1,2 +0,0 @@ -/* Jest 27 fallback */ -module.exports = require('./dist/environment-hooks'); diff --git a/environment-listener.js b/environment-listener.js new file mode 100644 index 0000000..3089cdc --- /dev/null +++ b/environment-listener.js @@ -0,0 +1,2 @@ +/* Jest 27 fallback */ +module.exports = require('./dist/environment-listener'); diff --git a/package.json b/package.json index 9f9f60f..3fdf371 100644 --- a/package.json +++ b/package.json @@ -30,10 +30,10 @@ "require": "./dist/environment-decorator.js", "types": "./dist/environment-decorator.d.ts" }, - "./environment-hooks": { - "import": "./dist/environment-hooks.js", - "require": "./dist/environment-hooks.js", - "types": "./dist/environment-hooks.d.ts" + "./environment-listener": { + "import": "./dist/environment-listener.js", + "require": "./dist/environment-listener.js", + "types": "./dist/environment-listener.d.ts" }, "./environment-jsdom": { "import": "./dist/environment-jsdom.js", @@ -87,10 +87,11 @@ }, "homepage": "https://github.com/wix-incubator/jest-metadata#readme", "dependencies": { - "bunyamin": "^1.1.1", + "bunyamin": "^1.4.2", "bunyan": "^2.0.5", "bunyan-debug-stream": "^3.1.0", "funpermaproxy": "^1.1.0", + "jest-environment-emit": "^1.0.0-alpha.5", "lodash.merge": "^4.6.2", "node-ipc": "9.2.1", "strip-ansi": "^6.0.0", diff --git a/src/environment-decorator.ts b/src/environment-decorator.ts index 92d7a51..2dde039 100644 --- a/src/environment-decorator.ts +++ b/src/environment-decorator.ts @@ -1,124 +1,7 @@ import type { JestEnvironment } from '@jest/environment'; -import type { Circus } from '@jest/types'; +import WithEmitter from 'jest-environment-emit'; +import listener from './environment-listener'; -import { - ForwardedCircusEvent, - getEmitter, - onHandleTestEvent, - onTestEnvironmentCreate, - onTestEnvironmentSetup, - onTestEnvironmentTeardown, -} from './environment-hooks'; -import type { ReadonlyAsyncEmitter } from './types'; - -export { ForwardedCircusEvent } from './environment-hooks'; - -export type WithEmitter = E & { - readonly testEvents: ReadonlyAsyncEmitter; -}; - -/** - * Decorator for a given JestEnvironment subclass that extends - * {@link JestEnvironment#constructor}, {@link JestEnvironment#global}, - * {@link JestEnvironment#setup}, and {@link JestEnvironment#handleTestEvent} - * and {@link JestEnvironment#teardown} in a way that is compatible with - * jest-metadata. - * - * You can use this decorator to extend a base JestEnvironment class inside - * your own environment class in a declarative way. If you prefer to control - * the integration with {@link module:jest-metadata} yourself, you can use - * low-level hooks from {@link module:jest-metadata/environment-hooks}. - * @param JestEnvironmentClass - Jest environment subclass to decorate - * @returns a decorated Jest environment subclass, e.g. `WithMetadata(JestEnvironmentNode)` - * @example - * ```javascript - * import WithMetadata from 'jest-metadata/environment-decorator'; - * - * class MyEnvironment extends WithMetadata(JestEnvironmentNode) { - * constructor(config, context) { - * super(config, context); - * - * this.testEvents - * .on('setup', async ({ event, state }) => { ... }) - * .on('include_test_location_in_result', ({ event, state }) => { ... }) - * .on('start_describe_definition', ({ event, state }) => { ... }) - * .on('finish_describe_definition', ({ event, state }) => { ... }) - * .on('add_hook', ({ event, state }) => { ... }) - * .on('add_test', ({ event, state }) => { ... }) - * .on('hook_failure', async ({ event, state }) => { ... }) - * .on('hook_start', async ({ event, state }) => { ... }) - * .on('hook_success', async ({ event, state }) => { ... }) - * .on('run_finish', async ({ event, state }) => { ... }) - * .on('run_start', async ({ event, state }) => { ... }) - * .on('run_describe_start', async ({ event, state }) => { ... }) - * .on('test_start', async ({ event, state }) => { ... }) - * .on('test_retry', async ({ event, state }) => { ... }) - * .on('test_skip', async ({ event, state }) => { ... }) - * .on('test_todo', async ({ event, state }) => { ... }) - * .on('test_fn_start', async ({ event, state }) => { ... }) - * .on('test_fn_failure', async ({ event, state }) => { ... }) - * .on('test_fn_success', async ({ event, state }) => { ... }) - * .on('test_done', async ({ event, state }) => { ... }) - * .on('run_describe_finish', async ({ event, state }) => { ... }) - * .on('teardown', async ({ event, state }) => { ... }) - * .on('error', ({ event, state }) => { ... }); - * } - * - * async setup() { - * await super.setup(); - * // ... your custom logic - * } - * - * async teardown() { - * // ... your custom logic - * await super.teardown(); - * } - * } - * ``` - */ -export function WithMetadata( +export const WithMetadata = ( JestEnvironmentClass: new (...args: any[]) => E, -): new (...args: any[]) => WithEmitter { - const compositeName = `WithMetadata(${JestEnvironmentClass.name})`; - - return { - // @ts-expect-error TS2415: Class '[`${compositeName}`]' incorrectly extends base class 'E'. - [`${compositeName}`]: class extends JestEnvironmentClass { - constructor(...args: any[]) { - super(...args); - onTestEnvironmentCreate(this, args[0], args[1]); - } - - protected get testEvents(): ReadonlyAsyncEmitter { - return getEmitter(this); - } - - async setup() { - await super.setup(); - await onTestEnvironmentSetup(this); - } - - // @ts-expect-error TS2415: The base class has an arrow function, but this can be a method - handleTestEvent(event: Circus.Event, state: Circus.State): void | Promise { - const maybePromise = (super.handleTestEvent as JestEnvironment['handleTestEvent'])?.( - event as any, - state, - ); - - return typeof maybePromise?.then === 'function' - ? maybePromise.then(() => onHandleTestEvent(this, event, state)) - : onHandleTestEvent(this, event, state); - } - - async teardown() { - await super.teardown(); - await onTestEnvironmentTeardown(this); - } - }, - }[compositeName] as unknown as new (...args: any[]) => WithEmitter; -} - -/** - * @inheritDoc - */ -export default WithMetadata; +) => WithEmitter(JestEnvironmentClass, listener, 'WithMetadata'); diff --git a/src/environment-jsdom.ts b/src/environment-jsdom.ts index 71f5910..7c839e0 100644 --- a/src/environment-jsdom.ts +++ b/src/environment-jsdom.ts @@ -1,6 +1,5 @@ import JestEnvironmentJsdom from 'jest-environment-jsdom'; import { WithMetadata } from './environment-decorator'; -export { ForwardedCircusEvent } from './environment-hooks'; export const TestEnvironment = WithMetadata(JestEnvironmentJsdom); export default TestEnvironment; diff --git a/src/environment-hooks.ts b/src/environment-listener.ts similarity index 59% rename from src/environment-hooks.ts rename to src/environment-listener.ts index 53c77db..68585b2 100644 --- a/src/environment-hooks.ts +++ b/src/environment-listener.ts @@ -1,18 +1,15 @@ import { inspect } from 'util'; -import type { EnvironmentContext, JestEnvironment, JestEnvironmentConfig } from '@jest/environment'; -import type { Circus } from '@jest/types'; +import type { EnvironmentListenerFn, TestEnvironmentCircusEvent } from 'jest-environment-emit'; import { JestMetadataError } from './errors'; -import { injectRealmIntoSandbox, realm, detectDuplicateRealms } from './realms'; -import { logger, jestUtils, SemiAsyncEmitter } from './utils'; +import { detectDuplicateRealms, injectRealmIntoSandbox, realm } from './realms'; +import { jestUtils, logger } from './utils'; -const log = logger.child({ cat: 'environment', tid: 'environment' }); -const emitterMap: WeakMap> = new WeakMap(); +const listener: EnvironmentListenerFn = (context) => { + const log = logger.child({ cat: 'environment', tid: 'environment' }); + const jestEnvironment = context.env; + const jestEnvironmentConfig = context.config; + const environmentContext = context.context; -export function onTestEnvironmentCreate( - jestEnvironment: JestEnvironment, - jestEnvironmentConfig: JestEnvironmentConfig, - environmentContext: EnvironmentContext, -): void { detectDuplicateRealms(true); injectRealmIntoSandbox(jestEnvironment.global, realm); const testFilePath = environmentContext.testPath; @@ -49,19 +46,33 @@ export function onTestEnvironmentCreate( } } - const testEventHandler = ({ event, state }: ForwardedCircusEvent) => { + const testEventHandler = ({ event, state }: TestEnvironmentCircusEvent) => { realm.environmentHandler.handleTestEvent(event, state); }; const flushHandler = () => realm.ipc.flush(); - const emitter = new SemiAsyncEmitter('environment', [ - 'start_describe_definition', - 'finish_describe_definition', - 'add_hook', - 'add_test', - 'error', - ]) + context.testEvents + .on( + 'test_environment_setup', + async function () { + if (realm.type === 'child_process') { + await realm.ipc.start(); + } + }, + -1, + ) + .on( + 'test_environment_teardown', + async function () { + detectDuplicateRealms(false); + + if (realm.type === 'child_process') { + await realm.ipc.stop(); + } + }, + Number.MAX_SAFE_INTEGER, + ) .on('setup', testEventHandler, -1) .on('include_test_location_in_result', testEventHandler, -1) .on('start_describe_definition', testEventHandler, -1) @@ -89,47 +100,6 @@ export function onTestEnvironmentCreate( .on('run_finish', testEventHandler, Number.MAX_SAFE_INTEGER - 1) .on('run_finish', flushHandler, Number.MAX_SAFE_INTEGER) .on('teardown', testEventHandler, Number.MAX_SAFE_INTEGER); - - emitterMap.set(jestEnvironment, emitter); -} - -export type ForwardedCircusEvent = { - type: E['name']; - event: E; - state: Circus.State; }; -export async function onTestEnvironmentSetup(_env: JestEnvironment): Promise { - if (realm.type === 'child_process') { - await realm.ipc.start(); - } -} - -export async function onTestEnvironmentTeardown(_env: JestEnvironment): Promise { - detectDuplicateRealms(false); - - if (realm.type === 'child_process') { - await realm.ipc.stop(); - } -} - -/** - * Pass Jest Circus event and state to the handler. - * After recalculating the state, this method synchronizes with the metadata server. - */ -export const onHandleTestEvent = ( - env: JestEnvironment, - event: Circus.Event, - state: Circus.State, -): void | Promise => getEmitter(env).emit({ type: event.name, event, state }); - -export const getEmitter = (env: JestEnvironment) => { - const emitter = emitterMap.get(env); - if (!emitter) { - throw new JestMetadataError( - 'Emitter is not found. Most likely, you are using a non-valid environment reference.', - ); - } - - return emitter; -}; +export default listener; diff --git a/src/environment-node.ts b/src/environment-node.ts index b159b67..e6f2ccc 100644 --- a/src/environment-node.ts +++ b/src/environment-node.ts @@ -1,6 +1,5 @@ import JestEnvironmentNode from 'jest-environment-node'; import { WithMetadata } from './environment-decorator'; -export { ForwardedCircusEvent } from './environment-hooks'; export const TestEnvironment = WithMetadata(JestEnvironmentNode); export default TestEnvironment; diff --git a/src/jest-reporter/__tests__/fallback-api.test.ts b/src/jest-reporter/__tests__/fallback-api.test.ts index 7b9215b..2bc2d03 100644 --- a/src/jest-reporter/__tests__/fallback-api.test.ts +++ b/src/jest-reporter/__tests__/fallback-api.test.ts @@ -8,7 +8,7 @@ import { MetadataFactoryImpl, WriteMetadataEventEmitter, } from '../../metadata'; -import { SerialSyncEmitter } from '../../utils'; +import { SerialEmitter } from '../../utils'; import { AssociateMetadata } from '../AssociateMetadata'; import { FallbackAPI } from '../FallbackAPI'; import { QueryMetadata } from '../QueryMetadata'; @@ -23,10 +23,10 @@ describe('Fallback API', () => { const IPCServer = jest.requireMock('../../ipc').IPCServer; const ipc: jest.Mocked = new IPCServer(); - const emitter = new SerialSyncEmitter('core').on('*', (event: MetadataEvent) => { + const emitter = new SerialEmitter('core').on('*', (event: MetadataEvent) => { metadataHandler.handle(event); }) as MetadataEventEmitter; - const setEmitter = new SerialSyncEmitter('set') as WriteMetadataEventEmitter; + const setEmitter = new SerialEmitter('set') as WriteMetadataEventEmitter; const metadataRegistry = new GlobalMetadataRegistry(); const metadataFactory = new MetadataFactoryImpl(metadataRegistry, setEmitter); const globalMetadata = metadataFactory.createGlobalMetadata(); diff --git a/src/metadata/__tests__/integration.test.ts b/src/metadata/__tests__/integration.test.ts index 6d98e1a..9810686 100644 --- a/src/metadata/__tests__/integration.test.ts +++ b/src/metadata/__tests__/integration.test.ts @@ -1,6 +1,6 @@ import fixtures from '../../../e2e'; -import { SerialSyncEmitter } from '../../utils'; +import { SerialEmitter } from '../../utils'; import { PlantSerializer } from '../__utils__'; import { GlobalMetadataRegistry, @@ -13,7 +13,7 @@ describe('metadata - integration test:', () => { test.each(Object.values(fixtures.metadata))( `e2e/__fixtures__/%s`, (_name: string, fixture: any[]) => { - const emitter: WriteMetadataEventEmitter = new SerialSyncEmitter('set'); + const emitter: WriteMetadataEventEmitter = new SerialEmitter('set'); const metadataRegistry = new GlobalMetadataRegistry(); const metadataFactory = new MetadataFactoryImpl(metadataRegistry, emitter); const globalMetadata = metadataFactory.createGlobalMetadata(); diff --git a/src/metadata/__tests__/run-traversal.test.ts b/src/metadata/__tests__/run-traversal.test.ts index 8471fb5..d9fdcd2 100644 --- a/src/metadata/__tests__/run-traversal.test.ts +++ b/src/metadata/__tests__/run-traversal.test.ts @@ -1,6 +1,6 @@ import fixtures from '../../../e2e'; -import { SerialSyncEmitter } from '../../utils'; +import { SerialEmitter } from '../../utils'; import { GlobalMetadataRegistry, Metadata, @@ -15,7 +15,7 @@ describe('file metadata traversal:', () => { }); test.each(lastFixtures)(`fixtures/%s`, (_name: string, fixture: any[]) => { - const emitter: WriteMetadataEventEmitter = new SerialSyncEmitter('set'); + const emitter: WriteMetadataEventEmitter = new SerialEmitter('set'); const metadataRegistry = new GlobalMetadataRegistry(); const metadataFactory = new MetadataFactoryImpl(metadataRegistry, emitter); const globalMetadata = metadataFactory.createGlobalMetadata(); diff --git a/src/realms/BaseRealm.ts b/src/realms/BaseRealm.ts index aad73a9..397b423 100644 --- a/src/realms/BaseRealm.ts +++ b/src/realms/BaseRealm.ts @@ -12,18 +12,16 @@ import { WriteMetadataEventEmitter, } from '../metadata'; -import { AggregatedEmitter, SerialSyncEmitter } from '../utils'; +import { AggregatedEmitter, SerialEmitter } from '../utils'; export abstract class BaseRealm { - readonly coreEmitter = new SerialSyncEmitter('core').on( + readonly coreEmitter = new SerialEmitter('core').on( '*', (event: MetadataEvent) => { this.metadataHandler.handle(event); }, ) as MetadataEventEmitter; - readonly setEmitter = new SerialSyncEmitter( - 'set', - ) as WriteMetadataEventEmitter; + readonly setEmitter = new SerialEmitter('set') as WriteMetadataEventEmitter; readonly events = new AggregatedEmitter('events').add(this.coreEmitter); readonly metadataRegistry = new GlobalMetadataRegistry(); diff --git a/src/utils/emitters/AggregatedEmitter.test.ts b/src/utils/emitters/AggregatedEmitter.test.ts index 25718ea..68d4afe 100644 --- a/src/utils/emitters/AggregatedEmitter.test.ts +++ b/src/utils/emitters/AggregatedEmitter.test.ts @@ -1,5 +1,5 @@ import { AggregatedEmitter } from './AggregatedEmitter'; -import { SerialSyncEmitter } from './SerialSyncEmitter'; +import { SerialEmitter } from './SerialEmitter'; describe('AggregatedEmitter', () => { let emitter: AggregatedEmitter<{ type: string }>; @@ -9,8 +9,8 @@ describe('AggregatedEmitter', () => { }); test('should re-emit events of added #emitters', () => { - const dummy1 = new SerialSyncEmitter('dummy1'); - const dummy2 = new SerialSyncEmitter('dummy2'); + const dummy1 = new SerialEmitter('dummy1'); + const dummy2 = new SerialEmitter('dummy2'); const listener = jest.fn(); const event1 = { type: 'test', id: 1 }; const event2 = { type: 'test', id: 2 }; @@ -26,8 +26,8 @@ describe('AggregatedEmitter', () => { }); test('should allow subscribing to events only once', () => { - const dummy1 = new SerialSyncEmitter('dummy1'); - const dummy2 = new SerialSyncEmitter('dummy2'); + const dummy1 = new SerialEmitter('dummy1'); + const dummy2 = new SerialEmitter('dummy2'); const listener = jest.fn(); const event1 = { type: 'test', id: 1 }; const event2 = { type: 'test', id: 2 }; @@ -42,7 +42,7 @@ describe('AggregatedEmitter', () => { }); test('should allow unsubscribing from events', () => { - const dummy = new SerialSyncEmitter('dummy1'); + const dummy = new SerialEmitter('dummy1'); const listener = jest.fn(); const event = { type: 'test', id: 1 }; diff --git a/src/utils/emitters/AggregatedEmitter.ts b/src/utils/emitters/AggregatedEmitter.ts index d5d7fd4..06a8e8c 100644 --- a/src/utils/emitters/AggregatedEmitter.ts +++ b/src/utils/emitters/AggregatedEmitter.ts @@ -1,12 +1,12 @@ import type { Emitter, ReadonlyEmitter } from '../../types'; -import { SerialSyncEmitter } from './SerialSyncEmitter'; +import { SerialEmitter } from './SerialEmitter'; export class AggregatedEmitter implements ReadonlyEmitter { readonly #emitters = new WeakSet>(); - readonly #rootEmitter: SerialSyncEmitter; + readonly #rootEmitter: SerialEmitter; constructor(name: string) { - this.#rootEmitter = new SerialSyncEmitter(name); + this.#rootEmitter = new SerialEmitter(name); } add(emitter: Emitter): this { diff --git a/src/utils/emitters/ReadonlyEmitterBase.ts b/src/utils/emitters/ReadonlyEmitterBase.ts deleted file mode 100644 index 5699424..0000000 --- a/src/utils/emitters/ReadonlyEmitterBase.ts +++ /dev/null @@ -1,94 +0,0 @@ -/* eslint-disable @typescript-eslint/ban-types */ -import type { ReadonlyEmitter } from '../../types'; -import { iterateSorted } from '../iterateSorted'; -import { logger, nologger, optimizeTracing } from '../logger'; - -//#region Optimized event helpers - -const __CATEGORY_LISTENERS = ['listeners']; -const __LISTENERS = optimizeTracing((listener: unknown) => ({ - cat: __CATEGORY_LISTENERS, - fn: `${listener}`, -})); - -//#endregion - -const ONCE: unique symbol = Symbol('ONCE'); - -export abstract class ReadonlyEmitterBase - implements ReadonlyEmitter -{ - protected readonly _log: typeof logger; - protected readonly _listeners: Map = new Map(); - - #listenersCounter = 0; - - constructor(name?: string, shouldLog = true) { - this._log = (shouldLog ? logger : nologger).child({ cat: `emitter`, tid: `emitter-${name}` }); - this._listeners.set('*', []); - } - - on( - type: E['type'] | '*', - listener: Function & { [ONCE]?: true }, - order?: number, - ): this { - if (!listener[ONCE]) { - this._log.trace(__LISTENERS(listener), `on(${type})`); - } - - if (!this._listeners.has(type)) { - this._listeners.set(type, []); - } - - const listeners = this._listeners.get(type)!; - listeners.push([listener, order ?? this.#listenersCounter++]); - listeners.sort((a, b) => getOrder(a) - getOrder(b)); - - return this; - } - - once(type: E['type'] | '*', listener: Function, order?: number): this { - this._log.trace(__LISTENERS(listener), `once(${type})`); - return this.on(type, this.#createOnceListener(type, listener), order); - } - - off( - type: E['type'] | '*', - listener: Function & { [ONCE]?: true }, - _order?: number, - ): this { - if (!listener[ONCE]) { - this._log.trace(__LISTENERS(listener), `off(${type})`); - } - - const listeners = this._listeners.get(type) || []; - const index = listeners.findIndex(([l]) => l === listener); - if (index !== -1) { - listeners.splice(index, 1); - } - return this; - } - - protected *_getListeners(type: Event['type']): Iterable { - const wildcard: [Function, number][] = this._listeners.get('*') ?? []; - const named: [Function, number][] = this._listeners.get(type) ?? []; - for (const [listener] of iterateSorted<[Function, number]>(getOrder, wildcard, named)) { - yield listener; - } - } - - #createOnceListener(type: Event['type'], listener: Function) { - const onceListener = ((event: Event) => { - this.off(type, onceListener); - listener(event); - }) as Function & { [ONCE]?: true }; - - onceListener[ONCE] = true as const; - return onceListener; - } -} - -function getOrder([_a, b]: [T, number]): number { - return b; -} diff --git a/src/utils/emitters/SemiAsyncEmitter.ts b/src/utils/emitters/SemiAsyncEmitter.ts deleted file mode 100644 index 52825e5..0000000 --- a/src/utils/emitters/SemiAsyncEmitter.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { ReadonlyAsyncEmitter } from '../../types'; -import { SerialAsyncEmitter } from './SerialAsyncEmitter'; -import { SerialSyncEmitter } from './SerialSyncEmitter'; - -export class SemiAsyncEmitter - implements ReadonlyAsyncEmitter -{ - readonly #asyncEmitter: SerialAsyncEmitter; - readonly #syncEmitter: SerialSyncEmitter; - readonly #syncEvents: Set; - - constructor(name: string, syncEvents: Event['type'][]) { - this.#asyncEmitter = new SerialAsyncEmitter(name, false); - this.#syncEmitter = new SerialSyncEmitter(name, false); - this.#syncEvents = new Set(syncEvents); - } - - on( - type: E['type'] | '*', - listener: (event: E) => unknown, - order?: number, - ): this { - return this.#invoke('on', type, listener, order); - } - - once( - type: E['type'] | '*', - listener: (event: E) => unknown, - order?: number, - ): this { - return this.#invoke('once', type, listener, order); - } - - off(type: E['type'] | '*', listener: (event: E) => unknown): this { - return this.#invoke('off', type, listener); - } - - emit(event: Event): void | Promise { - return this.#syncEvents.has(event.type as Event['type']) - ? this.#syncEmitter.emit(event) - : this.#asyncEmitter.emit(event); - } - - #invoke( - methodName: 'on' | 'once' | 'off', - type: E['type'] | '*', - listener: (event: E) => unknown, - order?: number, - ): this { - const isSync = this.#syncEvents.has(type); - - if (type === '*' || isSync) { - this.#syncEmitter[methodName](type, listener, order); - } - - if (type === '*' || !isSync) { - this.#asyncEmitter[methodName](type, listener as (event: Event) => Promise, order); - } - - return this; - } -} diff --git a/src/utils/emitters/SerialAsyncEmitter.ts b/src/utils/emitters/SerialAsyncEmitter.ts deleted file mode 100644 index 1ccdda1..0000000 --- a/src/utils/emitters/SerialAsyncEmitter.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { AsyncEmitter } from '../../types'; -import { ReadonlyEmitterBase } from './ReadonlyEmitterBase'; -import { __EMIT, __INVOKE } from './syncEmitterCommons'; - -export class SerialAsyncEmitter - extends ReadonlyEmitterBase - implements AsyncEmitter -{ - #promise = Promise.resolve(); - - emit(event: Event): Promise { - return this.#enqueue(event); - } - - #enqueue(event: Event) { - return (this.#promise = this.#promise.then(() => this.#doEmit(event))); - } - - async #doEmit(event: Event) { - const eventType = event.type; - const listeners = [...this._getListeners(eventType)]; - - await this._log.trace.complete(__EMIT(event), event.type, async () => { - if (listeners) { - for (const listener of listeners) { - this._log.trace(__INVOKE(listener), 'invoke'); - await listener(event); - } - } - }); - } -} diff --git a/src/utils/emitters/SerialSyncEmitter.test.ts b/src/utils/emitters/SerialEmitter.test.ts similarity index 85% rename from src/utils/emitters/SerialSyncEmitter.test.ts rename to src/utils/emitters/SerialEmitter.test.ts index 0ef56da..944e5f8 100644 --- a/src/utils/emitters/SerialSyncEmitter.test.ts +++ b/src/utils/emitters/SerialEmitter.test.ts @@ -1,8 +1,8 @@ -import { SerialSyncEmitter } from './SerialSyncEmitter'; +import { SerialEmitter } from './SerialEmitter'; -describe('SerialSyncEmitter', () => { +describe('SerialEmitter', () => { it('should emit events', () => { - const emitter = new SerialSyncEmitter(); + const emitter = new SerialEmitter(); const listener = jest.fn(); emitter.on('test', listener); @@ -16,7 +16,7 @@ describe('SerialSyncEmitter', () => { }); it('should allow subscribing to events only once', () => { - const emitter = new SerialSyncEmitter(); + const emitter = new SerialEmitter(); const listener = jest.fn(); emitter.once('test', listener); emitter.emit({ type: 'test', payload: 42 }); @@ -26,7 +26,7 @@ describe('SerialSyncEmitter', () => { }); it('should allow subscribing to all events', () => { - const emitter = new SerialSyncEmitter(); + const emitter = new SerialEmitter(); const listener = jest.fn(); emitter.on('*', listener); emitter.emit({ type: 'test', payload: 42 }); @@ -38,7 +38,7 @@ describe('SerialSyncEmitter', () => { }); it('should allow unsubscribing from events', () => { - const emitter = new SerialSyncEmitter(); + const emitter = new SerialEmitter(); const listener = jest.fn(); emitter.on('test', listener); emitter.emit({ type: 'test', payload: 42 }); @@ -49,7 +49,7 @@ describe('SerialSyncEmitter', () => { }); it('should delay emits within emits', () => { - const emitter = new SerialSyncEmitter(); + const emitter = new SerialEmitter(); const listener1 = jest.fn(() => emitter.emit({ type: 'test', payload: 84 })); const listener2 = jest.fn(); emitter.once('test', listener1); diff --git a/src/utils/emitters/SerialEmitter.ts b/src/utils/emitters/SerialEmitter.ts new file mode 100644 index 0000000..3bda9e5 --- /dev/null +++ b/src/utils/emitters/SerialEmitter.ts @@ -0,0 +1,124 @@ +/* eslint-disable @typescript-eslint/ban-types */ +import type { Emitter } from '../../types'; + +import { logger, nologger, optimizeTracing } from '../logger'; + +//#region Optimized event helpers + +const CATEGORIES = { + ENQUEUE: ['enqueue'], + EMIT: ['emit'], + INVOKE: ['invoke'], + LISTENERS: ['listeners'], +}; + +const __EMIT = optimizeTracing((event: unknown) => ({ cat: CATEGORIES.EMIT, event })); +const __ENQUEUE = optimizeTracing((event: unknown) => ({ + cat: CATEGORIES.ENQUEUE, + event, +})); +const __INVOKE = optimizeTracing((listener: unknown, type?: '*') => ({ + cat: CATEGORIES.INVOKE, + fn: `${listener}`, + type, +})); +const __LISTENERS = optimizeTracing((listener: unknown) => ({ + cat: CATEGORIES.LISTENERS, + fn: `${listener}`, +})); + +//#endregion + +const ONCE: unique symbol = Symbol('ONCE'); + +/** + * An event emitter that emits events in the order they are received. + * If an event is emitted while another event is being emitted, the new event + * will be queued and emitted after the current event is finished. + */ +export class SerialEmitter implements Emitter { + protected readonly _log: typeof logger; + protected readonly _listeners: Map = new Map(); + + #queue: Event[] = []; + + constructor(name?: string, shouldLog = true) { + this._log = (shouldLog ? logger : nologger).child({ cat: `emitter`, tid: `emitter-${name}` }); + this._listeners.set('*', []); + } + + on(type: E['type'] | '*', listener: Function & { [ONCE]?: true }): this { + if (!listener[ONCE]) { + this._log.trace(__LISTENERS(listener), `on(${type})`); + } + + if (!this._listeners.has(type)) { + this._listeners.set(type, []); + } + + const listeners = this._listeners.get(type)!; + listeners.push(listener); + + return this; + } + + once(type: E['type'] | '*', listener: Function): this { + this._log.trace(__LISTENERS(listener), `once(${type})`); + return this.on(type, this.#createOnceListener(type, listener)); + } + + off(type: E['type'] | '*', listener: Function & { [ONCE]?: true }): this { + if (!listener[ONCE]) { + this._log.trace(__LISTENERS(listener), `off(${type})`); + } + + const listeners = this._listeners.get(type) || []; + const index = listeners.indexOf(listener); + if (index !== -1) { + listeners.splice(index, 1); + } + return this; + } + + emit(nextEvent: Event): void { + this.#queue.push(Object.freeze(nextEvent)); + + if (this.#queue.length > 1) { + this._log.trace(__ENQUEUE(nextEvent), `enqueue(${nextEvent.type})`); + return; + } + + while (this.#queue.length > 0) { + const event = this.#queue[0]; + const eventType = event.type; + const listeners = this._getListeners(eventType); + + this._log.trace.complete(__EMIT(event), event.type, () => { + if (listeners) { + for (const listener of listeners) { + this._log.trace(__INVOKE(listener), 'invoke'); + listener(event); + } + } + }); + + this.#queue.shift(); + } + } + + protected _getListeners(type: Event['type']): Function[] { + const wildcard: Function[] = this._listeners.get('*') ?? []; + const named: Function[] = this._listeners.get(type) ?? []; + return [...wildcard, ...named]; + } + + #createOnceListener(type: Event['type'], listener: Function) { + const onceListener = ((event: Event) => { + this.off(type, onceListener); + listener(event); + }) as Function & { [ONCE]?: true }; + + onceListener[ONCE] = true as const; + return onceListener; + } +} diff --git a/src/utils/emitters/SerialSyncEmitter.ts b/src/utils/emitters/SerialSyncEmitter.ts deleted file mode 100644 index 1407377..0000000 --- a/src/utils/emitters/SerialSyncEmitter.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { Emitter } from '../../types'; -import { ReadonlyEmitterBase } from './ReadonlyEmitterBase'; -import { __EMIT, __ENQUEUE, __INVOKE } from './syncEmitterCommons'; - -/** - * An event emitter that emits events in the order they are received. - * If an event is emitted while another event is being emitted, the new event - * will be queued and emitted after the current event is finished. - */ -export class SerialSyncEmitter - extends ReadonlyEmitterBase - implements Emitter -{ - #queue: Event[] = []; - - emit(nextEvent: Event): void { - this.#queue.push(Object.freeze(nextEvent)); - - if (this.#queue.length > 1) { - this._log.trace(__ENQUEUE(nextEvent), `enqueue(${nextEvent.type})`); - return; - } - - while (this.#queue.length > 0) { - const event = this.#queue[0]; - const eventType = event.type; - const listeners = [...this._getListeners(eventType)]; - - this._log.trace.complete(__EMIT(event), event.type, () => { - if (listeners) { - for (const listener of listeners) { - this._log.trace(__INVOKE(listener), 'invoke'); - listener(event); - } - } - }); - - this.#queue.shift(); - } - } -} diff --git a/src/utils/emitters/index.ts b/src/utils/emitters/index.ts index 2011a5f..c2073a3 100644 --- a/src/utils/emitters/index.ts +++ b/src/utils/emitters/index.ts @@ -1,4 +1,2 @@ export * from './AggregatedEmitter'; -export * from './SerialAsyncEmitter'; -export * from './SerialSyncEmitter'; -export * from './SemiAsyncEmitter'; +export * from './SerialEmitter'; diff --git a/src/utils/emitters/syncEmitterCommons.ts b/src/utils/emitters/syncEmitterCommons.ts deleted file mode 100644 index e77d68e..0000000 --- a/src/utils/emitters/syncEmitterCommons.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { optimizeTracing } from '../logger'; - -const CATEGORIES = { - ENQUEUE: ['enqueue'], - EMIT: ['emit'], - INVOKE: ['invoke'], -}; - -export const __ENQUEUE = optimizeTracing((event: unknown) => ({ - cat: CATEGORIES.ENQUEUE, - event, -})); -export const __EMIT = optimizeTracing((event: unknown) => ({ cat: CATEGORIES.EMIT, event })); -export const __INVOKE = optimizeTracing((listener: unknown, type?: '*') => ({ - cat: CATEGORIES.INVOKE, - fn: `${listener}`, - type, -})); diff --git a/src/utils/iterateSorted.test.ts b/src/utils/iterateSorted.test.ts deleted file mode 100644 index 1e8e69c..0000000 --- a/src/utils/iterateSorted.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { iterateSorted } from './iterateSorted'; - -describe('iterateSorted', () => { - const ordered = ['a', 'quick', 'brown', 'fox', 'jumps', 'over', 'the', 'lazy', 'dog']; - const getIndex = (x: string) => ordered.indexOf(x); - - it('should iterate over two sorted arrays', () => { - const a = ['a', 'fox', 'jumps', 'over', 'the', 'dog']; - const b = ['quick', 'brown', 'lazy']; - const c = [...iterateSorted(getIndex, a, b)]; - const d = [...iterateSorted(getIndex, b, a)]; - - expect(c).toEqual(ordered); - expect(d).toEqual(ordered); - }); - - it('should iterate once if arrays are the same', () => { - const a = ['a', 'fox', 'jumps', 'over', 'the', 'dog']; - const b = a; - const c = [...iterateSorted(getIndex, a, b)]; - expect(c).toEqual(a); - }); -}); diff --git a/src/utils/iterateSorted.ts b/src/utils/iterateSorted.ts deleted file mode 100644 index 8b6a80a..0000000 --- a/src/utils/iterateSorted.ts +++ /dev/null @@ -1,42 +0,0 @@ -export function* iterateSorted( - getIndex: (x: T) => number, - a: Iterable, - b: Iterable, -): IterableIterator { - if (a === b) { - yield* a; - return; - } - - const ia = a[Symbol.iterator](); - const ib = b[Symbol.iterator](); - - let ea = ia.next(); - let eb = ib.next(); - - while (!ea.done && !eb.done) { - const va = ea.value; - const vb = eb.value; - - const na = getIndex(va); - const nb = getIndex(vb); - - if (na <= nb) { - yield va; - ea = ia.next(); - } else { - yield vb; - eb = ib.next(); - } - } - - while (!ea.done) { - yield ea.value; - ea = ia.next(); - } - - while (!eb.done) { - yield eb.value; - eb = ib.next(); - } -} diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 38b3b47..fbc73f6 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,19 +1,25 @@ import fs from 'fs'; import path from 'path'; -import { traceEventStream, uniteTraceEventsToFile, wrapLogger } from 'bunyamin'; +import { bunyamin, traceEventStream, uniteTraceEventsToFile } from 'bunyamin'; import { createLogger } from 'bunyan'; import createDebugStream from 'bunyan-debug-stream'; import { noop } from './noop'; const logsDirectory = process.env.JEST_METADATA_DEBUG; -export const logger = wrapLogger({ - logger: createBunyanImpl(isTraceEnabled()), -}); - -export const nologger = wrapLogger({ - logger: createBunyanNoop(), -}) as typeof logger; +bunyamin.logger = createBunyanImpl(isTraceEnabled()); +if (!bunyamin.threadGroups.some((x) => x.id === 'metadata')) { + bunyamin.threadGroups.push( + { id: 'ipc-server', displayName: 'IPC Server (jest-metadata)' }, + { id: 'ipc-client', displayName: 'IPC Client (jest-metadata)' }, + { id: 'emitter-core', displayName: 'Core emitter (jest-metadata)' }, + { id: 'emitter-set', displayName: 'Set emitter (jest-metadata)' }, + { id: 'emitter-events', displayName: 'Events emitter (jest-metadata)' }, + { id: 'environment', displayName: 'Test Environment (jest-metadata)' }, + { id: 'metadata', displayName: 'Metadata (jest-metadata)' }, + { id: 'reporter', displayName: 'Reporter (jest-metadata)' }, + ); +} // eslint-disable-next-line @typescript-eslint/no-explicit-any export const optimizeTracing: (f: F) => F = isTraceEnabled() ? (f) => f : ((() => noop) as any); @@ -67,16 +73,7 @@ function createBunyanImpl(traceEnabled: boolean) { level: 'trace' as const, stream: traceEventStream({ filePath: createLogFilePath(), - threadGroups: [ - { id: 'ipc-server', displayName: 'IPC Server' }, - { id: 'ipc-client', displayName: 'IPC Client' }, - { id: 'emitter-core', displayName: 'Emitter (core)' }, - { id: 'emitter-set', displayName: 'Emitter (set)' }, - { id: 'emitter-events', displayName: 'Emitter (events)' }, - { id: 'environment', displayName: 'Test Environment' }, - { id: 'metadata', displayName: 'Metadata' }, - { id: 'reporter', displayName: 'Reporter' }, - ], + threadGroups: bunyamin.threadGroups, }), }, ] @@ -87,17 +84,6 @@ function createBunyanImpl(traceEnabled: boolean) { return bunyan; } -function createBunyanNoop() { - return { - trace: noop, - debug: noop, - info: noop, - warn: noop, - error: noop, - fatal: noop, - }; -} - const LOG_PATTERN = /^jest-metadata\..*\.log$/; export async function aggregateLogs() { @@ -123,3 +109,5 @@ export async function aggregateLogs() { fs.renameSync(logs[0], unitedLogPath); } } + +export { bunyamin as logger, nobunyamin as nologger } from 'bunyamin';