Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: plug-and-play test environment #56

Merged
merged 6 commits into from
Nov 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 44 additions & 28 deletions docs/jest-environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* `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.
2 changes: 0 additions & 2 deletions environment-hooks.js

This file was deleted.

2 changes: 2 additions & 0 deletions environment-listener.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/* Jest 27 fallback */
module.exports = require('./dist/environment-listener');
11 changes: 6 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
125 changes: 4 additions & 121 deletions src/environment-decorator.ts
Original file line number Diff line number Diff line change
@@ -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 extends JestEnvironment = JestEnvironment> = E & {
readonly testEvents: ReadonlyAsyncEmitter<ForwardedCircusEvent>;
};

/**
* 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<E extends JestEnvironment>(
export const WithMetadata = <E extends JestEnvironment = JestEnvironment>(
JestEnvironmentClass: new (...args: any[]) => E,
): new (...args: any[]) => WithEmitter<E> {
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<ForwardedCircusEvent> {
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<void> {
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<E>;
}

/**
* @inheritDoc
*/
export default WithMetadata;
) => WithEmitter<E>(JestEnvironmentClass, listener, 'WithMetadata');
1 change: 0 additions & 1 deletion src/environment-jsdom.ts
Original file line number Diff line number Diff line change
@@ -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;
92 changes: 31 additions & 61 deletions src/environment-hooks.ts → src/environment-listener.ts
Original file line number Diff line number Diff line change
@@ -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<object, SemiAsyncEmitter<ForwardedCircusEvent>> = 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;
Expand Down Expand Up @@ -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<ForwardedCircusEvent>('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)
Expand Down Expand Up @@ -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<E extends Circus.Event = Circus.Event> = {
type: E['name'];
event: E;
state: Circus.State;
};

export async function onTestEnvironmentSetup(_env: JestEnvironment): Promise<void> {
if (realm.type === 'child_process') {
await realm.ipc.start();
}
}

export async function onTestEnvironmentTeardown(_env: JestEnvironment): Promise<void> {
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<void> => 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;
1 change: 0 additions & 1 deletion src/environment-node.ts
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 3 additions & 3 deletions src/jest-reporter/__tests__/fallback-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -23,10 +23,10 @@ describe('Fallback API', () => {
const IPCServer = jest.requireMock('../../ipc').IPCServer;
const ipc: jest.Mocked<IPCServer> = new IPCServer();

const emitter = new SerialSyncEmitter<MetadataEvent>('core').on('*', (event: MetadataEvent) => {
const emitter = new SerialEmitter<MetadataEvent>('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();
Expand Down
Loading