From 927beb5fa077182fd0e2ea7f52b98f82881f69ed Mon Sep 17 00:00:00 2001 From: Yaroslav Serhieiev Date: Fri, 1 Dec 2023 18:55:49 +0200 Subject: [PATCH] feat: initial release (#4) --- .commitlintrc.js | 4 + .czrc | 3 + .editorconfig | 11 ++ .eslintignore | 3 + .eslintrc | 69 ++++++++++ .gitattributes | 1 + .github/workflows/ci.yml | 50 +++++++ .gitignore | 3 + .lintstagedrc.js | 3 + .npmrc | 2 + .nvmrc | 1 + .prettierrc | 10 ++ CODE_OF_CONDUCT.md | 128 +++++++++++++++++ CONTRIBUTING.md | 52 +++++++ README.md | 161 +++++++++++++++++++++- SECURITY.md | 47 +++++++ e2e/jest.config.js | 14 ++ e2e/listeners.cjs | 6 + e2e/listeners.mjs | 6 + index.js | 2 + jest.config.js | 13 ++ jsdom.js | 2 + node.js | 2 + package.json | 143 +++++++++++++++++++ src/__tests__/index.test.ts | 66 +++++++++ src/callbacks/index.ts | 1 + src/callbacks/requireModule.test.ts | 24 ++++ src/callbacks/requireModule.ts | 25 ++++ src/callbacks/resolveSubscription.test.ts | 20 +++ src/callbacks/resolveSubscription.ts | 41 ++++++ src/debug.ts | 1 + src/emitters/AsyncEmitter.ts | 20 +++ src/emitters/Emitter.ts | 17 +++ src/emitters/ReadonlyEmitterBase.ts | 95 +++++++++++++ src/emitters/SemiAsyncEmitter.test.ts | 44 ++++++ src/emitters/SemiAsyncEmitter.ts | 97 +++++++++++++ src/emitters/SerialAsyncEmitter.test.ts | 65 +++++++++ src/emitters/SerialAsyncEmitter.ts | 30 ++++ src/emitters/SerialSyncEmitter.test.ts | 67 +++++++++ src/emitters/SerialSyncEmitter.ts | 39 ++++++ src/emitters/index.ts | 5 + src/emitters/syncEmitterCommons.ts | 18 +++ src/hooks.ts | 141 +++++++++++++++++++ src/index.ts | 132 ++++++++++++++++++ src/jsdom.ts | 5 + src/node.ts | 5 + src/types.ts | 93 +++++++++++++ src/utils/__mocks__/logger.ts | 14 ++ src/utils/getHierarchy.test.ts | 41 ++++++ src/utils/getHierarchy.ts | 12 ++ src/utils/index.ts | 4 + src/utils/iterateSorted.test.ts | 23 ++++ src/utils/iterateSorted.ts | 42 ++++++ src/utils/logger.ts | 116 ++++++++++++++++ src/utils/makeDeferred.test.ts | 61 ++++++++ src/utils/makeDeferred.ts | 20 +++ src/utils/noop.ts | 5 + tsconfig.json | 29 ++++ typedoc.json | 6 + 59 files changed, 2158 insertions(+), 2 deletions(-) create mode 100644 .commitlintrc.js create mode 100644 .czrc create mode 100644 .editorconfig create mode 100644 .eslintignore create mode 100644 .eslintrc create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 .lintstagedrc.js create mode 100644 .npmrc create mode 100644 .nvmrc create mode 100644 .prettierrc create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 e2e/jest.config.js create mode 100644 e2e/listeners.cjs create mode 100644 e2e/listeners.mjs create mode 100644 index.js create mode 100644 jest.config.js create mode 100644 jsdom.js create mode 100644 node.js create mode 100644 package.json create mode 100644 src/__tests__/index.test.ts create mode 100644 src/callbacks/index.ts create mode 100644 src/callbacks/requireModule.test.ts create mode 100644 src/callbacks/requireModule.ts create mode 100644 src/callbacks/resolveSubscription.test.ts create mode 100644 src/callbacks/resolveSubscription.ts create mode 100644 src/debug.ts create mode 100644 src/emitters/AsyncEmitter.ts create mode 100644 src/emitters/Emitter.ts create mode 100644 src/emitters/ReadonlyEmitterBase.ts create mode 100644 src/emitters/SemiAsyncEmitter.test.ts create mode 100644 src/emitters/SemiAsyncEmitter.ts create mode 100644 src/emitters/SerialAsyncEmitter.test.ts create mode 100644 src/emitters/SerialAsyncEmitter.ts create mode 100644 src/emitters/SerialSyncEmitter.test.ts create mode 100644 src/emitters/SerialSyncEmitter.ts create mode 100644 src/emitters/index.ts create mode 100644 src/emitters/syncEmitterCommons.ts create mode 100644 src/hooks.ts create mode 100644 src/index.ts create mode 100644 src/jsdom.ts create mode 100644 src/node.ts create mode 100644 src/types.ts create mode 100644 src/utils/__mocks__/logger.ts create mode 100644 src/utils/getHierarchy.test.ts create mode 100644 src/utils/getHierarchy.ts create mode 100644 src/utils/index.ts create mode 100644 src/utils/iterateSorted.test.ts create mode 100644 src/utils/iterateSorted.ts create mode 100644 src/utils/logger.ts create mode 100644 src/utils/makeDeferred.test.ts create mode 100644 src/utils/makeDeferred.ts create mode 100644 src/utils/noop.ts create mode 100644 tsconfig.json create mode 100644 typedoc.json 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 = new WeakMap(); + +export function onTestEnvironmentCreate( + jestEnvironment: JestEnvironment, + jestEnvironmentConfig: JestEnvironmentConfig, + environmentContext: EnvironmentContext, +): void { + const testEvents = new SemiAsyncEmitter< + TestEnvironmentAsyncEventMap, + TestEnvironmentSyncEventMap + >('jest-environment-emit', [ + 'start_describe_definition', + 'finish_describe_definition', + 'add_hook', + 'add_test', + 'error', + ]); + + contexts.set(jestEnvironment, { + testEvents, + environmentConfig: normalizeJestEnvironmentConfig(jestEnvironmentConfig), + environmentContext, + }); +} + +export async function onTestEnvironmentSetup(env: JestEnvironment): Promise { + await subscribeToEvents(env); + await getContext(env).testEvents.emit('test_environment_setup', { + type: 'test_environment_setup', + env, + }); +} + +export const onHandleTestEvent = ( + env: JestEnvironment, + event: Circus.Event, + state: Circus.State, +): void | Promise => + getContext(env).testEvents.emit(event.name as any, { type: event.name, env, event, state }); + +export async function onTestEnvironmentTeardown(env: JestEnvironment): Promise { + await getContext(env).testEvents.emit('test_environment_teardown', { + type: 'test_environment_teardown', + env, + }); +} + +export const registerSubscription = ( + // eslint-disable-next-line @typescript-eslint/ban-types + klass: Function, + callback: EnvironmentListenerFn, +) => { + const callbacks = staticListeners.get(klass) ?? []; + callbacks.push(callback as EnvironmentListenerFn); + staticListeners.set(klass, callbacks); +}; + +async function subscribeToEvents(env: JestEnvironment) { + const envConfig = getContext(env).environmentConfig; + const { projectConfig } = envConfig; + const testEnvironmentOptions = projectConfig.testEnvironmentOptions; + const staticRegistrations = collectStaticRegistrations(env); + const configRegistrationsRaw = (testEnvironmentOptions.eventListeners ?? + []) as EnvironmentListener[]; + const configRegistrations = await Promise.all( + configRegistrationsRaw.map((r) => resolveSubscription(projectConfig.rootDir, r)), + ); + + const context = getCallbackContext(env); + + for (const [callback, options] of [...staticRegistrations, ...configRegistrations]) { + await callback(context, options); + } +} + +function getContext(env: JestEnvironment): EnvironmentInternalContext { + const memo = contexts.get(env); + if (!memo) { + throw new Error( + 'Environment context is not found. Most likely, you are using a non-valid environment reference.', + ); + } + + return memo; +} + +export function getEmitter(env: JestEnvironment): EnvironmentEventEmitter { + return getContext(env).testEvents as EnvironmentEventEmitter; +} + +function getCallbackContext(env: JestEnvironment): EnvironmentListenerContext { + const memo = getContext(env); + + return Object.freeze({ + env, + testEvents: memo.testEvents, + context: memo.environmentContext, + config: memo.environmentConfig, + }); +} + +/** Jest 27 legacy support */ +function normalizeJestEnvironmentConfig(jestEnvironmentConfig: JestEnvironmentConfig) { + return jestEnvironmentConfig.globalConfig + ? jestEnvironmentConfig + : ({ projectConfig: jestEnvironmentConfig as unknown } as JestEnvironmentConfig); +} + +function collectStaticRegistrations( + env: E, +): ResolvedEnvironmentListener[] { + return getHierarchy(env) + .flatMap((klass) => staticListeners.get(klass) ?? []) + .map((callback) => [callback, void 0]); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..001c73b --- /dev/null +++ b/src/index.ts @@ -0,0 +1,132 @@ +import type { JestEnvironment } from '@jest/environment'; +import type { Circus } from '@jest/types'; + +import { + getEmitter, + onHandleTestEvent, + onTestEnvironmentCreate, + onTestEnvironmentSetup, + onTestEnvironmentTeardown, + registerSubscription, +} from './hooks'; + +import type { EnvironmentListener, EnvironmentListenerFn, EnvironmentEventEmitter } from './types'; + +export * from './types'; + +/** + * 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 an extensible way. + * + * You won't need to extend this class directly – instead, you can tap into + * the lifecycle events by registering listeners in `testEnvironmentOptions.eventListeners` + * property in your Jest configuration file. + * + * Even a less intrusive way to tap into the lifecycle events is to use the + * static `register` method, which accepts a callback that will be invoked + * with the decorated Jest environment instance, the emitter, and the + * environment context. + * + * @param JestEnvironmentClass - Jest environment subclass to decorate + * @returns a decorated Jest environment subclass, e.g. `WithMetadata(JestEnvironmentNode)` + * @example + * ```javascript + * import { WithEmitter } from 'jest-environment-emit'; + * import JestEnvironmentNode from 'jest-environment-node'; + * + * export const TestEnvironment = WithEmitter(JestEnvironmentNode, { + * test_environment_setup: async () => {}, + * test_environment_teardown: async () => {}, + * setup: async () => {}, + * teardown: async () => {}, + * add_hook: () => {}, + * add_test: () => {}, + * error: () => {}, + * test_fn_start: async () => {}, + * test_fn_success: async () => {}, + * test_fn_failure: async () => {}, + * }, 'WithMyListeners'); // > class WithMyListeners(JestEnvironmentNode) {} + * + * export const AdvancedTestEnvironment = TestEnvironment.derive('WithMoreListeners', { + * test_environment_setup: async () => {}, + * }); + */ + +export function WithEmitter( + JestEnvironmentClass: new (...args: any[]) => E, + callback?: EnvironmentListenerFn, + MixinName = 'WithEmitter', +): WithEmitterClass { + const BaseClassName = JestEnvironmentClass.name; + const CompositeClassName = `${MixinName}(${BaseClassName})`; + const ClassWithEmitter = { + // @ts-expect-error TS2415: Class '[`${compositeName}`]' incorrectly extends base class 'E'. + [`${CompositeClassName}`]: class extends JestEnvironmentClass { + readonly testEvents: EnvironmentEventEmitter; + + constructor(...args: any[]) { + super(...args); + onTestEnvironmentCreate(this, args[0], args[1]); + this.testEvents = getEmitter(this); + } + + static derive( + callback: EnvironmentListenerFn, + DerivedMixinName = MixinName, + ): WithEmitterClass { + const derivedName = `${DerivedMixinName}(${BaseClassName})`; + const resultClass = { + [`${derivedName}`]: class extends ClassWithEmitter {}, + }[derivedName]; + registerSubscription(resultClass, callback); + return resultClass; + } + + 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); + } + }, + }[CompositeClassName] as unknown as WithEmitterClass; + + if (callback) { + registerSubscription(ClassWithEmitter, callback); + } + + return ClassWithEmitter; +} + +export type WithTestEvents = E & { + readonly testEvents: EnvironmentEventEmitter; + handleTestEvent: Circus.EventHandler; +}; + +export type WithEmitterClass = (new ( + ...args: any[] +) => WithTestEvents) & { + derive(callback: EnvironmentListener, ClassName?: string): WithEmitterClass; +}; + +/** + * @inheritDoc + */ +export default WithEmitter; diff --git a/src/jsdom.ts b/src/jsdom.ts new file mode 100644 index 0000000..f6283ac --- /dev/null +++ b/src/jsdom.ts @@ -0,0 +1,5 @@ +import JestEnvironmentJsdom from 'jest-environment-jsdom'; +import { WithEmitter } from './index'; + +export const TestEnvironment = WithEmitter(JestEnvironmentJsdom); +export default TestEnvironment; diff --git a/src/node.ts b/src/node.ts new file mode 100644 index 0000000..c3e6a74 --- /dev/null +++ b/src/node.ts @@ -0,0 +1,5 @@ +import JestEnvironmentNode from 'jest-environment-node'; +import { WithEmitter } from './index'; + +export const TestEnvironment = WithEmitter(JestEnvironmentNode); +export default TestEnvironment; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..7d32c1f --- /dev/null +++ b/src/types.ts @@ -0,0 +1,93 @@ +import type { EnvironmentContext, JestEnvironment, JestEnvironmentConfig } from '@jest/environment'; +import type { Circus } from '@jest/types'; +import type { ReadonlySemiAsyncEmitter } from './emitters'; + +export type TestEnvironmentEvent = + | TestEnvironmentSetupEvent + | TestEnvironmentTeardownEvent + | TestEnvironmentCircusEvent; + +export type TestEnvironmentSetupEvent = { + type: 'test_environment_setup'; + env: JestEnvironment; +}; + +export type TestEnvironmentTeardownEvent = { + type: 'test_environment_teardown'; + env: JestEnvironment; +}; + +export type TestEnvironmentCircusEvent = { + type: E['name']; + env: JestEnvironment; + event: E; + state: Circus.State; +}; + +export type TestEnvironmentSyncEventMap = { + add_hook: TestEnvironmentCircusEvent; + add_test: TestEnvironmentCircusEvent; + error: TestEnvironmentCircusEvent; + finish_describe_definition: TestEnvironmentCircusEvent< + Circus.Event & { name: 'finish_describe_definition' } + >; + start_describe_definition: TestEnvironmentCircusEvent< + Circus.Event & { name: 'start_describe_definition' } + >; +}; + +export type TestEnvironmentAsyncEventMap = { + hook_failure: TestEnvironmentCircusEvent; + hook_start: TestEnvironmentCircusEvent; + hook_success: TestEnvironmentCircusEvent; + include_test_location_in_result: TestEnvironmentCircusEvent< + Circus.Event & { name: 'include_test_location_in_result' } + >; + run_describe_finish: TestEnvironmentCircusEvent; + run_describe_start: TestEnvironmentCircusEvent; + run_finish: TestEnvironmentCircusEvent; + run_start: TestEnvironmentCircusEvent; + setup: TestEnvironmentCircusEvent; + teardown: TestEnvironmentCircusEvent; + test_done: TestEnvironmentCircusEvent; + test_environment_setup: TestEnvironmentSetupEvent; + test_environment_teardown: TestEnvironmentTeardownEvent; + test_fn_failure: TestEnvironmentCircusEvent; + test_fn_start: TestEnvironmentCircusEvent; + test_fn_success: TestEnvironmentCircusEvent; + test_retry: TestEnvironmentCircusEvent; + test_skip: TestEnvironmentCircusEvent; + test_start: TestEnvironmentCircusEvent; + test_started: TestEnvironmentCircusEvent; + test_todo: TestEnvironmentCircusEvent; +}; + +export type EnvironmentListener = + | EnvironmentListenerWithOptions + | EnvironmentListenerOnly; + +export type EnvironmentListenerWithOptions = [ + EnvironmentListenerOnly, + any, +]; + +export type EnvironmentListenerOnly = + | EnvironmentListenerFn + | string; + +export type EnvironmentListenerFn = ( + context: Readonly>, + listenerConfig?: any, +) => void; + +export type EnvironmentEventEmitter = ReadonlySemiAsyncEmitter< + TestEnvironmentAsyncEventMap, + TestEnvironmentSyncEventMap +>; + +export type EnvironmentListenerContext = { + env: E; + testEvents: EnvironmentEventEmitter; + context: EnvironmentContext; + config: JestEnvironmentConfig; +}; diff --git a/src/utils/__mocks__/logger.ts b/src/utils/__mocks__/logger.ts new file mode 100644 index 0000000..b1b447f --- /dev/null +++ b/src/utils/__mocks__/logger.ts @@ -0,0 +1,14 @@ +const { bunyamin } = jest.requireActual('bunyamin'); + +bunyamin.useLogger({ + fatal: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + trace: jest.fn(), +}); + +const optimizeTracing = jest.fn((f) => f); + +export { bunyamin as logger, optimizeTracing }; diff --git a/src/utils/getHierarchy.test.ts b/src/utils/getHierarchy.test.ts new file mode 100644 index 0000000..0b446d6 --- /dev/null +++ b/src/utils/getHierarchy.test.ts @@ -0,0 +1,41 @@ +import { getHierarchy } from './getHierarchy'; + +describe('getHierarchy', () => { + let map: Map; + + beforeEach(() => { + map = new Map(); + }); + + class A { + static push(value: string) { + const arr = map.get(this) ?? []; + arr.push(value); + map.set(this, arr); + } + + foo() {} + } + + class B extends A {} + class C extends B {} + + it('should return the hierarchy of a class', () => { + const a = new A(); + const b = new B(); + const c = new C(); + const anyProto = Object.getPrototypeOf(class {}); + + A.push('A'); + B.push('B'); + C.push('C'); + + expect(map.get(A)).toEqual(['A']); + expect(map.get(B)).toEqual(['B']); + expect(map.get(C)).toEqual(['C']); + + expect(getHierarchy(a)).toEqual([anyProto, A]); + expect(getHierarchy(b)).toEqual([anyProto, A, B]); + expect(getHierarchy(c)).toEqual([anyProto, A, B, C]); + }); +}); diff --git a/src/utils/getHierarchy.ts b/src/utils/getHierarchy.ts new file mode 100644 index 0000000..cd60557 --- /dev/null +++ b/src/utils/getHierarchy.ts @@ -0,0 +1,12 @@ +/* eslint-disable @typescript-eslint/ban-types */ +export function getHierarchy(instance: any): Function[] { + const hierarchy: Function[] = []; + let currentClass = instance?.constructor; + + while (typeof currentClass === 'function') { + hierarchy.push(currentClass); + currentClass = Object.getPrototypeOf(currentClass); + } + + return hierarchy.reverse(); +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..4dc2546 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,4 @@ +export * from './getHierarchy'; +export * from './iterateSorted'; +export { makeDeferred, Deferred } from './makeDeferred'; +export * from './logger'; diff --git a/src/utils/iterateSorted.test.ts b/src/utils/iterateSorted.test.ts new file mode 100644 index 0000000..1e8e69c --- /dev/null +++ b/src/utils/iterateSorted.test.ts @@ -0,0 +1,23 @@ +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 new file mode 100644 index 0000000..8b6a80a --- /dev/null +++ b/src/utils/iterateSorted.ts @@ -0,0 +1,42 @@ +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 new file mode 100644 index 0000000..4f6547f --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,116 @@ +import fs from 'fs'; +import path from 'path'; +import { + bunyamin, + isDebug, + threadGroups, + traceEventStream, + uniteTraceEventsToFile, +} from 'bunyamin'; +import { createLogger } from 'bunyan'; +import createDebugStream from 'bunyan-debug-stream'; +import { noop } from './noop'; + +const logsDirectory = process.env.JEST_BUNYAMIN_DIR; +const LOG_PATTERN = /^jest-bunyamin\..*\.log$/; +const PACKAGE_NAME = 'jest-enviroment-emit' as const; + +function isTraceEnabled(): boolean { + return !!logsDirectory; +} + +function createLogFilePath() { + const suffix = process.env.JEST_WORKER_ID ? `_${process.env.JEST_WORKER_ID}` : ''; + let counter = 0; + let filePath = ''; + + do { + // modules are re-initialized for each test file, + // so we cannot check once and cache the result + filePath = path.join( + process.env.JEST_BUNYAMIN_DIR!, + `jest-bunyamin.${process.pid}${suffix}${counter-- || '-0'}.log`, + ); + } while (fs.existsSync(filePath)); + + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + return filePath; +} + +function createBunyanImpl(traceEnabled: boolean) { + const label = process.env.JEST_WORKER_ID ? `Worker ${process.env.JEST_WORKER_ID}` : 'Main'; + const bunyan = createLogger({ + name: `jest (${label})`, + streams: [ + { + type: 'raw', + level: 'warn' as const, + stream: createDebugStream({ + out: process.stderr, + showMetadata: false, + showDate: false, + showPid: false, + showProcess: false, + showLoggerName: false, + showLevel: false, + prefixers: { + cat: (value) => String(value).split(',', 1)[0], + }, + }), + }, + ...(traceEnabled + ? [ + { + type: 'raw', + level: 'trace' as const, + stream: traceEventStream({ + filePath: createLogFilePath(), + threadGroups: bunyamin.threadGroups, + }), + }, + ] + : []), + ], + }); + + return bunyan; +} + +export async function aggregateLogs() { + const root = logsDirectory; + if (!root) { + return; + } + + const unitedLogPath = path.join(root, 'jest-bunyamin.log'); + if (fs.existsSync(unitedLogPath)) { + fs.rmSync(unitedLogPath); + } + + const logs = fs + .readdirSync(root) + .filter((x) => LOG_PATTERN.test(x)) + .map((x) => path.join(root, x)); + + if (logs.length > 1) { + await uniteTraceEventsToFile(logs, unitedLogPath); + for (const x of logs) fs.rmSync(x); + } else { + fs.renameSync(logs[0], unitedLogPath); + } +} + +threadGroups.add({ + id: PACKAGE_NAME, + displayName: PACKAGE_NAME, +}); + +bunyamin.useLogger(createBunyanImpl(isTraceEnabled()), 1); + +export const logger = bunyamin.child({ + cat: PACKAGE_NAME, +}); + +export const optimizeTracing: (f: F) => F = isDebug(PACKAGE_NAME) + ? (f) => f + : ((() => noop) as any); diff --git a/src/utils/makeDeferred.test.ts b/src/utils/makeDeferred.test.ts new file mode 100644 index 0000000..029e69f --- /dev/null +++ b/src/utils/makeDeferred.test.ts @@ -0,0 +1,61 @@ +import { makeDeferred } from './makeDeferred'; + +describe('makeDeferred', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + it('should return an object with promise, resolve, and reject properties', () => { + const deferred = makeDeferred(); + expect(deferred).toHaveProperty('promise'); + expect(deferred).toHaveProperty('resolve'); + expect(deferred).toHaveProperty('reject'); + }); + + it('should resolve the promise when the resolve function is called', async () => { + let resolvedValue: string | undefined; + const deferred = makeDeferred(); + + setTimeout(() => deferred.resolve('resolved value'), 1000); + + deferred.promise.then((value) => { + resolvedValue = value; + }); + + expect(resolvedValue).toBeUndefined(); // The promise should not have resolved yet + + jest.runAllTimers(); // Advance all timers + await deferred.promise; // Wait for the promise to resolve + + expect(resolvedValue).toBe('resolved value'); + }); + + it('should reject the promise when the reject function is called', async () => { + let errorValue: Error | undefined; + const deferred = makeDeferred(); + + setTimeout(() => deferred.reject(new Error('rejection reason')), 1000); + + deferred.promise.catch((error) => { + errorValue = error; + }); + + expect(errorValue).toBeUndefined(); // The promise should not have rejected yet + + jest.runAllTimers(); // Advance all timers + try { + await deferred.promise; + } catch (error: any) { + // Expect the rejection + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe('rejection reason'); + } + + expect(errorValue).toBeInstanceOf(Error); + expect(errorValue?.message).toBe('rejection reason'); + }); +}); diff --git a/src/utils/makeDeferred.ts b/src/utils/makeDeferred.ts new file mode 100644 index 0000000..72a8a59 --- /dev/null +++ b/src/utils/makeDeferred.ts @@ -0,0 +1,20 @@ +export function makeDeferred(): Deferred { + let resolve: (value: T) => void; + let reject: (reason?: unknown) => void; + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + + return { + promise: promise, + resolve: resolve!, + reject: reject!, + }; +} + +export type Deferred = { + promise: Promise; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +}; diff --git a/src/utils/noop.ts b/src/utils/noop.ts new file mode 100644 index 0000000..b662148 --- /dev/null +++ b/src/utils/noop.ts @@ -0,0 +1,5 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +const empty = {}; +export const noop: (...args: any[]) => any = () => { + return empty; +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a044e6a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "node16", + "declaration": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "importHelpers": true, + "importsNotUsedAsValues": "error", + "ignoreDeprecations": "5.0", + "strict": true, + "esModuleInterop": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/typedoc.json b/typedoc.json new file mode 100644 index 0000000..e25cf98 --- /dev/null +++ b/typedoc.json @@ -0,0 +1,6 @@ +{ + "entryPoints": ["src/index.ts"], + "hideGenerator": true, + "name": "@wix-incubator/jest-environment-emit", + "out": "../../website" +}