diff --git a/.commitlintrc.js b/.commitlintrc.js
new file mode 100644
index 0000000..50fe89a
--- /dev/null
+++ b/.commitlintrc.js
@@ -0,0 +1,4 @@
+module.exports = {
+ extends: ['@commitlint/config-conventional'],
+ rules: {},
+};
diff --git a/.czrc b/.czrc
new file mode 100644
index 0000000..90c38db
--- /dev/null
+++ b/.czrc
@@ -0,0 +1,3 @@
+{
+ "path": "./node_modules/cz-conventional-changelog"
+}
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..e310ff5
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,11 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_size = 2
+indent_style = space
+insert_final_newline = true
+max_line_length = 100
+tab_width = 2
+trim_trailing_whitespace = true
diff --git a/.eslintignore b/.eslintignore
new file mode 100644
index 0000000..59c299a
--- /dev/null
+++ b/.eslintignore
@@ -0,0 +1,3 @@
+coverage/**
+dist/**
+*.js
diff --git a/.eslintrc b/.eslintrc
new file mode 100644
index 0000000..60eb138
--- /dev/null
+++ b/.eslintrc
@@ -0,0 +1,69 @@
+{
+ "root": true,
+ "env": {
+ "node": true,
+ "jest": true
+ },
+ "extends": [
+ "eslint:recommended",
+ "plugin:@typescript-eslint/recommended",
+ "plugin:import/recommended",
+ "plugin:import/typescript",
+ "plugin:unicorn/all",
+ "plugin:node/recommended",
+ "plugin:prettier/recommended",
+ "plugin:ecmascript-compat/recommended"
+ ],
+ "ignorePatterns": [
+ "__fixtures__",
+ "coverage",
+ "dist",
+ "e2e",
+ "*.js"
+ ],
+ "parser": "@typescript-eslint/parser",
+ "parserOptions": {
+ "project": "tsconfig.json"
+ },
+ "plugins": [
+ "no-only-tests"
+ ],
+ "rules": {
+ "import/extensions": [
+ "error",
+ "ignorePackages",
+ {
+ "js": "never",
+ "jsx": "never",
+ "ts": "never",
+ "tsx": "never"
+ }
+ ],
+ "import/no-cycle": "error",
+ "import/order": ["error", {
+ "alphabetize": {"order": "asc", "caseInsensitive": true}
+ }],
+ "import/no-internal-modules": "error",
+ "node/no-missing-import": "off",
+ "node/no-unsupported-features/es-syntax": "off",
+ "@typescript-eslint/no-this-alias": "off",
+ "@typescript-eslint/no-explicit-any": "off",
+ "@typescript-eslint/member-ordering": "error",
+ "@typescript-eslint/no-unused-vars": ["error", {
+ "argsIgnorePattern": "^_",
+ "varsIgnorePattern": "^_"
+ }],
+ "unicorn/consistent-function-scoping": "off",
+ "unicorn/filename-case": "off",
+ "unicorn/prefer-at": "off",
+ "unicorn/prefer-json-parse-buffer": "off",
+ "unicorn/prefer-node-protocol": "off",
+ "unicorn/prefer-string-replace-all": "off",
+ "unicorn/prevent-abbreviations": "off",
+ "unicorn/no-array-callback-reference": "off",
+ "unicorn/no-null": "off",
+ "unicorn/no-this-assignment": "off",
+ "unicorn/no-unused-properties": "off",
+ "unicorn/prefer-module": "off"
+ }
+}
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..6313b56
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+* text=auto eol=lf
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..415d5b2
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,50 @@
+name: CI
+
+on:
+ push:
+ branches: [master, alpha, beta]
+ paths-ignore:
+ - '**/*.md'
+ pull_request:
+ branches: [master, alpha, beta]
+ paths-ignore:
+ - '**/*.md'
+
+jobs:
+
+ sanity:
+ name: Sanity
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - uses: actions/setup-node@v3
+ with:
+ node-version-file: '.nvmrc'
+ - name: Install main project
+ uses: bahmutov/npm-install@v1
+ with:
+ useLockFile: false
+ - name: Lint
+ run: npm run lint:ci
+ - name: Unit Tests
+ run: npm test
+
+ publish:
+ name: Publish
+ if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/alpha' || github.ref == 'refs/heads/beta'
+ needs: [sanity]
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - uses: actions/setup-node@v3
+ with:
+ node-version-file: '.nvmrc'
+ - name: Install main project
+ uses: bahmutov/npm-install@v1
+ with:
+ useLockFile: false
+ - name: Semantic release
+ run: npx --no-install semantic-release --debug
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
diff --git a/.gitignore b/.gitignore
index c6bba59..c6f200a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -128,3 +128,6 @@ dist
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
+
+# IDE
+.idea/
diff --git a/.lintstagedrc.js b/.lintstagedrc.js
new file mode 100644
index 0000000..b69b4df
--- /dev/null
+++ b/.lintstagedrc.js
@@ -0,0 +1,3 @@
+module.exports = {
+ '*.{js,ts}': ['eslint --fix', 'jest --bail --findRelatedTests --passWithNoTests'],
+}
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..696ca75
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,2 @@
+package-lock=false
+workspaces=false
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..a77793e
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+lts/hydrogen
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..2fc458e
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,10 @@
+{
+ "printWidth": 100,
+ "tabWidth": 2,
+ "tabs": false,
+ "semi": true,
+ "singleQuote": true,
+ "trailingComma": "all",
+ "bracketSpacing": true,
+ "arrowParens": "always"
+}
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..1284eec
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,128 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, religion, or sexual identity
+and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes,
+ and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the
+ overall community
+
+Examples of unacceptable behavior include:
+
+* The use of sexualized language or imagery, and sexual attention or
+ advances of any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or email
+ address, without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official e-mail address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement at
+[Issues page](https://github.com/wix-incubator/jest-environment-emit/issues).
+All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series
+of actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or
+permanent ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within
+the community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.0, available at
+https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
+
+Community Impact Guidelines were inspired by [Mozilla's code of conduct
+enforcement ladder](https://github.com/mozilla/diversity).
+
+[homepage]: https://www.contributor-covenant.org
+
+For answers to common questions about this code of conduct, see the FAQ at
+https://www.contributor-covenant.org/faq. Translations are available at
+https://www.contributor-covenant.org/translations.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..764e506
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,52 @@
+# CONTRIBUTING
+
+We welcome issues and pull requests from the community. :purple_heart:
+
+## Issues
+
+Open an issue on the [issue tracker].
+
+There a few issue templates to choose from, where you will find instructions on how to report a bug or request a feature.
+
+## Pull requests
+
+There are no strict rules for pull requests, but we recommend the following:
+
+* Open an issue first, and discuss your idea with the maintainers.
+* Fork the repository and create a new branch for your changes.
+* Make your changes and submit a pull request.
+* Add tests for your changes.
+* Update the documentation.
+
+### Setup
+
+This is a standard Node.js project. You'll need to have Node.js installed.
+
+Fork this repository, clone and install dependencies:
+
+```bash
+npm install
+```
+
+### Checking your code
+
+Before committing, run the linter and tests:
+
+```bash
+npm run lint
+npm test
+```
+
+To create a commit, use [Commitizen]:
+
+```bash
+npx cz
+```
+
+and follow the instructions. We adhere to Angular's [commit message guidelines].
+
+Thanks in advance for your contribution!
+
+[commit message guidelines]: https://github.com/angular/angular/blob/main/CONTRIBUTING.md#commit
+[issue tracker]: https://github.com/wix-incubator/jest-environment-emit/issues
+[Commitizen]: https://github.com/commitizen/cz-cli
diff --git a/README.md b/README.md
index acd6561..c3ca87b 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,159 @@
-# jest-environment-emit
-Environment with unlimited test event handlers
+
+
+![logo](https://github.com/wix-incubator/jest-environment-emit/assets/1962469/02006bb8-e7e4-45e1-9876-9b11316ed912)
+
+## jest-environment-emit
+
+_Environment with unlimited test event handlers_
+
+[![npm version](https://badge.fury.io/js/jest-environment-emit.svg)](https://badge.fury.io/js/jest-environment-emit)
+
+
+
+## Overview
+
+There can be only one test environment per a project config in Jest unlike test reporters, which can be multiple.
+
+This limitation is not convenient, so this package provides a couple of pre-defined test environments and a way to create custom ones:
+
+* `jest-environment-emit` – exports `WithEmitter`, a higher-order function to create custom environment classes
+* `jest-environment-emit/node` – exports `TestEnvironment`, a wrapper over `jest-environment-node`
+* `jest-environment-emit/jsdom` – exports `TestEnvironment`, a wrapper over `jest-environment-jsdom`
+
+To wrap a custom environment, use `WithEmitter`:
+
+```js
+import { WithEmitter } from 'jest-environment-emit';
+// ...
+export default WithEmitter(MyEnvironment);
+```
+
+## Usage
+
+Assuming you have any test environment wrapped with `jest-environment-emit`, you can add as many event listeners as you want:
+
+```js
+/** @type {import('jest').Config} */
+module.exports = {
+ // ...
+ testEnvironment: 'jest-environment-emit/node',
+ testEnvironmentOptions: {
+ eventListeners: [
+ './simple-subscription-1.js',
+ ['./parametrized-subscription-2.js', { some: 'options' }],
+ ]
+ },
+};
+```
+
+The modules specified in `eventListeners` are required and should export a function with the following signature:
+
+```js
+/** @type {import('jest-environment-emit').EnvironmentListenerFn} */
+const subscription = function (context, options) {
+ context.testEvents
+ .on('test_environment_setup', async ({ env }) => {
+ // use like TestEnvironment#setup, e.g.:
+ env.global.__SOME__ = 'value';
+ })
+ .on('add_hook', ({ event, state }) => {
+ // use like TestEnvironment#handleTestEvent in jest-circus
+ })
+ .on('run_start', async ({ event, state }) => {
+ // use like TestEnvironment#handleTestEvent in jest-circus
+ })
+ .on('test_environment_teardown', async ({ env }) => {
+ // use like TestEnvironment#teardown
+ })
+ .on('*', ({ type }) => {
+ // wildcard listener
+ if (type === 'test_start') {
+ // ...
+ }
+ });
+};
+
+export default subscription; // module.exports = subscription;
+```
+
+For exact type definitions, see [src/types.ts](src/types.ts).
+
+## Library API
+
+### Deriving from classes
+
+This higher-order function can also accept one subscriber function and custom class name:
+
+```js
+import { WithEmitter } from 'jest-environment-emit';
+// ...
+export default WithEmitter(MyEnvironment, 'WithMyListeners', (context, options) => {
+ context.testEvents.on('*', ({ type }) => {
+ // ...
+ });
+}); // -> class WithMyListeners(MyEnvironment) { ... }
+```
+
+The derived classes have a static method `derive` to continue derivation, e.g.:
+
+```js
+import { TestEnvironment } from 'jest-environment-emit/node';
+
+export default TestEnvironment.derive((context, options) => {
+ context.testEvents.on('*', ({ type }) => {
+ // ...
+ });
+}, 'WithMyListeners'); // -> class MyTestEnvironment(JestEnvironment) { ... }
+```
+
+All derived classes have also a protected property `testEvents` to access the event emitter.
+
+```js
+import { TestEnvironment } from 'jest-environment-emit/node';
+
+export class MyTestEnvironment extends TestEnvironment {
+ constructor(config, context) {
+ super(config, context);
+
+ this.testEvents.on('*', ({ type }) => {
+ // ...
+ });
+ }
+}
+```
+
+### Using custom priorities
+
+By default, all event listeners are appended to the list. If you need to guarantee the order of execution, you can specify a priority:
+
+```js
+/** @type {import('jest-environment-emit').EnvironmentListenerFn} */
+const subscription = function (context, options) {
+ context.testEvents
+ .on('test_environment_setup', async ({ env }) => {
+ // make sure this listener is executed first
+ }, -1)
+ .on('test_environment_teardown', async ({ env }) => {
+ // make sure this listener is executed last
+ }, 1E6)
+};
+```
+
+Try to avoid using priorities unless you really need them.
+
+## Troubleshooting
+
+Use `JEST_BUNYAMIN_DIR=path/to/dir` to enable debug logging.
+
+In your `globalTeardown` script, you can aggregate all the logs into a single file for convenience:
+
+```js
+// globalTeardown.js
+import { aggregateLogs } from 'jest-environment-emit/debug';
+
+module.exports = async () => {
+ await aggregateLogs();
+};
+```
+
+The logs, e.g. `jest-bunyamin.log` is a file viewable with [Perfetto](https://ui.perfetto.dev/) or `chrome://tracing`.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..6a2fcb7
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,47 @@
+# Security Policy
+
+## Supported Versions
+
+| Version | Supported |
+| ------------- | ------------------ |
+| 1.0.x | :white_check_mark: |
+| 1.0.0-beta.x | :x: |
+
+## Reporting a Vulnerability
+
+We take the security of `jest-environment-emit` seriously.
+If you have discovered a security vulnerability, we appreciate your help in disclosing it to us in a responsible manner.
+
+### How to Report
+
+**Please do not report security vulnerabilities through public GitHub issues.**
+
+- **Via Email**: Send an email to [yaroslavs@wix.com](mailto:yaroslavs@wix.com).
+If possible, encrypt your message with our PGP public key to keep the information secure.
+
+### What to Include
+
+Please provide detailed information about the vulnerability, including:
+
+- Description of the vulnerability
+- Potential impact if it was exploited
+- A detailed reproduction case, preferably with code examples and/or screenshots
+
+### Expectations
+
+- We will acknowledge receipt of the vulnerability report and send an initial response within 2 weeks.
+- We will keep you informed of the progress towards fixing the vulnerability.
+- We will mention your contribution when we fix the vulnerability (unless you prefer to remain anonymous).
+
+### Safe Harbor
+
+Any activities conducted in a manner consistent with this policy will be considered authorized conduct and we will not pursue legal action against you.
+
+### Public Disclosure
+
+Please refrain from sharing about the vulnerability until we have resolved it, to ensure that users have ample opportunity to update and are not unnecessarily put at risk.
+
+## Thank You
+
+We are deeply grateful to all the conscientious users who help us ensure the security and privacy of `jest-environment-emit` users.
+
diff --git a/e2e/jest.config.js b/e2e/jest.config.js
new file mode 100644
index 0000000..2ee66fb
--- /dev/null
+++ b/e2e/jest.config.js
@@ -0,0 +1,14 @@
+const base = require('../jest.config');
+
+module.exports = {
+ ...base,
+
+ rootDir: '..',
+ testEnvironment: 'jest-environment-emit/node',
+ testEnvironmentOptions: {
+ eventListeners: [
+ ['./e2e/listeners.cjs', { prefix: 'cjs' }],
+ ['./e2e/listeners.mjs', { prefix: 'mjs' }],
+ ],
+ },
+};
diff --git a/e2e/listeners.cjs b/e2e/listeners.cjs
new file mode 100644
index 0000000..107d183
--- /dev/null
+++ b/e2e/listeners.cjs
@@ -0,0 +1,6 @@
+/** @type {import('jest-environment-emit').EnvironmentListenerFn} */
+module.exports = ({ testEvents }, { prefix }) => {
+ testEvents.on('test_environment_teardown', () => {
+ console.log(`[${prefix}] test_environment_teardown`);
+ });
+};
diff --git a/e2e/listeners.mjs b/e2e/listeners.mjs
new file mode 100644
index 0000000..2fe76d3
--- /dev/null
+++ b/e2e/listeners.mjs
@@ -0,0 +1,6 @@
+/** @type {import('jest-environment-emit').EnvironmentListenerFn} */
+export default ({ testEvents }, { prefix }) => {
+ testEvents.on('test_environment_teardown', () => {
+ console.log(`[${prefix}] test_environment_teardown`);
+ });
+};
diff --git a/index.js b/index.js
new file mode 100644
index 0000000..d0ac0e9
--- /dev/null
+++ b/index.js
@@ -0,0 +1,2 @@
+/* Jest 27 fallback */
+module.exports = require('./dist/index');
diff --git a/jest.config.js b/jest.config.js
new file mode 100644
index 0000000..00895b0
--- /dev/null
+++ b/jest.config.js
@@ -0,0 +1,13 @@
+const CI = require('is-ci');
+
+/** @type {import('jest').Config} */
+module.exports = {
+ collectCoverage: CI,
+ coverageDirectory: '../../artifacts/unit/coverage',
+ modulePathIgnorePatterns: [/dist/, /node_modules/, /e2e/].map((s) => s.source),
+ preset: 'ts-jest',
+ testMatch: [
+ '/src/**/*.test.{js,ts}',
+ '/src/__tests__/**/*.{js,ts}'
+ ],
+};
diff --git a/jsdom.js b/jsdom.js
new file mode 100644
index 0000000..1bc0e94
--- /dev/null
+++ b/jsdom.js
@@ -0,0 +1,2 @@
+/* Jest 27 fallback */
+module.exports = require('./dist/jsdom');
diff --git a/node.js b/node.js
new file mode 100644
index 0000000..df3845d
--- /dev/null
+++ b/node.js
@@ -0,0 +1,2 @@
+/* Jest 27 fallback */
+module.exports = require('./dist/node');
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..2998b1e
--- /dev/null
+++ b/package.json
@@ -0,0 +1,143 @@
+{
+ "name": "jest-environment-emit",
+ "version": "1.0.0",
+ "description": "Environment with unlimited test event handlers",
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "files": [
+ "README.md",
+ "src",
+ "dist",
+ "*.js",
+ "!**/__utils__",
+ "!**/__tests__",
+ "!**/*.test.*"
+ ],
+ "exports": {
+ ".": {
+ "import": "./dist/index.js",
+ "require": "./dist/index.js",
+ "types": "./dist/index.d.ts"
+ },
+ "./jsdom": {
+ "import": "./dist/jsdom.js",
+ "require": "./dist/jsdom.js",
+ "types": "./dist/jsdom.d.ts"
+ },
+ "./node": {
+ "import": "./dist/node.js",
+ "require": "./dist/node.js",
+ "types": "./dist/node.d.ts"
+ },
+ "./debug": {
+ "import": "./dist/debug.js",
+ "require": "./dist/debug.js",
+ "types": "./dist/debug.d.ts"
+ },
+ "./package.json": "./package.json"
+ },
+ "engines": {
+ "node": ">=16.14.0"
+ },
+ "scripts": {
+ "prepare": "husky install || true",
+ "prepack": "tsc",
+ "build": "tsc",
+ "lint": "eslint . --fix",
+ "lint:ci": "eslint .",
+ "lint:staged": "lint-staged",
+ "test": "jest"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/wix-incubator/jest-environment-emit.git"
+ },
+ "keywords": [
+ "environment",
+ "jest",
+ "jest-environment",
+ "jest-circus"
+ ],
+ "author": "Yaroslav Serhieiev ",
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/wix-incubator/jest-environment-emit/issues"
+ },
+ "homepage": "https://github.com/wix-incubator/jest-environment-emit#readme",
+ "dependencies": {
+ "bunyamin": "^1.5.0",
+ "bunyan": "^2.0.5",
+ "bunyan-debug-stream": "^3.1.0",
+ "funpermaproxy": "^1.1.0",
+ "lodash.merge": "^4.6.2",
+ "node-ipc": "9.2.1",
+ "strip-ansi": "^6.0.0",
+ "tslib": "^2.5.3"
+ },
+ "peerDependencies": {
+ "@jest/environment": ">=27.2.5",
+ "@jest/types": ">=27.2.5",
+ "jest": ">=27.2.5",
+ "jest-environment-jsdom": ">=27.2.5",
+ "jest-environment-node": ">=27.2.5"
+ },
+ "peerDependenciesMeta": {
+ "@jest/environment": {
+ "optional": true
+ },
+ "@jest/types": {
+ "optional": true
+ },
+ "jest": {
+ "optional": true
+ },
+ "jest-environment-jsdom": {
+ "optional": true
+ },
+ "jest-environment-node": {
+ "optional": true
+ }
+ },
+ "devDependencies": {
+ "@commitlint/cli": "^17.4.2",
+ "@commitlint/config-conventional": "^17.4.2",
+ "@jest/environment": "^29.3.1",
+ "@jest/reporters": "^29.3.1",
+ "@jest/types": "^29.3.1",
+ "@types/bunyan": "^1.8.11",
+ "@types/glob": "^8.0.0",
+ "@types/jest": "^29.2.5",
+ "@types/lodash": "^4.14.191",
+ "@types/node": "^18.11.18",
+ "@types/node-ipc": "^9.2.0",
+ "@types/rimraf": "^3.0.2",
+ "@typescript-eslint/eslint-plugin": "^6.2.0",
+ "@typescript-eslint/parser": "^6.2.0",
+ "cz-conventional-changelog": "^3.3.0",
+ "eslint": "^8.46.0",
+ "eslint-config-prettier": "^8.9.0",
+ "eslint-import-resolver-typescript": "^3.5.5",
+ "eslint-plugin-ecmascript-compat": "^3.0.0",
+ "eslint-plugin-import": "^2.28.0",
+ "eslint-plugin-jsdoc": "^46.4.5",
+ "eslint-plugin-no-only-tests": "^3.1.0",
+ "eslint-plugin-node": "^11.1.0",
+ "eslint-plugin-prettier": "^5.0.0",
+ "eslint-plugin-unicorn": "^48.0.1",
+ "http-server": "^14.1.1",
+ "husky": "^8.0.3",
+ "is-ci": "^3.0.1",
+ "jest": "^29.6.2",
+ "jest-environment-jsdom": "^29.6.2",
+ "jest-environment-node": "^29.6.2",
+ "lint-staged": "^13.1.0",
+ "lodash": "^4.17.21",
+ "prettier": "^3.0.0",
+ "semantic-release": "^22.0.5",
+ "ts-jest": "^29.1.1",
+ "typescript": "~5.1.0"
+ },
+ "browserslist": [
+ "node 16"
+ ]
+}
diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts
new file mode 100644
index 0000000..dbf6208
--- /dev/null
+++ b/src/__tests__/index.test.ts
@@ -0,0 +1,66 @@
+import JestEnvironmentEmit from '../index';
+
+describe('integration test', () => {
+ it('should be able to subscribe to events', async () => {
+ const fnSubscription = jest.fn();
+ const configSubscription = jest.fn();
+ const originalMethods = {
+ setup: jest.fn().mockResolvedValue(void 0),
+ handleTestEvent: jest.fn().mockResolvedValue(void 0),
+ teardown: jest.fn().mockResolvedValue(void 0),
+ };
+
+ class OriginalEnvironment {
+ async setup() {
+ return originalMethods.setup();
+ }
+ async handleTestEvent() {
+ return originalMethods.handleTestEvent();
+ }
+ async teardown() {
+ return originalMethods.teardown();
+ }
+ }
+
+ const EnvironmentBase = JestEnvironmentEmit(OriginalEnvironment as any, undefined, 'Base');
+ const Environment = EnvironmentBase.derive(fnSubscription, 'WithMocks');
+
+ const config = {
+ globalConfig: {},
+ projectConfig: {
+ rootDir: process.cwd(),
+ testEnvironmentOptions: {
+ eventListeners: [configSubscription, [configSubscription, { foo: 'bar' }]],
+ },
+ },
+ };
+ const context = {};
+ const env = new Environment(config, context);
+ const onAddHook = jest.fn();
+ env.testEvents.on('add_hook', onAddHook);
+
+ expect(fnSubscription).not.toHaveBeenCalled();
+ await env.setup();
+ expect(originalMethods.setup).toHaveBeenCalled();
+ const expectedContext = {
+ env,
+ context,
+ config,
+ testEvents: expect.objectContaining({
+ on: expect.any(Function),
+ once: expect.any(Function),
+ off: expect.any(Function),
+ }),
+ };
+ expect(fnSubscription).toHaveBeenCalledWith(expectedContext, void 0);
+ expect(configSubscription).toHaveBeenCalledWith(expectedContext, void 0);
+ expect(configSubscription).toHaveBeenCalledWith(expectedContext, { foo: 'bar' });
+
+ await env.handleTestEvent({ name: 'add_hook' } as any, {} as any);
+ expect(originalMethods.handleTestEvent).toHaveBeenCalled();
+ expect(onAddHook).toHaveBeenCalled();
+
+ await env.teardown();
+ expect(originalMethods.teardown).toHaveBeenCalled();
+ });
+});
diff --git a/src/callbacks/index.ts b/src/callbacks/index.ts
new file mode 100644
index 0000000..f7893d1
--- /dev/null
+++ b/src/callbacks/index.ts
@@ -0,0 +1 @@
+export * from './resolveSubscription';
diff --git a/src/callbacks/requireModule.test.ts b/src/callbacks/requireModule.test.ts
new file mode 100644
index 0000000..f28b644
--- /dev/null
+++ b/src/callbacks/requireModule.test.ts
@@ -0,0 +1,24 @@
+jest.mock('../utils/logger');
+
+describe('requireModule', () => {
+ let requireModule: any;
+
+ beforeEach(() => {
+ requireModule = jest.requireActual('./requireModule').requireModule;
+ });
+
+ it('should require a module from the given root', async () => {
+ const theModule = await requireModule(process.cwd(), './package.json');
+ expect(theModule).toBeDefined();
+ });
+
+ it('should return null if the module cannot be found', async () => {
+ const theModule = await requireModule('.', 'non-existent');
+ const { logger } = jest.requireMock('../utils/logger');
+ expect(theModule).toBe(null);
+ expect(logger.logger.warn).toHaveBeenCalledWith(
+ expect.anything(),
+ 'Failed to resolve: non-existent',
+ );
+ });
+});
diff --git a/src/callbacks/requireModule.ts b/src/callbacks/requireModule.ts
new file mode 100644
index 0000000..1ccb719
--- /dev/null
+++ b/src/callbacks/requireModule.ts
@@ -0,0 +1,25 @@
+import type { JestEnvironment } from '@jest/environment';
+import type { EnvironmentListenerOnly } from '../types';
+import { logger } from '../utils';
+
+export async function requireModule(
+ rootDir: string,
+ moduleName: string,
+): Promise | null> {
+ try {
+ const cwdPath = require.resolve(moduleName, { paths: [rootDir] });
+ return (await importModule(cwdPath)) as EnvironmentListenerOnly;
+ } catch (error: any) {
+ logger.warn({ cat: 'import', err: error }, `Failed to resolve: ${moduleName}`);
+ return null;
+ }
+}
+
+async function importModule(absolutePath: string): Promise {
+ let result = await import(absolutePath);
+ if (result.__esModule) {
+ result = result.default;
+ }
+
+ return result.default ?? result;
+}
diff --git a/src/callbacks/resolveSubscription.test.ts b/src/callbacks/resolveSubscription.test.ts
new file mode 100644
index 0000000..e7f5b2b
--- /dev/null
+++ b/src/callbacks/resolveSubscription.test.ts
@@ -0,0 +1,20 @@
+import { resolveSubscription } from './resolveSubscription';
+
+describe('resolveSubscription', function () {
+ it('should tolerate falsy values', async function () {
+ const [callback] = await resolveSubscription(process.cwd(), undefined as any);
+ expect(callback).not.toThrow();
+ });
+
+ it('should pass through a function', async function () {
+ const fn = jest.fn();
+ const [callback] = await resolveSubscription(process.cwd(), fn);
+ expect(callback).toBe(fn);
+ });
+
+ it('should resolve a module name', async function () {
+ const [identity] = await resolveSubscription(process.cwd(), 'lodash/identity');
+ const someObject = {} as any;
+ expect(identity(someObject)).toBe(someObject);
+ });
+});
diff --git a/src/callbacks/resolveSubscription.ts b/src/callbacks/resolveSubscription.ts
new file mode 100644
index 0000000..be8ecdf
--- /dev/null
+++ b/src/callbacks/resolveSubscription.ts
@@ -0,0 +1,41 @@
+import type { JestEnvironment } from '@jest/environment';
+import type {
+ EnvironmentListener,
+ EnvironmentListenerFn,
+ EnvironmentListenerOnly,
+ EnvironmentListenerWithOptions,
+} from '../types';
+
+import { requireModule } from './requireModule';
+
+export type ResolvedEnvironmentListener = [
+ EnvironmentListenerFn,
+ any,
+];
+
+export async function resolveSubscription(
+ rootDir: string,
+ registration: EnvironmentListener | null,
+): Promise> {
+ if (Array.isArray(registration)) {
+ const [callback, options] = registration as EnvironmentListenerWithOptions;
+ return [await resolveSubscriptionSingle(rootDir, callback), options];
+ }
+
+ return [await resolveSubscriptionSingle(rootDir, registration), undefined];
+}
+
+export async function resolveSubscriptionSingle(
+ rootDir: string,
+ registration: EnvironmentListenerOnly | null,
+): Promise> {
+ if (typeof registration === 'function') {
+ return registration;
+ }
+
+ if (typeof registration === 'string') {
+ return resolveSubscriptionSingle(rootDir, await requireModule(rootDir, registration));
+ }
+
+ return () => {};
+}
diff --git a/src/debug.ts b/src/debug.ts
new file mode 100644
index 0000000..a820ef8
--- /dev/null
+++ b/src/debug.ts
@@ -0,0 +1 @@
+export { aggregateLogs } from './utils';
diff --git a/src/emitters/AsyncEmitter.ts b/src/emitters/AsyncEmitter.ts
new file mode 100644
index 0000000..c656c00
--- /dev/null
+++ b/src/emitters/AsyncEmitter.ts
@@ -0,0 +1,20 @@
+export interface ReadonlyAsyncEmitter {
+ on(
+ type: K | '*',
+ listener: (event: EventMap[K]) => void | Promise,
+ weight?: number,
+ ): this;
+ once(
+ type: K | '*',
+ listener: (event: EventMap[K]) => void | Promise,
+ weight?: number,
+ ): this;
+ off(
+ type: K | '*',
+ listener: (event: EventMap[K]) => void | Promise,
+ ): this;
+}
+
+export interface AsyncEmitter extends ReadonlyAsyncEmitter {
+ emit(type: K, event: EventMap[K]): Promise;
+}
diff --git a/src/emitters/Emitter.ts b/src/emitters/Emitter.ts
new file mode 100644
index 0000000..a76e0bb
--- /dev/null
+++ b/src/emitters/Emitter.ts
@@ -0,0 +1,17 @@
+export interface ReadonlyEmitter {
+ on(
+ type: K | '*',
+ listener: (event: EventMap[K]) => unknown,
+ weight?: number,
+ ): this;
+ once(
+ type: K | '*',
+ listener: (event: EventMap[K]) => unknown,
+ weight?: number,
+ ): this;
+ off(type: K | '*', listener: (event: EventMap[K]) => unknown): this;
+}
+
+export interface Emitter extends ReadonlyEmitter {
+ emit(type: K, event: EventMap[K]): void;
+}
diff --git a/src/emitters/ReadonlyEmitterBase.ts b/src/emitters/ReadonlyEmitterBase.ts
new file mode 100644
index 0000000..e4b7008
--- /dev/null
+++ b/src/emitters/ReadonlyEmitterBase.ts
@@ -0,0 +1,95 @@
+/* eslint-disable @typescript-eslint/ban-types */
+import { logger, optimizeTracing, iterateSorted } from '../utils';
+import type { ReadonlyEmitter } from './Emitter';
+
+//#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) {
+ this._log = logger.child({
+ cat: `emitter`,
+ tid: [name, {}],
+ });
+
+ this._listeners.set('*', []);
+ }
+
+ on(
+ type: K | '*',
+ listener: Function & { [ONCE]?: true },
+ order?: number,
+ ): this {
+ if (!listener[ONCE]) {
+ this._log.trace(__LISTENERS(listener), `on(${String(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: K | '*', listener: Function, order?: number): this {
+ this._log.trace(__LISTENERS(listener), `once(${String(type)})`);
+ return this.on(type, this.#createOnceListener(type, listener), order);
+ }
+
+ off(
+ type: K | '*',
+ listener: Function & { [ONCE]?: true },
+ _order?: number,
+ ): this {
+ if (!listener[ONCE]) {
+ this._log.trace(__LISTENERS(listener), `off(${String(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: K): 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: K | '*', 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/emitters/SemiAsyncEmitter.test.ts b/src/emitters/SemiAsyncEmitter.test.ts
new file mode 100644
index 0000000..28dc886
--- /dev/null
+++ b/src/emitters/SemiAsyncEmitter.test.ts
@@ -0,0 +1,44 @@
+import { SemiAsyncEmitter } from './SemiAsyncEmitter';
+
+describe('SemiAsyncEmitter', () => {
+ let emitter: SemiAsyncEmitter<{ async_event: number }, { sync_event: number }>;
+
+ beforeEach(() => {
+ emitter = new SemiAsyncEmitter('test-emitter', ['sync_event']);
+ });
+
+ it('should emit promises for async events', async () => {
+ const listener = jest.fn();
+ emitter.on('async_event', listener);
+ const promise = emitter.emit('async_event', 42);
+ expect(promise).toBeInstanceOf(Promise);
+ expect(listener).toHaveBeenCalledTimes(0);
+ await promise;
+ expect(listener).toHaveBeenCalledTimes(1);
+ expect(listener).toHaveBeenCalledWith(42);
+ });
+
+ it('should emit voids for sync events', async () => {
+ const listener = jest.fn();
+ emitter.on('sync_event', listener);
+ const promise = emitter.emit('sync_event', 42);
+ expect(promise).toBeUndefined();
+ expect(listener).toHaveBeenCalledTimes(1);
+ expect(listener).toHaveBeenCalledWith(42);
+ });
+
+ it('should allow wildcard listeners', async () => {
+ const listener = jest.fn();
+ emitter.on('*', listener);
+ const result1 = emitter.emit('sync_event', 42);
+ expect(result1).toBeUndefined();
+ expect(listener).toHaveBeenCalledTimes(1);
+ expect(listener).toHaveBeenCalledWith(42);
+
+ const result2 = emitter.emit('async_event', 84);
+ expect(result2).toBeInstanceOf(Promise);
+ await result2;
+ expect(listener).toHaveBeenCalledTimes(2);
+ expect(listener).toHaveBeenCalledWith(84);
+ });
+});
diff --git a/src/emitters/SemiAsyncEmitter.ts b/src/emitters/SemiAsyncEmitter.ts
new file mode 100644
index 0000000..bdc3314
--- /dev/null
+++ b/src/emitters/SemiAsyncEmitter.ts
@@ -0,0 +1,97 @@
+import type { ReadonlyAsyncEmitter } from './AsyncEmitter';
+import type { ReadonlyEmitter } from './Emitter';
+import { SerialAsyncEmitter } from './SerialAsyncEmitter';
+import { SerialSyncEmitter } from './SerialSyncEmitter';
+
+export type ReadonlySemiAsyncEmitter = ReadonlyAsyncEmitter &
+ ReadonlyEmitter;
+
+export class SemiAsyncEmitter
+ implements ReadonlyAsyncEmitter, ReadonlyEmitter
+{
+ readonly #asyncEmitter: SerialAsyncEmitter;
+ readonly #syncEmitter: SerialSyncEmitter;
+ readonly #syncEvents: Set;
+
+ constructor(name: string, syncEvents: Iterable) {
+ this.#asyncEmitter = new SerialAsyncEmitter(name);
+ this.#syncEmitter = new SerialSyncEmitter(name);
+ this.#syncEvents = new Set(syncEvents);
+ }
+
+ on(
+ type: K | '*',
+ listener: (event: SyncMap[K]) => unknown,
+ order?: number,
+ ): this;
+ on(
+ type: K | '*',
+ listener: (event: AsyncMap[K]) => unknown,
+ order?: number,
+ ): this;
+ on(
+ type: K | '*',
+ listener: (event: any) => unknown,
+ order?: number,
+ ): this {
+ return this.#invoke('on', type, listener, order);
+ }
+
+ once(
+ type: K | '*',
+ listener: (event: SyncMap[K]) => unknown,
+ order?: number,
+ ): this;
+ once(
+ type: K | '*',
+ listener: (event: AsyncMap[K]) => unknown,
+ order?: number,
+ ): this;
+ once(
+ type: K | '*',
+ listener: (event: any) => unknown,
+ order?: number,
+ ): this {
+ return this.#invoke('once', type, listener, order);
+ }
+
+ off(type: K | '*', listener: (event: SyncMap[K]) => unknown): this;
+ off(type: K | '*', listener: (event: AsyncMap[K]) => unknown): this;
+ off(
+ type: K | '*',
+ listener: (event: any) => unknown,
+ ): this {
+ return this.#invoke('off', type, listener);
+ }
+
+ emit(type: K, event: SyncMap[K]): void;
+ emit(type: K, event: AsyncMap[K]): Promise;
+ emit(type: K, event: any): void | Promise {
+ return this.#syncEvents.has(type as keyof SyncMap)
+ ? this.#syncEmitter.emit(type as keyof SyncMap, event)
+ : this.#asyncEmitter.emit(type as keyof AsyncMap, event);
+ }
+
+ #invoke(
+ methodName: 'on' | 'once' | 'off',
+ type: K | '*',
+ listener: (event: any) => unknown,
+ order?: number,
+ ): this {
+ const isSync = this.#syncEvents.has(type as keyof SyncMap);
+
+ if (type === '*' || isSync) {
+ this.#syncEmitter[methodName](type as keyof SyncMap, listener, order);
+ }
+
+ if (type === '*' || !isSync) {
+ this.#asyncEmitter[methodName](
+ type as keyof AsyncMap,
+ listener as (event: Event) => Promise,
+ order,
+ );
+ }
+
+ return this;
+ }
+}
diff --git a/src/emitters/SerialAsyncEmitter.test.ts b/src/emitters/SerialAsyncEmitter.test.ts
new file mode 100644
index 0000000..6825b4e
--- /dev/null
+++ b/src/emitters/SerialAsyncEmitter.test.ts
@@ -0,0 +1,65 @@
+import { SerialAsyncEmitter } from './SerialAsyncEmitter';
+
+describe('SerialAsyncEmitter', () => {
+ it('should emit events', async () => {
+ const emitter = new SerialAsyncEmitter('test-emitter');
+ const listener = jest.fn();
+ emitter.on('test', listener);
+
+ await emitter.emit('test', { type: 'test', payload: 42 });
+ expect(listener).toHaveBeenCalledTimes(1);
+ expect(listener).toHaveBeenCalledWith({ type: 'test', payload: 42 });
+
+ await emitter.emit('test', { type: 'test', payload: 84 });
+ expect(listener).toHaveBeenCalledTimes(2);
+ expect(listener).toHaveBeenCalledWith({ type: 'test', payload: 84 });
+ });
+
+ it('should allow subscribing to events only once', async () => {
+ const emitter = new SerialAsyncEmitter('test-emitter');
+ const listener = jest.fn();
+ emitter.once('test', listener);
+ await emitter.emit('test', { type: 'test', payload: 42 });
+ expect(listener).toHaveBeenCalledWith({ type: 'test', payload: 42 });
+ await emitter.emit('test', { type: 'test', payload: 84 });
+ expect(listener).toHaveBeenCalledTimes(1);
+ });
+
+ it('should allow subscribing to all events', async () => {
+ const emitter = new SerialAsyncEmitter('test-emitter');
+ const listener = jest.fn();
+ emitter.on('*', listener);
+ await emitter.emit('test', { type: 'test', payload: 42 });
+ await emitter.emit('misc', { type: 'misc', payload: 84 });
+
+ expect(listener).toHaveBeenCalledWith({ type: 'test', payload: 42 });
+ expect(listener).toHaveBeenCalledWith({ type: 'misc', payload: 84 });
+ expect(listener).toHaveBeenCalledTimes(2);
+ });
+
+ it('should allow unsubscribing from events', async () => {
+ const emitter = new SerialAsyncEmitter('test-emitter');
+ const listener = jest.fn();
+ emitter.on('test', listener);
+ await emitter.emit('test', { type: 'test', payload: 42 });
+ expect(listener).toHaveBeenCalledTimes(1);
+ emitter.off('test', listener);
+ await emitter.emit('test', { type: 'test', payload: 84 });
+ expect(listener).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not delay emits within emits', async () => {
+ const emitter = new SerialAsyncEmitter('test-emitter');
+ const listener1 = jest.fn(() => emitter.emit('test', { type: 'test', payload: 84 }));
+ const listener2 = jest.fn();
+ emitter.once('test', listener1);
+ emitter.on('test', listener2);
+ await emitter.emit('test', { type: 'test', payload: 42 });
+ expect(listener2).toHaveBeenCalledTimes(1);
+ });
+});
+
+type TestEventMap = {
+ test: { type: 'test'; payload: number };
+ misc: { type: 'misc'; payload: number };
+};
diff --git a/src/emitters/SerialAsyncEmitter.ts b/src/emitters/SerialAsyncEmitter.ts
new file mode 100644
index 0000000..06301fd
--- /dev/null
+++ b/src/emitters/SerialAsyncEmitter.ts
@@ -0,0 +1,30 @@
+import type { AsyncEmitter } from './AsyncEmitter';
+import { ReadonlyEmitterBase } from './ReadonlyEmitterBase';
+import { __EMIT, __INVOKE } from './syncEmitterCommons';
+
+export class SerialAsyncEmitter
+ extends ReadonlyEmitterBase
+ implements AsyncEmitter
+{
+ #promise = Promise.resolve();
+
+ emit(eventType: K, event: EventMap[K]): Promise {
+ return this.#enqueue(eventType, event);
+ }
+
+ #enqueue(eventType: K, event: EventMap[K]) {
+ return (this.#promise = this.#promise.then(() => this.#doEmit(eventType, event)));
+ }
+
+ async #doEmit(eventType: K, event: EventMap[K]) {
+ const listeners = [...this._getListeners(eventType)];
+
+ await this._log.trace.complete(__EMIT(event), String(eventType), async () => {
+ if (listeners) {
+ for (const listener of listeners) {
+ await this._log.trace.complete(__INVOKE(listener), 'invoke', () => listener(event));
+ }
+ }
+ });
+ }
+}
diff --git a/src/emitters/SerialSyncEmitter.test.ts b/src/emitters/SerialSyncEmitter.test.ts
new file mode 100644
index 0000000..634b885
--- /dev/null
+++ b/src/emitters/SerialSyncEmitter.test.ts
@@ -0,0 +1,67 @@
+import { SerialSyncEmitter } from './SerialSyncEmitter';
+
+describe('SerialSyncEmitter', () => {
+ it('should emit events', () => {
+ const emitter = new SerialSyncEmitter('test-emitter');
+ const listener = jest.fn();
+ emitter.on('test', listener);
+
+ emitter.emit('test', { type: 'test', payload: 42 });
+ expect(listener).toHaveBeenCalledTimes(1);
+ expect(listener).toHaveBeenCalledWith({ type: 'test', payload: 42 });
+
+ emitter.emit('test', { type: 'test', payload: 84 });
+ expect(listener).toHaveBeenCalledTimes(2);
+ expect(listener).toHaveBeenCalledWith({ type: 'test', payload: 84 });
+ });
+
+ it('should allow subscribing to events only once', () => {
+ const emitter = new SerialSyncEmitter('test-emitter');
+ const listener = jest.fn();
+ emitter.once('test', listener);
+ emitter.emit('test', { type: 'test', payload: 42 });
+ expect(listener).toHaveBeenCalledWith({ type: 'test', payload: 42 });
+ emitter.emit('test', { type: 'test', payload: 84 });
+ expect(listener).toHaveBeenCalledTimes(1);
+ });
+
+ it('should allow subscribing to all events', () => {
+ const emitter = new SerialSyncEmitter('test-emitter');
+ const listener = jest.fn();
+ emitter.on('*', listener);
+ emitter.emit('test', { type: 'test', payload: 42 });
+ emitter.emit('misc', { type: 'misc', payload: 84 });
+
+ expect(listener).toHaveBeenCalledWith({ type: 'test', payload: 42 });
+ expect(listener).toHaveBeenCalledWith({ type: 'misc', payload: 84 });
+ expect(listener).toHaveBeenCalledTimes(2);
+ });
+
+ it('should allow unsubscribing from events', () => {
+ const emitter = new SerialSyncEmitter('test-emitter');
+ const listener = jest.fn();
+ emitter.on('test', listener);
+ emitter.emit('test', { type: 'test', payload: 42 });
+ expect(listener).toHaveBeenCalledTimes(1);
+ emitter.off('test', listener);
+ emitter.emit('test', { type: 'test', payload: 84 });
+ expect(listener).toHaveBeenCalledTimes(1);
+ });
+
+ it('should delay emits within emits', () => {
+ const emitter = new SerialSyncEmitter('test-emitter');
+ const listener1 = jest.fn(() => emitter.emit('test', { type: 'test', payload: 84 }));
+ const listener2 = jest.fn();
+ emitter.once('test', listener1);
+ emitter.on('test', listener2);
+ emitter.emit('test', { type: 'test', payload: 42 });
+ expect(listener2).toHaveBeenCalledTimes(2);
+ expect(listener2.mock.calls[0][0]).toEqual({ type: 'test', payload: 42 });
+ expect(listener2.mock.calls[1][0]).toEqual({ type: 'test', payload: 84 });
+ });
+});
+
+type TestEventMap = {
+ test: { type: 'test'; payload: number };
+ misc: { type: 'misc'; payload: number };
+};
diff --git a/src/emitters/SerialSyncEmitter.ts b/src/emitters/SerialSyncEmitter.ts
new file mode 100644
index 0000000..5a0c11c
--- /dev/null
+++ b/src/emitters/SerialSyncEmitter.ts
@@ -0,0 +1,39 @@
+import type { Emitter } from './Emitter';
+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: [keyof EventMap, EventMap[keyof EventMap]][] = [];
+
+ emit(nextEventType: K, nextEvent: EventMap[K]): void {
+ this.#queue.push([nextEventType, Object.freeze(nextEvent as any)]);
+
+ if (this.#queue.length > 1) {
+ this._log.trace(__ENQUEUE(nextEvent), `enqueue(${String(nextEventType)})`);
+ return;
+ }
+
+ while (this.#queue.length > 0) {
+ const [eventType, event] = this.#queue[0];
+ const listeners = [...this._getListeners(eventType)];
+
+ this._log.trace.complete(__EMIT(event), String(eventType), () => {
+ if (listeners) {
+ for (const listener of listeners) {
+ this._log.trace.complete(__INVOKE(listener), 'invoke', () => listener(event));
+ }
+ }
+ });
+
+ this.#queue.shift();
+ }
+ }
+}
diff --git a/src/emitters/index.ts b/src/emitters/index.ts
new file mode 100644
index 0000000..64153fb
--- /dev/null
+++ b/src/emitters/index.ts
@@ -0,0 +1,5 @@
+export * from './AsyncEmitter';
+export * from './Emitter';
+export * from './SerialAsyncEmitter';
+export * from './SerialSyncEmitter';
+export * from './SemiAsyncEmitter';
diff --git a/src/emitters/syncEmitterCommons.ts b/src/emitters/syncEmitterCommons.ts
new file mode 100644
index 0000000..1a6163b
--- /dev/null
+++ b/src/emitters/syncEmitterCommons.ts
@@ -0,0 +1,18 @@
+import { optimizeTracing } from '../utils';
+
+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/hooks.ts b/src/hooks.ts
new file mode 100644
index 0000000..fd633f6
--- /dev/null
+++ b/src/hooks.ts
@@ -0,0 +1,141 @@
+import type { EnvironmentContext, JestEnvironment, JestEnvironmentConfig } from '@jest/environment';
+import type { Circus } from '@jest/types';
+import { ResolvedEnvironmentListener, resolveSubscription } from './callbacks';
+import { SemiAsyncEmitter } from './emitters';
+import type {
+ EnvironmentListener,
+ EnvironmentListenerFn,
+ EnvironmentListenerContext,
+ TestEnvironmentAsyncEventMap,
+ TestEnvironmentSyncEventMap,
+ EnvironmentEventEmitter,
+} from './types';
+import { getHierarchy } from './utils';
+
+type EnvironmentEventEmitterImpl = SemiAsyncEmitter<
+ TestEnvironmentAsyncEventMap,
+ TestEnvironmentSyncEventMap
+>;
+
+type EnvironmentInternalContext = {
+ testEvents: EnvironmentEventEmitterImpl;
+ environmentConfig: JestEnvironmentConfig;
+ environmentContext: EnvironmentContext;
+};
+
+const contexts: WeakMap = new WeakMap();
+const staticListeners: WeakMap