├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .prettierignore ├── .releaserc.json ├── .vscode └── settings.json ├── .yarnrc.yml ├── LICENSE ├── README.md ├── api-extractor.json ├── ava.config.mjs ├── benchmark.api.md ├── contributing.md ├── diagram.svg ├── examples ├── basic │ └── index.ts └── threads │ ├── index.ts │ └── suites │ └── substring.ts ├── package.json ├── renovate.json ├── src ├── benchmark.ts ├── index.ts ├── suite.ts ├── test.ts ├── thread-worker.ts ├── thread.ts ├── types │ ├── index.ts │ └── thread-worker.ts └── utils.ts ├── test ├── benchmark │ ├── add-suite.test.ts │ ├── fixtures │ │ └── suites │ │ │ ├── empty.ts │ │ │ └── regular.ts │ └── run-suites.test.ts ├── suite │ ├── add-test.test.ts │ ├── constructor.test.ts │ └── run.test.ts ├── test │ └── run.test.ts ├── thread │ ├── fixtures │ │ └── suites │ │ │ ├── broken.ts │ │ │ ├── empty.ts │ │ │ ├── invalid.ts │ │ │ └── regular.ts │ ├── init.test.ts │ └── run.test.ts └── utils │ ├── abort-error.test.ts │ ├── compatible-import.test.ts │ └── fixtures │ └── esm.ts ├── tsconfig.json ├── typedoc.json ├── xo.config.mjs └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.yml] 12 | indent_style = space 13 | 14 | [*.yaml] 15 | indent_style = space 16 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.yarn/releases/** binary 2 | /.yarn/plugins/** binary 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | name: Build 8 | 9 | runs-on: ubuntu-latest 10 | timeout-minutes: 5 11 | 12 | steps: 13 | - name: Checkout Git repository 14 | uses: actions/checkout@v4 15 | - name: Enable Corepack 16 | # Required due to a limitation in setup-node https://github.com/actions/setup-node/issues/480#issuecomment-1820622085 17 | run: corepack enable 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: 'lts/*' 22 | cache: 'yarn' 23 | - name: Install dependencies 24 | run: yarn install --immutable 25 | - name: Build 26 | run: yarn run build 27 | - name: Upload artifacts 28 | uses: actions/upload-artifact@v4 29 | with: 30 | name: dist 31 | path: tsc_output 32 | lint: 33 | name: Lint 34 | 35 | runs-on: ubuntu-latest 36 | timeout-minutes: 5 37 | 38 | steps: 39 | - name: Checkout Git repository 40 | uses: actions/checkout@v4 41 | - name: Enable Corepack 42 | # Required due to a limitation in setup-node https://github.com/actions/setup-node/issues/480#issuecomment-1820622085 43 | run: corepack enable 44 | - name: Setup Node.js 45 | uses: actions/setup-node@v4 46 | with: 47 | node-version: 'lts/*' 48 | cache: 'yarn' 49 | - name: Install dependencies 50 | run: yarn install --immutable 51 | - name: Lint 52 | run: yarn run lint 53 | style: 54 | name: Check style 55 | 56 | runs-on: ubuntu-latest 57 | timeout-minutes: 5 58 | 59 | steps: 60 | - name: Checkout Git repository 61 | uses: actions/checkout@v4 62 | - name: Enable Corepack 63 | # Required due to a limitation in setup-node https://github.com/actions/setup-node/issues/480#issuecomment-1820622085 64 | run: corepack enable 65 | - name: Setup Node.js 66 | uses: actions/setup-node@v4 67 | with: 68 | node-version: 'lts/*' 69 | cache: 'yarn' 70 | - name: Install dependencies 71 | run: yarn install --immutable 72 | - name: Check style 73 | run: yarn run style 74 | test: 75 | name: Run unit tests 76 | 77 | runs-on: ubuntu-latest 78 | timeout-minutes: 5 79 | 80 | steps: 81 | - name: Checkout Git repository 82 | uses: actions/checkout@v4 83 | - name: Enable Corepack 84 | # Required due to a limitation in setup-node https://github.com/actions/setup-node/issues/480#issuecomment-1820622085 85 | run: corepack enable 86 | - name: Setup Node.js 87 | uses: actions/setup-node@v4 88 | with: 89 | node-version: 'lts/*' 90 | cache: 'yarn' 91 | - name: Install dependencies 92 | run: yarn install --immutable 93 | - name: Build package 94 | run: yarn run build 95 | - name: Run tests 96 | run: yarn run test:coverage 97 | - name: Submit test coverage 98 | uses: codecov/codecov-action@v4 99 | with: 100 | token: ${{ secrets.CODECOV_TOKEN }} 101 | files: ./coverage/lcov.info 102 | fail_ci_if_error: true 103 | validate-api: 104 | name: Validate API 105 | 106 | runs-on: ubuntu-latest 107 | timeout-minutes: 5 108 | 109 | steps: 110 | - name: Checkout Git repository 111 | uses: actions/checkout@v4 112 | - name: Enable Corepack 113 | # Required due to a limitation in setup-node https://github.com/actions/setup-node/issues/480#issuecomment-1820622085 114 | run: corepack enable 115 | - name: Setup Node.js 116 | uses: actions/setup-node@v4 117 | with: 118 | node-version: 'lts/*' 119 | cache: 'yarn' 120 | - name: Install dependencies 121 | run: yarn install --immutable 122 | - name: Build package 123 | run: yarn run build 124 | - name: Validate API 125 | run: yarn run validate-api 126 | publish: 127 | name: Publish 128 | 129 | runs-on: ubuntu-latest 130 | timeout-minutes: 15 131 | 132 | if: ${{ github.ref == 'refs/heads/main' }} 133 | 134 | needs: [test, validate-api, style, lint] 135 | 136 | steps: 137 | - name: Checkout Git repository 138 | uses: actions/checkout@v4 139 | - name: Enable Corepack 140 | # Required due to a limitation in setup-node https://github.com/actions/setup-node/issues/480#issuecomment-1820622085 141 | run: corepack enable 142 | - name: Setup Node.js 143 | uses: actions/setup-node@v4 144 | with: 145 | node-version: 'lts/*' 146 | cache: 'yarn' 147 | - name: Install dependencies 148 | run: yarn install --immutable 149 | - name: Build 150 | run: yarn run build 151 | - name: Generate TSDoc metadata 152 | run: yarn run validate-api 153 | - name: Run release 154 | run: yarn run release 155 | env: 156 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 157 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 158 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/node,linux,macos,windows,visualstudiocode 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=node,linux,macos,windows,visualstudiocode 3 | 4 | ### Linux ### 5 | *~ 6 | 7 | # temporary files which can be created if a process still has a handle open of a deleted file 8 | .fuse_hidden* 9 | 10 | # KDE directory preferences 11 | .directory 12 | 13 | # Linux trash folder which might appear on any partition or disk 14 | .Trash-* 15 | 16 | # .nfs files are created when an open file is removed but is still being accessed 17 | .nfs* 18 | 19 | ### macOS ### 20 | # General 21 | .DS_Store 22 | .AppleDouble 23 | .LSOverride 24 | 25 | # Icon must end with two \r 26 | Icon 27 | 28 | 29 | # Thumbnails 30 | ._* 31 | 32 | # Files that might appear in the root of a volume 33 | .DocumentRevisions-V100 34 | .fseventsd 35 | .Spotlight-V100 36 | .TemporaryItems 37 | .Trashes 38 | .VolumeIcon.icns 39 | .com.apple.timemachine.donotpresent 40 | 41 | # Directories potentially created on remote AFP share 42 | .AppleDB 43 | .AppleDesktop 44 | Network Trash Folder 45 | Temporary Items 46 | .apdisk 47 | 48 | ### Node ### 49 | # Logs 50 | logs 51 | *.log 52 | npm-debug.log* 53 | yarn-debug.log* 54 | yarn-error.log* 55 | lerna-debug.log* 56 | .pnpm-debug.log* 57 | 58 | # Diagnostic reports (https://nodejs.org/api/report.html) 59 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 60 | 61 | # Runtime data 62 | pids 63 | *.pid 64 | *.seed 65 | *.pid.lock 66 | 67 | # Directory for instrumented libs generated by jscoverage/JSCover 68 | lib-cov 69 | 70 | # Coverage directory used by tools like istanbul 71 | coverage 72 | *.lcov 73 | 74 | # nyc test coverage 75 | .nyc_output 76 | 77 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 78 | .grunt 79 | 80 | # Bower dependency directory (https://bower.io/) 81 | bower_components 82 | 83 | # node-waf configuration 84 | .lock-wscript 85 | 86 | # Compiled binary addons (https://nodejs.org/api/addons.html) 87 | build/Release 88 | 89 | # Dependency directories 90 | node_modules/ 91 | jspm_packages/ 92 | 93 | # Snowpack dependency directory (https://snowpack.dev/) 94 | web_modules/ 95 | 96 | # TypeScript cache 97 | *.tsbuildinfo 98 | 99 | # Optional npm cache directory 100 | .npm 101 | 102 | # Optional eslint cache 103 | .eslintcache 104 | 105 | # Microbundle cache 106 | .rpt2_cache/ 107 | .rts2_cache_cjs/ 108 | .rts2_cache_es/ 109 | .rts2_cache_umd/ 110 | 111 | # Optional REPL history 112 | .node_repl_history 113 | 114 | # Output of 'npm pack' 115 | *.tgz 116 | 117 | # Yarn Integrity file 118 | .yarn-integrity 119 | 120 | # dotenv environment variables file 121 | .env 122 | .env.test 123 | .env.production 124 | 125 | # parcel-bundler cache (https://parceljs.org/) 126 | .cache 127 | .parcel-cache 128 | 129 | # Next.js build output 130 | .next 131 | out 132 | 133 | # Nuxt.js build / generate output 134 | .nuxt 135 | dist 136 | 137 | # Gatsby files 138 | .cache/ 139 | # Comment in the public line in if your project uses Gatsby and not Next.js 140 | # https://nextjs.org/blog/next-9-1#public-directory-support 141 | # public 142 | 143 | # vuepress build output 144 | .vuepress/dist 145 | 146 | # Serverless directories 147 | .serverless/ 148 | 149 | # FuseBox cache 150 | .fusebox/ 151 | 152 | # DynamoDB Local files 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | .tern-port 157 | 158 | # Stores VSCode versions used for testing VSCode extensions 159 | .vscode-test 160 | 161 | # yarn v2 162 | .yarn/cache 163 | .yarn/unplugged 164 | .yarn/build-state.yml 165 | .yarn/install-state.gz 166 | .pnp.* 167 | 168 | ### Node Patch ### 169 | # Serverless Webpack directories 170 | .webpack/ 171 | 172 | ### VisualStudioCode ### 173 | .vscode/* 174 | !.vscode/settings.json 175 | !.vscode/tasks.json 176 | !.vscode/launch.json 177 | !.vscode/extensions.json 178 | *.code-workspace 179 | 180 | # Local History for Visual Studio Code 181 | .history/ 182 | 183 | ### VisualStudioCode Patch ### 184 | # Ignore all local history of files 185 | .history 186 | .ionide 187 | 188 | ### Windows ### 189 | # Windows thumbnail cache files 190 | Thumbs.db 191 | Thumbs.db:encryptable 192 | ehthumbs.db 193 | ehthumbs_vista.db 194 | 195 | # Dump file 196 | *.stackdump 197 | 198 | # Folder config file 199 | [Dd]esktop.ini 200 | 201 | # Recycle Bin used on file shares 202 | $RECYCLE.BIN/ 203 | 204 | # Windows Installer files 205 | *.cab 206 | *.msi 207 | *.msix 208 | *.msm 209 | *.msp 210 | 211 | # Windows shortcuts 212 | *.lnk 213 | 214 | # End of https://www.toptal.com/developers/gitignore/api/node,linux,macos,windows,visualstudiocode 215 | 216 | # TypeScript compiler output 217 | tsc_* 218 | 219 | # Yarn 220 | .pnp.* 221 | .yarn/* 222 | !.yarn/patches 223 | !.yarn/plugins 224 | !.yarn/releases 225 | !.yarn/sdks 226 | !.yarn/versions 227 | 228 | # API Extractor 229 | temp 230 | 231 | # TypeDoc output 232 | docs_out 233 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/node,linux,macos,windows,visualstudiocode 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=node,linux,macos,windows,visualstudiocode 3 | 4 | ### Linux ### 5 | *~ 6 | 7 | # temporary files which can be created if a process still has a handle open of a deleted file 8 | .fuse_hidden* 9 | 10 | # KDE directory preferences 11 | .directory 12 | 13 | # Linux trash folder which might appear on any partition or disk 14 | .Trash-* 15 | 16 | # .nfs files are created when an open file is removed but is still being accessed 17 | .nfs* 18 | 19 | ### macOS ### 20 | # General 21 | .DS_Store 22 | .AppleDouble 23 | .LSOverride 24 | 25 | # Icon must end with two \r 26 | Icon 27 | 28 | 29 | # Thumbnails 30 | ._* 31 | 32 | # Files that might appear in the root of a volume 33 | .DocumentRevisions-V100 34 | .fseventsd 35 | .Spotlight-V100 36 | .TemporaryItems 37 | .Trashes 38 | .VolumeIcon.icns 39 | .com.apple.timemachine.donotpresent 40 | 41 | # Directories potentially created on remote AFP share 42 | .AppleDB 43 | .AppleDesktop 44 | Network Trash Folder 45 | Temporary Items 46 | .apdisk 47 | 48 | ### Node ### 49 | # Logs 50 | logs 51 | *.log 52 | npm-debug.log* 53 | yarn-debug.log* 54 | yarn-error.log* 55 | lerna-debug.log* 56 | .pnpm-debug.log* 57 | 58 | # Diagnostic reports (https://nodejs.org/api/report.html) 59 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 60 | 61 | # Runtime data 62 | pids 63 | *.pid 64 | *.seed 65 | *.pid.lock 66 | 67 | # Directory for instrumented libs generated by jscoverage/JSCover 68 | lib-cov 69 | 70 | # Coverage directory used by tools like istanbul 71 | coverage 72 | *.lcov 73 | 74 | # nyc test coverage 75 | .nyc_output 76 | 77 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 78 | .grunt 79 | 80 | # Bower dependency directory (https://bower.io/) 81 | bower_components 82 | 83 | # node-waf configuration 84 | .lock-wscript 85 | 86 | # Compiled binary addons (https://nodejs.org/api/addons.html) 87 | build/Release 88 | 89 | # Dependency directories 90 | node_modules/ 91 | jspm_packages/ 92 | 93 | # Snowpack dependency directory (https://snowpack.dev/) 94 | web_modules/ 95 | 96 | # TypeScript cache 97 | *.tsbuildinfo 98 | 99 | # Optional npm cache directory 100 | .npm 101 | 102 | # Optional eslint cache 103 | .eslintcache 104 | 105 | # Microbundle cache 106 | .rpt2_cache/ 107 | .rts2_cache_cjs/ 108 | .rts2_cache_es/ 109 | .rts2_cache_umd/ 110 | 111 | # Optional REPL history 112 | .node_repl_history 113 | 114 | # Output of 'npm pack' 115 | *.tgz 116 | 117 | # Yarn Integrity file 118 | .yarn-integrity 119 | 120 | # dotenv environment variables file 121 | .env 122 | .env.test 123 | .env.production 124 | 125 | # parcel-bundler cache (https://parceljs.org/) 126 | .cache 127 | .parcel-cache 128 | 129 | # Next.js build output 130 | .next 131 | out 132 | 133 | # Nuxt.js build / generate output 134 | .nuxt 135 | dist 136 | 137 | # Gatsby files 138 | .cache/ 139 | # Comment in the public line in if your project uses Gatsby and not Next.js 140 | # https://nextjs.org/blog/next-9-1#public-directory-support 141 | # public 142 | 143 | # vuepress build output 144 | .vuepress/dist 145 | 146 | # Serverless directories 147 | .serverless/ 148 | 149 | # FuseBox cache 150 | .fusebox/ 151 | 152 | # DynamoDB Local files 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | .tern-port 157 | 158 | # Stores VSCode versions used for testing VSCode extensions 159 | .vscode-test 160 | 161 | # yarn v2 162 | .yarn/cache 163 | .yarn/unplugged 164 | .yarn/build-state.yml 165 | .yarn/install-state.gz 166 | .pnp.* 167 | 168 | ### Node Patch ### 169 | # Serverless Webpack directories 170 | .webpack/ 171 | 172 | ### VisualStudioCode ### 173 | .vscode/* 174 | !.vscode/settings.json 175 | !.vscode/tasks.json 176 | !.vscode/launch.json 177 | !.vscode/extensions.json 178 | *.code-workspace 179 | 180 | # Local History for Visual Studio Code 181 | .history/ 182 | 183 | ### VisualStudioCode Patch ### 184 | # Ignore all local history of files 185 | .history 186 | .ionide 187 | 188 | ### Windows ### 189 | # Windows thumbnail cache files 190 | Thumbs.db 191 | Thumbs.db:encryptable 192 | ehthumbs.db 193 | ehthumbs_vista.db 194 | 195 | # Dump file 196 | *.stackdump 197 | 198 | # Folder config file 199 | [Dd]esktop.ini 200 | 201 | # Recycle Bin used on file shares 202 | $RECYCLE.BIN/ 203 | 204 | # Windows Installer files 205 | *.cab 206 | *.msi 207 | *.msix 208 | *.msm 209 | *.msp 210 | 211 | # Windows shortcuts 212 | *.lnk 213 | 214 | # End of https://www.toptal.com/developers/gitignore/api/node,linux,macos,windows,visualstudiocode 215 | 216 | # TypeScript compiler output 217 | tsc_* 218 | 219 | # Yarn 220 | .yarn/* 221 | .pnp.* 222 | 223 | # API Extractor 224 | temp 225 | benchmark.api.md 226 | 227 | # TypeDoc output 228 | docs_out 229 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": ["main"], 3 | "plugins": [ 4 | [ 5 | "@semantic-release/commit-analyzer", 6 | { 7 | "preset": "angular" 8 | } 9 | ], 10 | "@semantic-release/release-notes-generator", 11 | "@semantic-release/github", 12 | "@semantic-release/npm" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[javascript]": { 3 | "editor.defaultFormatter": "samverschueren.linter-xo" 4 | }, 5 | "[javascriptreact]": { 6 | "editor.defaultFormatter": "samverschueren.linter-xo" 7 | }, 8 | "[typescript]": { 9 | "editor.defaultFormatter": "samverschueren.linter-xo" 10 | }, 11 | "[typescriptreact]": { 12 | "editor.defaultFormatter": "samverschueren.linter-xo" 13 | }, 14 | "editor.defaultFormatter": "esbenp.prettier-vscode", 15 | "javascript.preferences.importModuleSpecifierEnding": "index", 16 | "typescript.tsdk": "node_modules/typescript/lib" 17 | } 18 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | enableGlobalCache: true 2 | 3 | nodeLinker: node-modules 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `@jonahsnider/benchmark` 2 | 3 | A Node.js benchmarking library with support for multithreading and TurboFan optimization isolation. 4 | 5 | ## Install 6 | 7 | This library is published on the npm registry and can be installed with your package manager of choice: 8 | 9 | ```sh 10 | yarn add @jonahsnider/benchmark 11 | # or 12 | npm i @jonahsnider/benchmark 13 | ``` 14 | 15 | Your project must use ESModules in order to import the library. 16 | 17 | ## Usage 18 | 19 | Below are some common use cases for the library. 20 | You can also read the [full documentation][docs] or the [API overview][api-overview] for more details on the package API. 21 | 22 | ### Basic usage 23 | 24 | This is a basic example that doesn't take advantage of multithreading (which is fine for simple benchmarks!). 25 | 26 | You can view the example [here](./examples/basic/index.ts) or by following the guide below: 27 | 28 | ```js 29 | import {Benchmark, Suite} from '@jonahsnider/benchmark'; 30 | 31 | // 1. Create benchmark 32 | const benchmark = new Benchmark(); 33 | 34 | // 2. Create suite(s) 35 | const concatenation = new Suite('concatenation', { 36 | run: { 37 | // Run benchmark for 3000ms 38 | durationMs: 3000, 39 | }, 40 | warmup: { 41 | // Run 1_000_000 warmup trials 42 | trials: 1_000_000, 43 | }, 44 | }); 45 | 46 | // 3. Register tests 47 | concatenation 48 | .addTest('+', () => 'a' + 'b') 49 | .addTest('templates', () => `${'a'}${'b'}`) 50 | .addTest('.concat()', () => 'a'.concat('b')); 51 | 52 | // 4. Run benchmark 53 | const results = await benchmark.runSuites(); 54 | 55 | console.log(results); 56 | // Map(1) { 57 | // 'concatenation' => Map(3) { 58 | // '+' => Histogram, 59 | // 'templates' => Histogram, 60 | // '.concat()' => Histogram 61 | // } 62 | // } 63 | 64 | // 5. You can also run individual suites 65 | const suiteResults = await concatenation.run(); 66 | 67 | console.log(suiteResults); 68 | // Map(3) { 69 | // '+' => Histogram, 70 | // 'templates' => Histogram, 71 | // '.concat()' => Histogram 72 | // } 73 | ``` 74 | 75 | ### Multithreading 76 | 77 | Enabling multithreading allows you to run each suite in parallel which results in much faster benchmarks and keeps TurboFan optimizations isolated to each suite. 78 | This is useful for functions that are prone to deoptimizations when a different code path is triggered from a different suite. 79 | 80 | You can view an example of this [here](./examples/threads/index.ts) or by following the guide below: 81 | 82 | First, we create our suites in individual files. 83 | This is required since each file will be loaded in a separate thread. 84 | 85 | In a new directory called `./suites/` create a file called `substring.js`: 86 | 87 | ```js 88 | import {Suite} from '@jonahsnider/benchmark'; 89 | 90 | // 1. Create suite 91 | const suite = new Suite('substring', { 92 | // Easy way to pass this suite's filepath to the thread 93 | filepath: import.meta.url, 94 | run: { 95 | // Run 1000 benchmark trials 96 | trials: 1000, 97 | }, 98 | warmup: { 99 | // Run warmup for 10_000ms 100 | durationMs: 10_000, 101 | }, 102 | }); 103 | 104 | // 2. Register tests 105 | suite.addTest('substring', () => { 106 | const string = 'abcdef'; 107 | 108 | return string.substring(1, 4); 109 | }); 110 | 111 | suite.addTest('substr', () => { 112 | const string = 'abcdef'; 113 | 114 | return string.substr(1, 4); 115 | }); 116 | 117 | suite.addTest('slice', () => { 118 | const string = 'abcdef'; 119 | 120 | return string.slice(1, 4); 121 | }); 122 | 123 | export default suite; 124 | ``` 125 | 126 | If you want to create other suites you can do that now. 127 | 128 | Once every suite is created we need to create the main file in `./index.js`: 129 | 130 | ```js 131 | import {Benchmark} from '@jonahsnider/benchmark'; 132 | // You may want to create a ./suites/index.js file which exports each suite 133 | import substringSuite from './suites/substring.js'; 134 | 135 | // 3. Create benchmark 136 | const benchmark = new Benchmark(); 137 | 138 | // 4. Register suites with {threaded: true} - you must `await` this since loading is async 139 | await benchmark.addSuite(substringSuite, {threaded: true}); 140 | 141 | // 5. Run benchmark 142 | const results = await benchmark.runSuites(); 143 | 144 | console.log(results); 145 | // Map(1) { 146 | // 'substring' => Map(3) { 147 | // 'substring' => Histogram, 148 | // 'substr' => Histogram, 149 | // 'slice' => Histogram 150 | // } 151 | // } 152 | ``` 153 | 154 | ### Advanced usage 155 | 156 | This library is designed to be very modular and customizable by allowing you to use your own structures instead of the built-in ones. 157 | 158 | When registering a suite with `Benchmark#addSuite` you can pass an instance of a `Suite` or you could pass your own structure that the implements the `SuiteLike` interface. 159 | 160 | You can also use `Suite`s directly without using `Benchmark` at all or make a tool as an alternative for `Benchmark`. 161 | 162 | Refer to [the API overview][api-overview] and [documentation][docs] for more information. 163 | 164 | ## Terminology 165 | 166 | ![A tree diagram showing the relation between `Benchmark`s, `Suite`s, and `Test`s](./diagram.svg) 167 | 168 | A `Benchmark` is the top-level object that helps manage your suites. 169 | You can use multiple benchmarks if you'd like but usually you only need one. 170 | 171 | A `Suite` is a group of tests that are run as a group. 172 | Each test in a suite should be a different implementation for the same thing. 173 | For example, you could make a suite for "sorting an array" and your tests could be "insertion sort", "selection sort", and "bubble sort". 174 | 175 | A test is a single function that is run many times while its performance is recorded. 176 | Internally it's represented with the `Test` class which _is_ exported by the package but you probably don't need to use it directly. 177 | 178 | [api-overview]: ./benchmark.api.md 179 | [docs]: https://benchmark.jonah.pw 180 | -------------------------------------------------------------------------------- /api-extractor.json: -------------------------------------------------------------------------------- 1 | /** 2 | * Config file for API Extractor. For more info, please visit: https://api-extractor.com 3 | */ 4 | { 5 | "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", 6 | 7 | /** 8 | * Optionally specifies another JSON config file that this file extends from. This provides a way for 9 | * standard settings to be shared across multiple projects. 10 | * 11 | * If the path starts with "./" or "../", the path is resolved relative to the folder of the file that contains 12 | * the "extends" field. Otherwise, the first path segment is interpreted as an NPM package name, and will be 13 | * resolved using NodeJS require(). 14 | * 15 | * SUPPORTED TOKENS: none 16 | * DEFAULT VALUE: "" 17 | */ 18 | // "extends": "./shared/api-extractor-base.json" 19 | // "extends": "my-package/include/api-extractor-base.json" 20 | 21 | /** 22 | * Determines the "" token that can be used with other config file settings. The project folder 23 | * typically contains the tsconfig.json and package.json config files, but the path is user-defined. 24 | * 25 | * The path is resolved relative to the folder of the config file that contains the setting. 26 | * 27 | * The default value for "projectFolder" is the token "", which means the folder is determined by traversing 28 | * parent folders, starting from the folder containing api-extractor.json, and stopping at the first folder 29 | * that contains a tsconfig.json file. If a tsconfig.json file cannot be found in this way, then an error 30 | * will be reported. 31 | * 32 | * SUPPORTED TOKENS: 33 | * DEFAULT VALUE: "" 34 | */ 35 | // "projectFolder": "..", 36 | 37 | /** 38 | * (REQUIRED) Specifies the .d.ts file to be used as the starting point for analysis. API Extractor 39 | * analyzes the symbols exported by this module. 40 | * 41 | * The file extension must be ".d.ts" and not ".ts". 42 | * 43 | * The path is resolved relative to the folder of the config file that contains the setting; to change this, 44 | * prepend a folder token such as "". 45 | * 46 | * SUPPORTED TOKENS: , , 47 | */ 48 | "mainEntryPointFilePath": "/tsc_types/src/index.d.ts", 49 | 50 | /** 51 | * A list of NPM package names whose exports should be treated as part of this package. 52 | * 53 | * For example, suppose that Webpack is used to generate a distributed bundle for the project "library1", 54 | * and another NPM package "library2" is embedded in this bundle. Some types from library2 may become part 55 | * of the exported API for library1, but by default API Extractor would generate a .d.ts rollup that explicitly 56 | * imports library2. To avoid this, we might specify: 57 | * 58 | * "bundledPackages": [ "library2" ], 59 | * 60 | * This would direct API Extractor to embed those types directly in the .d.ts rollup, as if they had been 61 | * local files for library1. 62 | * 63 | * The "bundledPackages" elements may specify glob patterns using minimatch syntax. To ensure deterministic 64 | * output, globs are expanded by matching explicitly declared top-level dependencies only. For example, 65 | * the pattern below will NOT match "@my-company/example" unless it appears in a field such as "dependencies" 66 | * or "devDependencies" of the project's package.json file: 67 | * 68 | * "bundledPackages": [ "@my-company/*" ], 69 | */ 70 | "bundledPackages": [], 71 | 72 | /** 73 | * Specifies what type of newlines API Extractor should use when writing output files. By default, the output files 74 | * will be written with Windows-style newlines. To use POSIX-style newlines, specify "lf" instead. 75 | * To use the OS's default newline kind, specify "os". 76 | * 77 | * DEFAULT VALUE: "crlf" 78 | */ 79 | // "newlineKind": "crlf", 80 | 81 | /** 82 | * Specifies how API Extractor sorts members of an enum when generating the .api.json file. By default, the output 83 | * files will be sorted alphabetically, which is "by-name". To keep the ordering in the source code, specify 84 | * "preserve". 85 | * 86 | * DEFAULT VALUE: "by-name" 87 | */ 88 | // "enumMemberOrder": "by-name", 89 | 90 | /** 91 | * Set to true when invoking API Extractor's test harness. When `testMode` is true, the `toolVersion` field in the 92 | * .api.json file is assigned an empty string to prevent spurious diffs in output files tracked for tests. 93 | * 94 | * DEFAULT VALUE: "false" 95 | */ 96 | // "testMode": false, 97 | 98 | /** 99 | * Determines how the TypeScript compiler engine will be invoked by API Extractor. 100 | */ 101 | "compiler": { 102 | /** 103 | * Specifies the path to the tsconfig.json file to be used by API Extractor when analyzing the project. 104 | * 105 | * The path is resolved relative to the folder of the config file that contains the setting; to change this, 106 | * prepend a folder token such as "". 107 | * 108 | * Note: This setting will be ignored if "overrideTsconfig" is used. 109 | * 110 | * SUPPORTED TOKENS: , , 111 | * DEFAULT VALUE: "/tsconfig.json" 112 | */ 113 | // "tsconfigFilePath": "/tsconfig.json", 114 | /** 115 | * Provides a compiler configuration that will be used instead of reading the tsconfig.json file from disk. 116 | * The object must conform to the TypeScript tsconfig schema: 117 | * 118 | * http://json.schemastore.org/tsconfig 119 | * 120 | * If omitted, then the tsconfig.json file will be read from the "projectFolder". 121 | * 122 | * DEFAULT VALUE: no overrideTsconfig section 123 | */ 124 | // "overrideTsconfig": { 125 | // . . . 126 | // } 127 | /** 128 | * This option causes the compiler to be invoked with the --skipLibCheck option. This option is not recommended 129 | * and may cause API Extractor to produce incomplete or incorrect declarations, but it may be required when 130 | * dependencies contain declarations that are incompatible with the TypeScript engine that API Extractor uses 131 | * for its analysis. Where possible, the underlying issue should be fixed rather than relying on skipLibCheck. 132 | * 133 | * DEFAULT VALUE: false 134 | */ 135 | // "skipLibCheck": true, 136 | }, 137 | 138 | /** 139 | * Configures how the API report file (*.api.md) will be generated. 140 | */ 141 | "apiReport": { 142 | /** 143 | * (REQUIRED) Whether to generate an API report. 144 | */ 145 | "enabled": true, 146 | 147 | /** 148 | * The base filename for the API report files, to be combined with "reportFolder" or "reportTempFolder" 149 | * to produce the full file path. The "reportFileName" should not include any path separators such as 150 | * "\" or "/". The "reportFileName" should not include a file extension, since API Extractor will automatically 151 | * append an appropriate file extension such as ".api.md". If the "reportVariants" setting is used, then the 152 | * file extension includes the variant name, for example "my-report.public.api.md" or "my-report.beta.api.md". 153 | * The "complete" variant always uses the simple extension "my-report.api.md". 154 | * 155 | * Previous versions of API Extractor required "reportFileName" to include the ".api.md" extension explicitly; 156 | * for backwards compatibility, that is still accepted but will be discarded before applying the above rules. 157 | * 158 | * SUPPORTED TOKENS: , 159 | * DEFAULT VALUE: "" 160 | */ 161 | // "reportFileName": "", 162 | 163 | /** 164 | * To support different approval requirements for different API levels, multiple "variants" of the API report can 165 | * be generated. The "reportVariants" setting specifies a list of variants to be generated. If omitted, 166 | * by default only the "complete" variant will be generated, which includes all @internal, @alpha, @beta, 167 | * and @public items. Other possible variants are "alpha" (@alpha + @beta + @public), "beta" (@beta + @public), 168 | * and "public" (@public only). 169 | * 170 | * DEFAULT VALUE: [ "complete" ] 171 | */ 172 | // "reportVariants": ["public", "beta"], 173 | 174 | /** 175 | * Specifies the folder where the API report file is written. The file name portion is determined by 176 | * the "reportFileName" setting. 177 | * 178 | * The API report file is normally tracked by Git. Changes to it can be used to trigger a branch policy, 179 | * e.g. for an API review. 180 | * 181 | * The path is resolved relative to the folder of the config file that contains the setting; to change this, 182 | * prepend a folder token such as "". 183 | * 184 | * SUPPORTED TOKENS: , , 185 | * DEFAULT VALUE: "/etc/" 186 | */ 187 | "reportFolder": "." 188 | 189 | /** 190 | * Specifies the folder where the temporary report file is written. The file name portion is determined by 191 | * the "reportFileName" setting. 192 | * 193 | * After the temporary file is written to disk, it is compared with the file in the "reportFolder". 194 | * If they are different, a production build will fail. 195 | * 196 | * The path is resolved relative to the folder of the config file that contains the setting; to change this, 197 | * prepend a folder token such as "". 198 | * 199 | * SUPPORTED TOKENS: , , 200 | * DEFAULT VALUE: "/temp/" 201 | */ 202 | // "reportTempFolder": "/temp/", 203 | 204 | /** 205 | * Whether "forgotten exports" should be included in the API report file. Forgotten exports are declarations 206 | * flagged with `ae-forgotten-export` warnings. See https://api-extractor.com/pages/messages/ae-forgotten-export/ to 207 | * learn more. 208 | * 209 | * DEFAULT VALUE: "false" 210 | */ 211 | // "includeForgottenExports": false 212 | }, 213 | 214 | /** 215 | * Configures how the doc model file (*.api.json) will be generated. 216 | */ 217 | "docModel": { 218 | /** 219 | * (REQUIRED) Whether to generate a doc model file. 220 | */ 221 | "enabled": true 222 | 223 | /** 224 | * The output path for the doc model file. The file extension should be ".api.json". 225 | * 226 | * The path is resolved relative to the folder of the config file that contains the setting; to change this, 227 | * prepend a folder token such as "". 228 | * 229 | * SUPPORTED TOKENS: , , 230 | * DEFAULT VALUE: "/temp/.api.json" 231 | */ 232 | // "apiJsonFilePath": "/temp/.api.json", 233 | 234 | /** 235 | * Whether "forgotten exports" should be included in the doc model file. Forgotten exports are declarations 236 | * flagged with `ae-forgotten-export` warnings. See https://api-extractor.com/pages/messages/ae-forgotten-export/ to 237 | * learn more. 238 | * 239 | * DEFAULT VALUE: "false" 240 | */ 241 | // "includeForgottenExports": false, 242 | 243 | /** 244 | * The base URL where the project's source code can be viewed on a website such as GitHub or 245 | * Azure DevOps. This URL path corresponds to the `` path on disk. 246 | * 247 | * This URL is concatenated with the file paths serialized to the doc model to produce URL file paths to individual API items. 248 | * For example, if the `projectFolderUrl` is "https://github.com/microsoft/rushstack/tree/main/apps/api-extractor" and an API 249 | * item's file path is "api/ExtractorConfig.ts", the full URL file path would be 250 | * "https://github.com/microsoft/rushstack/tree/main/apps/api-extractor/api/ExtractorConfig.js". 251 | * 252 | * This setting can be omitted if you don't need source code links in your API documentation reference. 253 | * 254 | * SUPPORTED TOKENS: none 255 | * DEFAULT VALUE: "" 256 | */ 257 | // "projectFolderUrl": "http://github.com/path/to/your/projectFolder" 258 | }, 259 | 260 | /** 261 | * Configures how the .d.ts rollup file will be generated. 262 | */ 263 | "dtsRollup": { 264 | /** 265 | * (REQUIRED) Whether to generate the .d.ts rollup file. 266 | */ 267 | "enabled": true, 268 | 269 | /** 270 | * Specifies the output path for a .d.ts rollup file to be generated without any trimming. 271 | * This file will include all declarations that are exported by the main entry point. 272 | * 273 | * If the path is an empty string, then this file will not be written. 274 | * 275 | * The path is resolved relative to the folder of the config file that contains the setting; to change this, 276 | * prepend a folder token such as "". 277 | * 278 | * SUPPORTED TOKENS: , , 279 | * DEFAULT VALUE: "/dist/.d.ts" 280 | */ 281 | "untrimmedFilePath": "/tsc_output/index.d.ts" 282 | 283 | /** 284 | * Specifies the output path for a .d.ts rollup file to be generated with trimming for an "alpha" release. 285 | * This file will include only declarations that are marked as "@public", "@beta", or "@alpha". 286 | * 287 | * If the path is an empty string, then this file will not be written. 288 | * 289 | * The path is resolved relative to the folder of the config file that contains the setting; to change this, 290 | * prepend a folder token such as "". 291 | * 292 | * SUPPORTED TOKENS: , , 293 | * DEFAULT VALUE: "" 294 | */ 295 | // "alphaTrimmedFilePath": "/dist/-alpha.d.ts", 296 | 297 | /** 298 | * Specifies the output path for a .d.ts rollup file to be generated with trimming for a "beta" release. 299 | * This file will include only declarations that are marked as "@public" or "@beta". 300 | * 301 | * If the path is an empty string, then this file will not be written. 302 | * 303 | * The path is resolved relative to the folder of the config file that contains the setting; to change this, 304 | * prepend a folder token such as "". 305 | * 306 | * SUPPORTED TOKENS: , , 307 | * DEFAULT VALUE: "" 308 | */ 309 | // "betaTrimmedFilePath": "/dist/-beta.d.ts", 310 | 311 | /** 312 | * Specifies the output path for a .d.ts rollup file to be generated with trimming for a "public" release. 313 | * This file will include only declarations that are marked as "@public". 314 | * 315 | * If the path is an empty string, then this file will not be written. 316 | * 317 | * The path is resolved relative to the folder of the config file that contains the setting; to change this, 318 | * prepend a folder token such as "". 319 | * 320 | * SUPPORTED TOKENS: , , 321 | * DEFAULT VALUE: "" 322 | */ 323 | // "publicTrimmedFilePath": "/dist/-public.d.ts", 324 | 325 | /** 326 | * When a declaration is trimmed, by default it will be replaced by a code comment such as 327 | * "Excluded from this release type: exampleMember". Set "omitTrimmingComments" to true to remove the 328 | * declaration completely. 329 | * 330 | * DEFAULT VALUE: false 331 | */ 332 | // "omitTrimmingComments": true 333 | }, 334 | 335 | /** 336 | * Configures how the tsdoc-metadata.json file will be generated. 337 | */ 338 | "tsdocMetadata": { 339 | /** 340 | * Whether to generate the tsdoc-metadata.json file. 341 | * 342 | * DEFAULT VALUE: true 343 | */ 344 | // "enabled": true, 345 | /** 346 | * Specifies where the TSDoc metadata file should be written. 347 | * 348 | * The path is resolved relative to the folder of the config file that contains the setting; to change this, 349 | * prepend a folder token such as "". 350 | * 351 | * The default value is "", which causes the path to be automatically inferred from the "tsdocMetadata", 352 | * "typings" or "main" fields of the project's package.json. If none of these fields are set, the lookup 353 | * falls back to "tsdoc-metadata.json" in the package folder. 354 | * 355 | * SUPPORTED TOKENS: , , 356 | * DEFAULT VALUE: "" 357 | */ 358 | // "tsdocMetadataFilePath": "/dist/tsdoc-metadata.json" 359 | }, 360 | 361 | /** 362 | * Configures how API Extractor reports error and warning messages produced during analysis. 363 | * 364 | * There are three sources of messages: compiler messages, API Extractor messages, and TSDoc messages. 365 | */ 366 | "messages": { 367 | /** 368 | * Configures handling of diagnostic messages reported by the TypeScript compiler engine while analyzing 369 | * the input .d.ts files. 370 | * 371 | * TypeScript message identifiers start with "TS" followed by an integer. For example: "TS2551" 372 | * 373 | * DEFAULT VALUE: A single "default" entry with logLevel=warning. 374 | */ 375 | "compilerMessageReporting": { 376 | /** 377 | * Configures the default routing for messages that don't match an explicit rule in this table. 378 | */ 379 | "default": { 380 | /** 381 | * Specifies whether the message should be written to the the tool's output log. Note that 382 | * the "addToApiReportFile" property may supersede this option. 383 | * 384 | * Possible values: "error", "warning", "none" 385 | * 386 | * Errors cause the build to fail and return a nonzero exit code. Warnings cause a production build fail 387 | * and return a nonzero exit code. For a non-production build (e.g. when "api-extractor run" includes 388 | * the "--local" option), the warning is displayed but the build will not fail. 389 | * 390 | * DEFAULT VALUE: "warning" 391 | */ 392 | "logLevel": "warning" 393 | 394 | /** 395 | * When addToApiReportFile is true: If API Extractor is configured to write an API report file (.api.md), 396 | * then the message will be written inside that file; otherwise, the message is instead logged according to 397 | * the "logLevel" option. 398 | * 399 | * DEFAULT VALUE: false 400 | */ 401 | // "addToApiReportFile": false 402 | } 403 | 404 | // "TS2551": { 405 | // "logLevel": "warning", 406 | // "addToApiReportFile": true 407 | // }, 408 | // 409 | // . . . 410 | }, 411 | 412 | /** 413 | * Configures handling of messages reported by API Extractor during its analysis. 414 | * 415 | * API Extractor message identifiers start with "ae-". For example: "ae-extra-release-tag" 416 | * 417 | * DEFAULT VALUE: See api-extractor-defaults.json for the complete table of extractorMessageReporting mappings 418 | */ 419 | "extractorMessageReporting": { 420 | "default": { 421 | "logLevel": "warning" 422 | // "addToApiReportFile": false 423 | } 424 | 425 | // "ae-extra-release-tag": { 426 | // "logLevel": "warning", 427 | // "addToApiReportFile": true 428 | // }, 429 | // 430 | // . . . 431 | }, 432 | 433 | /** 434 | * Configures handling of messages reported by the TSDoc parser when analyzing code comments. 435 | * 436 | * TSDoc message identifiers start with "tsdoc-". For example: "tsdoc-link-tag-unescaped-text" 437 | * 438 | * DEFAULT VALUE: A single "default" entry with logLevel=warning. 439 | */ 440 | "tsdocMessageReporting": { 441 | "default": { 442 | "logLevel": "warning" 443 | // "addToApiReportFile": false 444 | } 445 | 446 | // "tsdoc-link-tag-unescaped-text": { 447 | // "logLevel": "warning", 448 | // "addToApiReportFile": true 449 | // }, 450 | // 451 | // . . . 452 | } 453 | } 454 | } 455 | -------------------------------------------------------------------------------- /ava.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | files: ['tsc_output/test/**/*.test.js'], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /benchmark.api.md: -------------------------------------------------------------------------------- 1 | ## API Report File for "@jonahsnider/benchmark" 2 | 3 | > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). 4 | 5 | ```ts 6 | 7 | import { RecordableHistogram } from 'node:perf_hooks'; 8 | 9 | // @public (undocumented) 10 | export namespace Benchmark { 11 | export type Results = Map; 12 | export type RunOptions = { 13 | sequential?: boolean | undefined; 14 | }; 15 | } 16 | 17 | // @public 18 | export class Benchmark { 19 | addSuite(suite: SuiteLike, options?: { 20 | threaded: false; 21 | }): this; 22 | addSuite(suite: SuiteLike, options: { 23 | threaded: true; 24 | }): Promise; 25 | runSuites(abortSignal?: AbortSignal, options?: Benchmark.RunOptions): Promise; 26 | readonly suites: ReadonlyMap; 27 | } 28 | 29 | // @public (undocumented) 30 | export namespace Suite { 31 | export type Name = string; 32 | export type Options = { 33 | run: RunOptions; 34 | warmup: RunOptions; 35 | filepath?: string | undefined; 36 | }; 37 | export type Results = Map; 38 | export type RunOptions = { 39 | trials: number; 40 | durationMs?: undefined; 41 | } | { 42 | trials?: undefined; 43 | durationMs: number; 44 | }; 45 | } 46 | 47 | // @public 48 | export class Suite implements SuiteLike { 49 | constructor(name: Suite.Name, 50 | options: Suite.Options); 51 | addTest(testName: string, test: Test): this; 52 | addTest(testName: string, fn: () => unknown): this; 53 | get filepath(): string | undefined; 54 | // (undocumented) 55 | readonly name: Suite.Name; 56 | readonly options: Suite.Options; 57 | run(abortSignal?: AbortSignal): Promise; 58 | tests: ReadonlyMap; 59 | } 60 | 61 | // @public 62 | export type SuiteLike = { 63 | readonly name: Suite.Name; 64 | readonly filepath?: string | undefined; 65 | run(abortSignal?: AbortSignal): Suite.Results | PromiseLike; 66 | }; 67 | 68 | // @public (undocumented) 69 | export namespace Test { 70 | export type Name = string; 71 | } 72 | 73 | // @public 74 | export class Test { 75 | constructor(implementation: () => T | PromiseLike); 76 | readonly histogram: RecordableHistogram; 77 | run(): Promise; 78 | } 79 | 80 | // (No @packageDocumentation comment for this package) 81 | 82 | ``` 83 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Prequisites 4 | 5 | This project uses [Node.js](https://nodejs.org) to run, so make sure you've got a compatible version installed. 6 | 7 | [Yarn](https://yarnpkg.com) is used to manage dependencies and run scripts. 8 | After cloning the repository you can use this command to install dependencies: 9 | 10 | ```sh 11 | yarn 12 | ``` 13 | 14 | ## Building 15 | 16 | Run the `build` script to compile the TypeScript source code into JavaScript in the `tsc_output` folder. 17 | 18 | ```sh 19 | yarn run build 20 | ``` 21 | 22 | ## Style 23 | 24 | This project uses [Prettier](https://prettier.io) to validate the formatting and style across the codebase. 25 | 26 | You can run Prettier in the project with this command: 27 | 28 | ```sh 29 | yarn run style 30 | ``` 31 | 32 | ## Linting 33 | 34 | This project uses [XO](https://github.com/xojs/xo) (which uses [ESLint](https://eslint.org) and some plugins internally) to perform static analysis of the source code. 35 | It reports issues like unused variables or not following best practices to ensure the project is well-written. 36 | 37 | ```sh 38 | yarn run lint 39 | ``` 40 | 41 | ## Testing 42 | 43 | Unit tests are stored in the `test/` folder and follow a structure similar to the source code (ex. `src/benchmark.ts` has `test/benchmark/*.test.ts`). 44 | You can run the tests with the `test` script: 45 | 46 | ```sh 47 | yarn test 48 | ``` 49 | 50 | Tests use the compiled output of the library, so before running the tests you'll need to run the build script: 51 | 52 | ```sh 53 | yarn run build 54 | ``` 55 | 56 | When working with tests locally you may want to run one terminal with 57 | 58 | ```sh 59 | yarn run build --watch 60 | ``` 61 | 62 | and another with 63 | 64 | ```sh 65 | yarn test --watch 66 | ``` 67 | 68 | ## Coverage 69 | 70 | Running `yarn test:coverage` will generate a `coverage` folder which has a breakdown of coverage of the project. 71 | The CI will upload the coverage information to [CodeCov](https://codecov.io) which can be [viewed here](https://codecov.io/gh/jonahsnider/benchmark). 72 | -------------------------------------------------------------------------------- /diagram.svg: -------------------------------------------------------------------------------- 1 |
Benchmark
Suite
Suite
Test
Test
Test
Test
Test
Test
-------------------------------------------------------------------------------- /examples/basic/index.ts: -------------------------------------------------------------------------------- 1 | import {Benchmark, Suite} from '../../src/index.js'; 2 | 3 | const benchmark = new Benchmark(); 4 | 5 | const concatenation = new Suite('concatenation', {run: {durationMs: 3000}, warmup: {trials: 1_000_000}}) 6 | .addTest('+', () => 'a' + 'b') 7 | .addTest('templates', () => `${'a'}${'b'}`) 8 | .addTest('.concat()', () => 'a'.concat('b')); 9 | 10 | benchmark.addSuite(concatenation); 11 | 12 | const results = await benchmark.runSuites(); 13 | 14 | console.log(results); 15 | -------------------------------------------------------------------------------- /examples/threads/index.ts: -------------------------------------------------------------------------------- 1 | import {Benchmark} from '../../src/index.js'; 2 | import substringSuite from './suites/substring.js'; 3 | 4 | const benchmark = new Benchmark(); 5 | 6 | await benchmark.addSuite(substringSuite, {threaded: true}); 7 | 8 | const results = await benchmark.runSuites(); 9 | 10 | console.log(results); 11 | -------------------------------------------------------------------------------- /examples/threads/suites/substring.ts: -------------------------------------------------------------------------------- 1 | import {Suite} from '../../../src/index.js'; 2 | 3 | const suite = new Suite('substring', {filepath: import.meta.url, run: {trials: 1e3}, warmup: {durationMs: 10_000}}); 4 | 5 | suite.addTest('substring', () => { 6 | const string = 'abcdef'; 7 | 8 | return string.substring(1, 4); 9 | }); 10 | 11 | suite.addTest('substr', () => { 12 | const string = 'abcdef'; 13 | 14 | return string.substr(1, 4); 15 | }); 16 | 17 | suite.addTest('slice', () => { 18 | const string = 'abcdef'; 19 | 20 | return string.slice(1, 4); 21 | }); 22 | 23 | export default suite; 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jonahsnider/benchmark", 3 | "version": "0.0.0-development", 4 | "bugs": { 5 | "url": "https://github.com/jonahsnider/benchmark/issues" 6 | }, 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/jonahsnider/benchmark.git" 10 | }, 11 | "license": "Apache-2.0", 12 | "author": { 13 | "name": "Jonah Snider", 14 | "email": "jonah@jonahsnider.com", 15 | "url": "https://jonahsnider.com" 16 | }, 17 | "sideEffects": false, 18 | "type": "module", 19 | "exports": { 20 | ".": { 21 | "types": "./tsc_output/index.d.ts", 22 | "default": "./tsc_output/src/index.js" 23 | } 24 | }, 25 | "main": "./tsc_output/src/index.js", 26 | "types": "./tsc_output/index.d.ts", 27 | "files": [ 28 | "tsc_output", 29 | "!tsc_output/{test,examples}" 30 | ], 31 | "scripts": { 32 | "build": "tsc", 33 | "docs": "typedoc", 34 | "lint": "xo", 35 | "release": "semantic-release", 36 | "style": "prettier --check .", 37 | "test": "ava", 38 | "test:coverage": "c8 --reporter lcov --reporter text-summary --include 'tsc_output/src/**/*.js' --exclude 'tsc_output/src/index.js' --all ava", 39 | "validate-api": "api-extractor run", 40 | "validate-api:local": "api-extractor run --local --verbose" 41 | }, 42 | "prettier": "@jonahsnider/prettier-config", 43 | "dependencies": { 44 | "@jonahsnider/util": "11.0.0", 45 | "tslib": "2.8.1" 46 | }, 47 | "devDependencies": { 48 | "@jonahsnider/prettier-config": "1.1.2", 49 | "@jonahsnider/xo-config": "6.0.0", 50 | "@microsoft/api-extractor": "7.52.8", 51 | "@tsconfig/node16": "16.1.4", 52 | "@types/node": "22.15.29", 53 | "ava": "6.3.0", 54 | "c8": "10.1.3", 55 | "eslint-plugin-prettier": "5.4.1", 56 | "jest-mock": "29.7.0", 57 | "prettier": "3.5.3", 58 | "semantic-release": "24.2.5", 59 | "typedoc": "0.28.5", 60 | "typescript": "5.8.3", 61 | "xo": "1.0.5" 62 | }, 63 | "packageManager": "yarn@4.9.1", 64 | "engines": { 65 | "node": ">=16.0.0" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["github>jonahsnider/renovate-config"] 4 | } 5 | -------------------------------------------------------------------------------- /src/benchmark.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert/strict'; 2 | import {partition} from '@jonahsnider/util'; 3 | import type {Suite, SuiteLike} from './suite.js'; 4 | import {Thread} from './thread.js'; 5 | 6 | /** 7 | * @public 8 | */ 9 | // eslint-disable-next-line @typescript-eslint/no-namespace 10 | export namespace Benchmark { 11 | /** 12 | * A `Map` where keys are the {@link (Suite:namespace).Name | suite names} and values are the {@link (Suite:namespace).Results | suite results}. 13 | * 14 | * @public 15 | */ 16 | export type Results = Map; 17 | 18 | /** 19 | * Options for running a {@link (Benchmark:class)}. 20 | * 21 | * @public 22 | */ 23 | export type RunOptions = { 24 | /** 25 | * Whether to run multithreaded suites sequentially or in parallel. 26 | * 27 | * @public 28 | */ 29 | sequential?: boolean | undefined; 30 | }; 31 | } 32 | 33 | /** 34 | * A benchmark which has many {@link SuiteLike}s. 35 | * 36 | * @example 37 | * ```js 38 | * import { Benchmark, Suite } from '@jonahsnider/benchmark'; 39 | * 40 | * const benchmark = new Benchmark(); 41 | * 42 | * const suite = new Suite('concatenation', { warmup: { durationMs: 10_000 }, run: { durationMs: 10_000 } }) 43 | * .addTest('+', () => 'a' + 'b') 44 | * .addTest('templates', () => `${'a'}${'b'}`) 45 | * .addTest('.concat()', () => 'a'.concat('b')); 46 | * 47 | * benchmark.addSuite(suite); 48 | * 49 | * const results = await benchmark.run(); 50 | * 51 | * console.log(results); 52 | * ``` 53 | * 54 | * @public 55 | */ 56 | export class Benchmark { 57 | readonly #suites = new Map(); 58 | readonly #multithreadedSuites = new Set(); 59 | 60 | /** 61 | * The {@link SuiteLike}s in this {@link (Benchmark:class)}. 62 | */ 63 | // eslint-disable-next-line @typescript-eslint/member-ordering 64 | readonly suites: ReadonlyMap = this.#suites; 65 | 66 | /** 67 | * Add a {@link SuiteLike} to this {@link (Benchmark:class)}. 68 | * 69 | * @example 70 | * ```js 71 | * benchmark.addSuite(suite); 72 | * ``` 73 | * 74 | * @param suite - The {@link SuiteLike} to add 75 | * 76 | * @returns `this` 77 | */ 78 | addSuite(suite: SuiteLike, options?: {threaded: false}): this; 79 | /** 80 | * Add a {@link SuiteLike} to this {@link (Benchmark:class)} by loading it in a separate thread via its filepath. 81 | * 82 | * @example 83 | * ```js 84 | * await benchmark.addSuite(suite); 85 | * ``` 86 | * 87 | * @param suite - A {@link (Suite:class)} with a filepath provided 88 | * 89 | * @returns `this` 90 | */ 91 | addSuite(suite: SuiteLike, options: {threaded: true}): Promise; 92 | addSuite(suiteLike: SuiteLike, options?: {threaded: boolean}): this | Promise { 93 | assert.ok(!this.#suites.has(suiteLike.name), new RangeError(`A suite with the name "${suiteLike.name}" already exists`)); 94 | 95 | if (options?.threaded) { 96 | assert.ok(suiteLike.filepath); 97 | 98 | // eslint-disable-next-line promise/prefer-await-to-then 99 | return Thread.init(suiteLike.filepath).then(threadedSuite => { 100 | this.#suites.set(threadedSuite.name, threadedSuite); 101 | this.#multithreadedSuites.add(threadedSuite.name); 102 | 103 | return this; 104 | }); 105 | } 106 | 107 | this.#suites.set(suiteLike.name, suiteLike); 108 | return this; 109 | } 110 | 111 | /** 112 | * Run all {@link (Suite:class)}s for this {@link (Benchmark:class)}. 113 | * 114 | * @example 115 | * ```js 116 | * const results = await benchmark.runSuites(); 117 | * ``` 118 | * 119 | * @example 120 | * Using an `AbortSignal` to cancel the benchmark: 121 | * ```js 122 | * const ac = new AbortController(); 123 | * const signal = ac.signal; 124 | * 125 | * benchmark 126 | * .runSuites(signal) 127 | * .then(console.log) 128 | * .catch(error => { 129 | * if (error.name === 'AbortError') { 130 | * console.log('The benchmark was aborted'); 131 | * } 132 | * }); 133 | * 134 | * ac.abort(); 135 | * ``` 136 | * 137 | * @param abortSignal - An optional `AbortSignal` that can be used to cancel the running suites 138 | * 139 | * @returns A {@link (Benchmark:namespace).Results} `Map` 140 | */ 141 | async runSuites(abortSignal?: AbortSignal, options?: Benchmark.RunOptions): Promise { 142 | const results: Benchmark.Results = new Map(); 143 | 144 | const [multithreaded, singleThreaded] = partition(this.#suites.values(), suite => this.#multithreadedSuites.has(suite.name)); 145 | 146 | // Single-threaded suites are executed serially to avoid any interference 147 | for (const suite of singleThreaded) { 148 | // eslint-disable-next-line no-await-in-loop 149 | const suiteResults = await suite.run(abortSignal); 150 | 151 | results.set(suite.name, suiteResults); 152 | } 153 | 154 | if (options?.sequential) { 155 | for (const suite of multithreaded) { 156 | // eslint-disable-next-line no-await-in-loop 157 | const suiteResults = await suite.run(abortSignal); 158 | 159 | results.set(suite.name, suiteResults); 160 | } 161 | } else { 162 | await Promise.all( 163 | multithreaded.map(async suite => { 164 | const suiteResults = await suite.run(abortSignal); 165 | 166 | results.set(suite.name, suiteResults); 167 | }), 168 | ); 169 | } 170 | 171 | return results; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export {Benchmark} from './benchmark.js'; 2 | export {Suite} from './suite.js'; 3 | export type {SuiteLike} from './suite.js'; 4 | export {Test} from './test.js'; 5 | -------------------------------------------------------------------------------- /src/suite.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert/strict'; 2 | import {type RecordableHistogram, performance} from 'node:perf_hooks'; 3 | import {Test} from './test.js'; 4 | import {AbortError} from './utils.js'; 5 | 6 | /** 7 | * A suite of related tests that can be run together. 8 | * 9 | * @public 10 | */ 11 | export type SuiteLike = { 12 | /** 13 | * The name of this {@link SuiteLike}. 14 | */ 15 | readonly name: Suite.Name; 16 | 17 | /** 18 | * The filepath to this {@link SuiteLike}, when available. 19 | */ 20 | readonly filepath?: string | undefined; 21 | 22 | /** 23 | * Runs this {@link SuiteLike}. 24 | * 25 | * @example 26 | * A synchronous implementation: 27 | * ```js 28 | * const results = suite.run(); 29 | * ``` 30 | * 31 | * @example 32 | * An asynchronous implementation: 33 | * ```js 34 | * const results = await suite.run(); 35 | * ``` 36 | * 37 | * @example 38 | * Using an `AbortSignal` to cancel the suite: 39 | * ```js 40 | * const ac = new AbortController(); 41 | * const signal = ac.signal; 42 | * 43 | * suite 44 | * .run(signal) 45 | * .then(console.log) 46 | * .catch(error => { 47 | * if (error.name === 'AbortError') { 48 | * console.log('The suite was aborted'); 49 | * } 50 | * }); 51 | * 52 | * ac.abort(); 53 | * ``` 54 | * 55 | * @returns The results of running this {@link SuiteLike} 56 | */ 57 | run(abortSignal?: AbortSignal): Suite.Results | PromiseLike; 58 | }; 59 | 60 | /** 61 | * @public 62 | */ 63 | // eslint-disable-next-line @typescript-eslint/no-namespace 64 | export namespace Suite { 65 | /** 66 | * The name of a {@link (Suite:class)}. 67 | * 68 | * @public 69 | */ 70 | export type Name = string; 71 | 72 | /** 73 | * Results from running a {@link (Suite:class)}. 74 | * 75 | * @public 76 | */ 77 | export type Results = Map; 78 | 79 | /** 80 | * Options for running {@link (Test:class)}s. 81 | * 82 | * @public 83 | */ 84 | export type RunOptions = 85 | | { 86 | trials: number; 87 | durationMs?: undefined; 88 | } 89 | | { 90 | trials?: undefined; 91 | durationMs: number; 92 | }; 93 | 94 | /** 95 | * Options for running a {@link (Suite:class)}. 96 | * 97 | * @public 98 | */ 99 | export type Options = { 100 | run: RunOptions; 101 | warmup: RunOptions; 102 | filepath?: string | undefined; 103 | }; 104 | } 105 | 106 | /** 107 | * A collection of {@link (Test:class)}s that are different implementations of the same thing (ex. different ways of sorting an array). 108 | * 109 | * @example 110 | * ```js 111 | * import { Suite } from '@jonahsnider/benchmark'; 112 | * 113 | * const suite = new Suite('concatenation', { warmup: { durationMs: 10_000 }, run: { durationMs: 10_000 } }) 114 | * .addTest('+', () => 'a' + 'b') 115 | * .addTest('templates', () => `${'a'}${'b'}`) 116 | * .addTest('.concat()', () => 'a'.concat('b')); 117 | * 118 | * const results = await suite.run(); 119 | * 120 | * console.log(results); 121 | * ``` 122 | * 123 | * @public 124 | */ 125 | export class Suite implements SuiteLike { 126 | readonly #tests = new Map(); 127 | 128 | /** 129 | * The tests in this {@link (Suite:class)}. 130 | */ 131 | // eslint-disable-next-line @typescript-eslint/member-ordering 132 | tests: ReadonlyMap = this.#tests; 133 | 134 | /** 135 | * This {@link (Suite:class)}'s filepath, if it was provided. 136 | * Used for running the {@link (Suite:class)} in a separate thread. 137 | */ 138 | get filepath(): string | undefined { 139 | return this.options.filepath; 140 | } 141 | 142 | /** 143 | * Creates a new {@link (Suite:class)}. 144 | * 145 | * @example 146 | * ```js 147 | * import { Suite } from '@jonahsnider/benchmark'; 148 | * 149 | * const suite = new Suite('concatenation', { warmup: { durationMs: 10_000 }, run: { durationMs: 10_000 } }); 150 | * ``` 151 | * 152 | * @example 153 | * Suites that specify a filepath can be run in a separate thread in a {@link (Benchmark:class)}. 154 | * ```js 155 | * import { Suite } from '@jonahsnider/benchmark'; 156 | * 157 | * const suite = new Suite('concatenation', { 158 | * warmup: { durationMs: 10_000 }, 159 | * run: { durationMs: 10_000 }, 160 | * filepath: import.meta.url 161 | * }); 162 | * ``` 163 | * 164 | * @param name - The name of the {@link (Suite:class)} 165 | * @param options - Options for the {@link (Suite:class)} 166 | */ 167 | constructor( 168 | public readonly name: Suite.Name, 169 | /** 170 | * Options for running this {@link (Suite:class)} and its warmup. 171 | */ 172 | public readonly options: Suite.Options, 173 | ) {} 174 | 175 | /** 176 | * Adds a test to this {@link (Suite:class)}. 177 | * 178 | * @example 179 | * ```js 180 | * const test = new Test(() => 'a' + 'b'); 181 | * 182 | * suite.addTest('+', test); 183 | * ``` 184 | * 185 | * @param testName - The name of the test 186 | * @param test - The test to add 187 | * 188 | * @returns `this` 189 | */ 190 | addTest(testName: string, test: Test): this; 191 | /** 192 | * Creates and adds a test to this {@link (Suite:class)}. 193 | * 194 | * @example 195 | * ```js 196 | * suite.addTest('+', () => 'a' + 'b'); 197 | * ``` 198 | * 199 | * @param testName - The name of the test 200 | * @param fn - The function to run 201 | * 202 | * @returns `this` 203 | */ 204 | 205 | addTest(testName: string, fn: () => unknown): this; 206 | addTest(testName: string, fnOrTest: Test | (() => unknown)): this { 207 | assert.ok(!this.#tests.has(testName)); 208 | assert.strictEqual(typeof testName, 'string', new TypeError(`The "testName" argument must be of type string.`)); 209 | 210 | if (fnOrTest instanceof Test) { 211 | this.#tests.set(testName, fnOrTest); 212 | } else { 213 | assert.strictEqual(typeof fnOrTest, 'function', new TypeError(`The "fn" argument must be of type function.`)); 214 | 215 | this.#tests.set(testName, new Test(fnOrTest)); 216 | } 217 | 218 | return this; 219 | } 220 | 221 | /** 222 | * Runs this {@link (Suite:class)} using {@link (Suite:class).options}. 223 | * 224 | * @example 225 | * ```js 226 | * const results = await suite.run(); 227 | * ``` 228 | * 229 | * @example 230 | * Using an `AbortSignal` to cancel the suite: 231 | * ```js 232 | * const ac = new AbortController(); 233 | * const signal = ac.signal; 234 | * 235 | * suite 236 | * .run(signal) 237 | * .then(console.log) 238 | * .catch(error => { 239 | * if (error.name === 'AbortError') { 240 | * console.log('The suite was aborted'); 241 | * } 242 | * }); 243 | * 244 | * ac.abort(); 245 | * ``` 246 | * 247 | * @returns The results of running this {@link (Suite:class)} 248 | */ 249 | async run(abortSignal?: AbortSignal): Promise { 250 | this.#clearResults(); 251 | 252 | await this.#runWarmup(abortSignal); 253 | 254 | await this.#runTests(abortSignal); 255 | 256 | const results: Suite.Results = new Map([...this.#tests.entries()].map(([testName, test]) => [testName, test.histogram])); 257 | 258 | return results; 259 | } 260 | 261 | #clearResults(): void { 262 | for (const tests of this.#tests.values()) { 263 | tests.histogram.reset(); 264 | } 265 | } 266 | 267 | async #runTestsOnce(): Promise { 268 | for (const test of this.#tests.values()) { 269 | // eslint-disable-next-line no-await-in-loop 270 | await test.run(); 271 | } 272 | } 273 | 274 | async #runTestsWithOptions(options: Suite.RunOptions, abortSignal?: AbortSignal): Promise { 275 | if (options.durationMs === undefined) { 276 | for (let count = 0; count < options.trials; count++) { 277 | if (abortSignal?.aborted) { 278 | throw new AbortError(); 279 | } 280 | 281 | // eslint-disable-next-line no-await-in-loop 282 | await this.#runTestsOnce(); 283 | } 284 | } else { 285 | const startTime = performance.now(); 286 | 287 | while (performance.now() - startTime < options.durationMs) { 288 | if (abortSignal?.aborted) { 289 | throw new AbortError(); 290 | } 291 | 292 | // eslint-disable-next-line no-await-in-loop 293 | await this.#runTestsOnce(); 294 | } 295 | } 296 | } 297 | 298 | async #runTests(abortSignal?: AbortSignal): Promise { 299 | await this.#runTestsWithOptions(this.options.run, abortSignal); 300 | } 301 | 302 | async #runWarmup(abortSignal?: AbortSignal): Promise { 303 | await this.#runTestsWithOptions(this.options.warmup, abortSignal); 304 | 305 | this.#clearResults(); 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | import {type RecordableHistogram, createHistogram, performance} from 'node:perf_hooks'; 2 | 3 | /** 4 | * @public 5 | */ 6 | // eslint-disable-next-line @typescript-eslint/no-namespace 7 | export namespace Test { 8 | /** 9 | * The name of a {@link (Test:class)}. 10 | * 11 | * @public 12 | */ 13 | export type Name = string; 14 | } 15 | 16 | /** 17 | * Tracks a function's execution in {@link (Test:class).histogram}. 18 | * 19 | * @example 20 | * ```ts 21 | * import { Test } from '@jonahsnider/benchmark'; 22 | * 23 | * const test = new Test(() => 'a' + 'b'); 24 | * ``` 25 | * 26 | * @public 27 | */ 28 | export class Test { 29 | /** Execution times for this {@link (Test:class)}'s implementation. */ 30 | readonly histogram: RecordableHistogram = createHistogram(); 31 | readonly #implementation: () => T | PromiseLike; 32 | 33 | /** 34 | * Create a new {@link (Test:class)} with a given implementation. 35 | * 36 | * You probably don't want to instantiate this class directly, instead you can register tests with {@link (Suite:class).(addTest:2)}. 37 | * You can also register {@link (Test:class)} instances with {@link (Suite:class).(addTest:1)}. 38 | * 39 | * @example 40 | * ```ts 41 | * const test = new Test(() => 'a' + 'b'); 42 | * ``` 43 | * 44 | * @param implementation - The implementation function of the test 45 | */ 46 | constructor(implementation: () => T | PromiseLike) { 47 | this.#implementation = () => { 48 | const startMs = performance.now(); 49 | const result = implementation(); 50 | const endMs = performance.now(); 51 | 52 | const durationNs = Math.round((endMs - startMs) * 1e6); 53 | 54 | // Sometimes the duration is 0, seems like it's only when running on arm64 - see https://github.com/nodejs/node/issues/41641 55 | this.histogram.record(durationNs || 1); 56 | 57 | return result; 58 | }; 59 | } 60 | 61 | /** 62 | * Runs this {@link (Test:class)}'s implementation once and records the execution time in {@link (Test:class).histogram}. 63 | * 64 | * @returns The return value of this {@link (Test:class)}'s implementation 65 | */ 66 | async run(): Promise { 67 | return this.#implementation(); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/thread-worker.ts: -------------------------------------------------------------------------------- 1 | import {isMainThread, parentPort, workerData} from 'node:worker_threads'; 2 | import assert from 'node:assert/strict'; 3 | import type {Suite} from './suite.js'; 4 | import {ThreadWorker} from './types/index.js'; 5 | import {compatibleImport} from './utils.js'; 6 | 7 | assert.ok(!isMainThread, new Error('This file should be run in a thread')); 8 | assert.ok(parentPort); 9 | 10 | const {suitePath} = workerData as ThreadWorker.Data; 11 | 12 | let suite: Suite; 13 | 14 | let ac: AbortController; 15 | 16 | async function run(): Promise { 17 | ac = new AbortController(); 18 | 19 | suite ??= await compatibleImport(suitePath); 20 | 21 | try { 22 | const results = await suite.run(ac.signal); 23 | 24 | const response: ThreadWorker.Response = { 25 | kind: ThreadWorker.Response.Kind.Results, 26 | results, 27 | }; 28 | 29 | parentPort!.postMessage(response); 30 | } catch (error) { 31 | const response: ThreadWorker.Response = { 32 | kind: ThreadWorker.Response.Kind.Error, 33 | error, 34 | }; 35 | 36 | parentPort!.postMessage(response); 37 | } 38 | } 39 | 40 | parentPort.on('message', async (message: ThreadWorker.Message) => { 41 | switch (message.kind) { 42 | case ThreadWorker.Message.Kind.Run: { 43 | await run(); 44 | 45 | break; 46 | } 47 | 48 | case ThreadWorker.Message.Kind.Abort: { 49 | ac.abort(); 50 | break; 51 | } 52 | } 53 | }); 54 | -------------------------------------------------------------------------------- /src/thread.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert/strict'; 2 | import {once} from 'node:events'; 3 | import {type WorkerOptions, Worker} from 'node:worker_threads'; 4 | import {type SuiteLike, Suite} from './suite.js'; 5 | import {ThreadWorker} from './types/index.js'; 6 | import {compatibleImport} from './utils.js'; 7 | 8 | const WORKER_PATH = new URL('thread-worker.js', import.meta.url); 9 | 10 | /** 11 | * Runs a {@link (Suite:class)} in a separate thread. 12 | */ 13 | export class Thread implements SuiteLike { 14 | static async init(suiteFilepath: string): Promise { 15 | const suite = await compatibleImport(suiteFilepath); 16 | 17 | assert.ok(suite instanceof Suite, new TypeError(`Expected "${suiteFilepath}" to export a Suite instance`)); 18 | 19 | return new Thread(suite, suiteFilepath); 20 | } 21 | 22 | readonly name: Suite.Name; 23 | readonly filepath: string; 24 | 25 | #worker: Worker; 26 | readonly #workerOptions: WorkerOptions; 27 | 28 | constructor(suite: Suite, suitePath: string) { 29 | this.name = suite.name; 30 | this.filepath = suitePath; 31 | 32 | const workerData: ThreadWorker.Data = { 33 | suitePath, 34 | }; 35 | 36 | this.#workerOptions = { 37 | workerData, 38 | }; 39 | 40 | this.#worker = this.#createWorker(); 41 | 42 | this.#worker.on('exit', this.#onExit.bind(this)); 43 | 44 | this.#worker.unref(); 45 | } 46 | 47 | async run(abortSignal?: AbortSignal): Promise { 48 | const runMessage: ThreadWorker.Message = {kind: ThreadWorker.Message.Kind.Run}; 49 | 50 | // Worker must be run before an abort signal is sent 51 | this.#worker.postMessage(runMessage); 52 | 53 | const onAbortListener = this.#onAbort.bind(this); 54 | 55 | abortSignal?.addEventListener('abort', onAbortListener, {once: true}); 56 | const message = once(this.#worker, 'message'); 57 | 58 | try { 59 | const [response] = (await message) as [ThreadWorker.Response]; 60 | 61 | switch (response.kind) { 62 | case ThreadWorker.Response.Kind.Results: { 63 | return response.results; 64 | } 65 | 66 | case ThreadWorker.Response.Kind.Error: { 67 | // Note: Structured clone algorithm has weird behavior with Error instances - see https://github.com/nodejs/help/issues/1558#issuecomment-431142715 and https://github.com/nodejs/node/issues/26692#issuecomment-658010376 68 | 69 | throw response.error; 70 | } 71 | 72 | // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check 73 | default: { 74 | throw new RangeError('Unknown response kind'); 75 | } 76 | } 77 | } finally { 78 | abortSignal?.removeEventListener('abort', onAbortListener); 79 | } 80 | } 81 | 82 | #createWorker(): Worker { 83 | return new Worker(WORKER_PATH, this.#workerOptions); 84 | } 85 | 86 | async #onExit(code: number): Promise { 87 | // This prevents the main thread from hanging when the worker is not `unref`'d 88 | await this.#worker.terminate(); 89 | 90 | // Create a new worker 91 | this.#worker = this.#createWorker(); 92 | 93 | throw new Error(`Worker exited with code ${code}`); 94 | } 95 | 96 | #onAbort(): void { 97 | const abortMessage = {kind: ThreadWorker.Message.Kind.Abort}; 98 | 99 | this.#worker.postMessage(abortMessage); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * as ThreadWorker from './thread-worker.js'; 2 | -------------------------------------------------------------------------------- /src/types/thread-worker.ts: -------------------------------------------------------------------------------- 1 | import type {Suite} from '../suite.js'; 2 | 3 | export type Data = { 4 | suitePath: string; 5 | }; 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-namespace 8 | export namespace Message { 9 | export enum Kind { 10 | Run, 11 | Abort, 12 | } 13 | } 14 | 15 | // eslint-disable-next-line @typescript-eslint/no-redeclare 16 | export type Message = 17 | | { 18 | kind: Message.Kind.Run; 19 | } 20 | | { 21 | kind: Message.Kind.Abort; 22 | }; 23 | 24 | // eslint-disable-next-line @typescript-eslint/no-namespace 25 | export namespace Response { 26 | export enum Kind { 27 | Results, 28 | Error, 29 | } 30 | } 31 | 32 | // eslint-disable-next-line @typescript-eslint/no-redeclare 33 | export type Response = 34 | | { 35 | kind: Response.Kind.Results; 36 | results: Suite.Results; 37 | } 38 | | { 39 | kind: Response.Kind.Error; 40 | error: unknown; 41 | }; 42 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export async function compatibleImport(path: string): Promise { 2 | const mod = (await import(path)) as {default: T}; 3 | 4 | return mod.default; 5 | } 6 | 7 | export const SKIP_SUITE = {run: {trials: 0}, warmup: {trials: 0}} as const; 8 | 9 | export const SHORT_SUITE = {run: {trials: 1e3}, warmup: {trials: 1e3}} as const; 10 | 11 | export const SHORT_TIMED_SUITE = {run: {durationMs: 100}, warmup: {durationMs: 100}} as const; 12 | 13 | /** @see https://github.com/nodejs/node/blob/cf56abe6bba2ccc7b3553f4255ab1fd683e06f8f/lib/internal/errors.js#L823-L829 */ 14 | export class AbortError extends Error { 15 | code = 'ABORT_ERR' as const; 16 | name = 'AbortError'; 17 | 18 | constructor() { 19 | super('The operation was aborted'); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /test/benchmark/add-suite.test.ts: -------------------------------------------------------------------------------- 1 | import {name} from '@jonahsnider/util'; 2 | import test from 'ava'; 3 | import {Benchmark} from '../../src/benchmark.js'; 4 | import {Suite} from '../../src/suite.js'; 5 | import {Thread} from '../../src/thread.js'; 6 | import {SKIP_SUITE} from '../../src/utils.js'; 7 | import emptySuite from './fixtures/suites/empty.js'; 8 | 9 | test(`adds ${name(Suite)} instances`, t => { 10 | const benchmark = new Benchmark(); 11 | 12 | const suite = new Suite('suite', SKIP_SUITE); 13 | 14 | t.deepEqual(benchmark.suites, new Map()); 15 | 16 | benchmark.addSuite(suite); 17 | 18 | t.deepEqual(benchmark.suites, new Map([['suite', suite]])); 19 | }); 20 | 21 | test(`adds suites with ${name(Thread)}`, async t => { 22 | const benchmark = new Benchmark(); 23 | 24 | t.deepEqual(benchmark.suites, new Map()); 25 | 26 | await benchmark.addSuite(emptySuite, {threaded: true}); 27 | 28 | t.deepEqual([...benchmark.suites.keys()], ['empty suite']); 29 | t.is(benchmark.suites.get('empty suite')!.constructor, Thread); 30 | }); 31 | -------------------------------------------------------------------------------- /test/benchmark/fixtures/suites/empty.ts: -------------------------------------------------------------------------------- 1 | import {Suite} from '../../../../src/suite.js'; 2 | import {SKIP_SUITE} from '../../../../src/utils.js'; 3 | 4 | const suite = new Suite('empty suite', {...SKIP_SUITE, filepath: import.meta.url}); 5 | 6 | export default suite; 7 | -------------------------------------------------------------------------------- /test/benchmark/fixtures/suites/regular.ts: -------------------------------------------------------------------------------- 1 | import {Suite} from '../../../../src/suite.js'; 2 | import {SHORT_SUITE} from '../../../../src/utils.js'; 3 | 4 | const suite = new Suite('suite', {...SHORT_SUITE, filepath: import.meta.url}); 5 | 6 | // eslint-disable-next-line @typescript-eslint/no-empty-function 7 | suite.addTest('test a', () => {}); 8 | // eslint-disable-next-line @typescript-eslint/no-empty-function 9 | suite.addTest('test b', () => {}); 10 | 11 | export default suite; 12 | -------------------------------------------------------------------------------- /test/benchmark/run-suites.test.ts: -------------------------------------------------------------------------------- 1 | import {createHistogram} from 'node:perf_hooks'; 2 | import test from 'ava'; 3 | import {Benchmark} from '../../src/benchmark.js'; 4 | import {Suite} from '../../src/suite.js'; 5 | import {AbortError, SHORT_SUITE} from '../../src/utils.js'; 6 | import regularSuite from './fixtures/suites/regular.js'; 7 | import emptySuite from './fixtures/suites/empty.js'; 8 | 9 | test('runs single threaded suites', async t => { 10 | const benchmark = new Benchmark(); 11 | 12 | // eslint-disable-next-line @typescript-eslint/no-empty-function 13 | const suiteA = new Suite('suite a', SHORT_SUITE).addTest('test a', () => {}).addTest('test b', () => {}); 14 | // eslint-disable-next-line @typescript-eslint/no-empty-function 15 | const suiteB = new Suite('suite b', SHORT_SUITE).addTest('test b', () => {}).addTest('test c', () => {}); 16 | 17 | benchmark.addSuite(suiteA).addSuite(suiteB); 18 | 19 | const results = await benchmark.runSuites(); 20 | 21 | t.deepEqual( 22 | results, 23 | new Map([ 24 | [ 25 | 'suite a', 26 | new Map([ 27 | ['test a', createHistogram()], 28 | ['test b', createHistogram()], 29 | ]), 30 | ], 31 | [ 32 | 'suite b', 33 | new Map([ 34 | ['test b', createHistogram()], 35 | ['test c', createHistogram()], 36 | ]), 37 | ], 38 | ]), 39 | ); 40 | }); 41 | 42 | test('runs multithreaded suites', async t => { 43 | const benchmark = new Benchmark(); 44 | 45 | benchmark.addSuite(emptySuite); 46 | await benchmark.addSuite(regularSuite, {threaded: true}); 47 | 48 | const results = await benchmark.runSuites(); 49 | 50 | t.deepEqual( 51 | results, 52 | new Map([ 53 | ['empty suite', new Map()], 54 | [ 55 | 'suite', 56 | new Map([ 57 | ['test a', createHistogram()], 58 | ['test b', createHistogram()], 59 | ]), 60 | ], 61 | ]), 62 | ); 63 | }); 64 | 65 | test('uses AbortSignals without threads', async t => { 66 | const ac = new AbortController(); 67 | const benchmark = new Benchmark(); 68 | 69 | benchmark.addSuite(regularSuite); 70 | 71 | const assertion = t.throwsAsync(benchmark.runSuites(ac.signal), {instanceOf: AbortError}); 72 | 73 | ac.abort(); 74 | 75 | await assertion; 76 | }); 77 | 78 | test('uses AbortSignals with threads', async t => { 79 | const ac = new AbortController(); 80 | const benchmark = new Benchmark(); 81 | 82 | await benchmark.addSuite(regularSuite, {threaded: true}); 83 | 84 | const assertion = t.throwsAsync(benchmark.runSuites(ac.signal), {name: 'Error', message: 'The operation was aborted'}); 85 | 86 | ac.abort(); 87 | 88 | await assertion; 89 | }); 90 | -------------------------------------------------------------------------------- /test/suite/add-test.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import {Suite} from '../../src/suite.js'; 3 | import {Test} from '../../src/test.js'; 4 | import {SKIP_SUITE} from '../../src/utils.js'; 5 | 6 | test('adds a test', t => { 7 | const suite = new Suite('name', SKIP_SUITE); 8 | 9 | t.deepEqual(suite.tests, new Map()); 10 | 11 | // eslint-disable-next-line @typescript-eslint/no-empty-function 12 | suite.addTest('test', () => {}); 13 | 14 | // eslint-disable-next-line @typescript-eslint/no-empty-function 15 | t.deepEqual(suite.tests, new Map([['test', new Test(() => {})]])); 16 | }); 17 | 18 | test('adds a Test instance', t => { 19 | const suite = new Suite('name', SKIP_SUITE); 20 | 21 | t.deepEqual(suite.tests, new Map()); 22 | 23 | // eslint-disable-next-line @typescript-eslint/no-empty-function 24 | const test = new Test(() => {}); 25 | 26 | suite.addTest('test', test); 27 | 28 | t.deepEqual(suite.tests, new Map([['test', test]])); 29 | t.is(suite.tests.get('test'), test); 30 | }); 31 | -------------------------------------------------------------------------------- /test/suite/constructor.test.ts: -------------------------------------------------------------------------------- 1 | import {name} from '@jonahsnider/util'; 2 | import test from 'ava'; 3 | import {Suite} from '../../src/suite.js'; 4 | import {SKIP_SUITE} from '../../src/utils.js'; 5 | 6 | test(`${name(Suite)}.prototype.name`, t => { 7 | const suite = new Suite('name', SKIP_SUITE); 8 | 9 | t.is(suite.name, 'name'); 10 | }); 11 | 12 | test(`${name(Suite)}.prototype.options`, t => { 13 | const options = {...SKIP_SUITE, filepath: 'suite.js'}; 14 | 15 | const suite = new Suite('suite', options); 16 | 17 | t.is(suite.options, options); 18 | }); 19 | 20 | test(`${name(Suite)}.prototype.filepath`, t => { 21 | const suite = new Suite('suite', {...SKIP_SUITE, filepath: 'suite.js'}); 22 | 23 | t.is(suite.filepath, 'suite.js'); 24 | }); 25 | -------------------------------------------------------------------------------- /test/suite/run.test.ts: -------------------------------------------------------------------------------- 1 | import {createHistogram, performance} from 'node:perf_hooks'; 2 | import {toDigits} from '@jonahsnider/util'; 3 | import test from 'ava'; 4 | import * as mock from 'jest-mock'; 5 | import {Suite} from '../../src/suite.js'; 6 | import {AbortError, SHORT_SUITE, SHORT_TIMED_SUITE} from '../../src/utils.js'; 7 | 8 | test('runs tests with given number of runs', async t => { 9 | const TOTAL_TRIALS = SHORT_SUITE.warmup.trials + SHORT_SUITE.run.trials; 10 | 11 | const suite = new Suite('name', SHORT_SUITE); 12 | 13 | const testA = mock.fn(); 14 | const testB = mock.fn(); 15 | suite.addTest('test a', testA).addTest('test b', testB); 16 | 17 | t.is(testA.mock.calls.length, 0); 18 | t.is(testB.mock.calls.length, 0); 19 | 20 | const results = await suite.run(); 21 | 22 | t.is(testA.mock.calls.length, TOTAL_TRIALS); 23 | t.is(testB.mock.calls.length, TOTAL_TRIALS); 24 | 25 | t.deepEqual( 26 | results, 27 | new Map([ 28 | ['test a', createHistogram()], 29 | ['test b', createHistogram()], 30 | ]), 31 | ); 32 | }); 33 | 34 | test('runs tests with given duration', async t => { 35 | const TOTAL_DURATION_MS = SHORT_TIMED_SUITE.warmup.durationMs + SHORT_TIMED_SUITE.run.durationMs; 36 | 37 | const suite = new Suite('name', SHORT_TIMED_SUITE); 38 | 39 | const testA = mock.fn(); 40 | const testB = mock.fn(); 41 | 42 | suite.addTest('test a', testA).addTest('test b', testB); 43 | 44 | t.is(testA.mock.calls.length, 0); 45 | t.is(testB.mock.calls.length, 0); 46 | 47 | const start = performance.now(); 48 | const results = await suite.run(); 49 | const end = performance.now(); 50 | 51 | t.true(testA.mock.calls.length > 0); 52 | t.true(testB.mock.calls.length > 0); 53 | 54 | t.is(toDigits(end - start, -2), TOTAL_DURATION_MS); 55 | 56 | t.deepEqual( 57 | results, 58 | new Map([ 59 | ['test a', createHistogram()], 60 | ['test b', createHistogram()], 61 | ]), 62 | ); 63 | }); 64 | 65 | test('handles errors in tests', async t => { 66 | const suite = new Suite('name', SHORT_SUITE); 67 | 68 | suite.addTest('test', () => { 69 | throw new Error('test error'); 70 | }); 71 | 72 | await t.throwsAsync(suite.run(), {instanceOf: Error, message: 'test error'}); 73 | }); 74 | 75 | test('uses AbortSignals when running with trials', async t => { 76 | const ac = new AbortController(); 77 | const suite = new Suite('name', SHORT_SUITE); 78 | 79 | // eslint-disable-next-line @typescript-eslint/no-empty-function 80 | suite.addTest('test', () => {}); 81 | 82 | const assertion = t.throwsAsync(suite.run(ac.signal), {instanceOf: AbortError}); 83 | 84 | ac.abort(); 85 | 86 | await assertion; 87 | }); 88 | 89 | test('uses AbortSignals when running with duration', async t => { 90 | const ac = new AbortController(); 91 | const suite = new Suite('name', SHORT_TIMED_SUITE); 92 | 93 | // eslint-disable-next-line @typescript-eslint/no-empty-function 94 | suite.addTest('test', () => {}); 95 | 96 | const assertion = t.throwsAsync(suite.run(ac.signal), {instanceOf: AbortError}); 97 | 98 | ac.abort(); 99 | 100 | await assertion; 101 | }); 102 | -------------------------------------------------------------------------------- /test/test/run.test.ts: -------------------------------------------------------------------------------- 1 | import {createHistogram} from 'node:perf_hooks'; 2 | import test from 'ava'; 3 | import * as mock from 'jest-mock'; 4 | import {Test} from '../../src/test.js'; 5 | 6 | test('runs', async t => { 7 | const implementation = mock.fn(); 8 | 9 | const instance = new Test(implementation); 10 | 11 | t.is(implementation.mock.calls.length, 0); 12 | 13 | await instance.run(); 14 | 15 | t.is(implementation.mock.calls.length, 1); 16 | }); 17 | 18 | test('updates the histogram', async t => { 19 | // eslint-disable-next-line @typescript-eslint/no-empty-function 20 | const instance = new Test(() => {}); 21 | 22 | t.deepEqual(instance.histogram, createHistogram()); 23 | t.is(instance.histogram.percentiles.size, 1); 24 | 25 | await instance.run(); 26 | 27 | t.is(instance.histogram.percentiles.size, 2); 28 | }); 29 | -------------------------------------------------------------------------------- /test/thread/fixtures/suites/broken.ts: -------------------------------------------------------------------------------- 1 | import {Suite} from '../../../../src/suite.js'; 2 | import {SHORT_SUITE} from '../../../../src/utils.js'; 3 | 4 | const suite = new Suite('suite', {...SHORT_SUITE, filepath: import.meta.url}); 5 | 6 | // eslint-disable-next-line @typescript-eslint/no-empty-function 7 | suite.addTest('test a', () => {}); 8 | suite.addTest('test b', () => { 9 | throw new Error('broken test'); 10 | }); 11 | 12 | export default suite; 13 | -------------------------------------------------------------------------------- /test/thread/fixtures/suites/empty.ts: -------------------------------------------------------------------------------- 1 | import {Suite} from '../../../../src/suite.js'; 2 | import {SKIP_SUITE} from '../../../../src/utils.js'; 3 | 4 | const suite = new Suite('suite', {...SKIP_SUITE, filepath: import.meta.url}); 5 | 6 | export default suite; 7 | -------------------------------------------------------------------------------- /test/thread/fixtures/suites/invalid.ts: -------------------------------------------------------------------------------- 1 | const notSuite = {filepath: import.meta.url}; 2 | 3 | export default notSuite; 4 | -------------------------------------------------------------------------------- /test/thread/fixtures/suites/regular.ts: -------------------------------------------------------------------------------- 1 | import {Suite} from '../../../../src/suite.js'; 2 | import {SHORT_SUITE} from '../../../../src/utils.js'; 3 | 4 | const suite = new Suite('suite', {...SHORT_SUITE, filepath: import.meta.url}); 5 | 6 | // eslint-disable-next-line @typescript-eslint/no-empty-function 7 | suite.addTest('test a', () => {}); 8 | // eslint-disable-next-line @typescript-eslint/no-empty-function 9 | suite.addTest('test b', () => {}); 10 | 11 | export default suite; 12 | -------------------------------------------------------------------------------- /test/thread/init.test.ts: -------------------------------------------------------------------------------- 1 | import {name} from '@jonahsnider/util'; 2 | import test from 'ava'; 3 | import {Suite} from '../../src/suite.js'; 4 | import {Thread} from '../../src/thread.js'; 5 | import emptySuite from './fixtures/suites/empty.js'; 6 | import invalidSuite from './fixtures/suites/invalid.js'; 7 | 8 | test(`instantiates a ${name(Thread)}`, async t => { 9 | const thread = await Thread.init(emptySuite.filepath!); 10 | 11 | t.is(thread.constructor, Thread); 12 | t.is(thread.name, 'suite'); 13 | }); 14 | 15 | test(`throws if given path is not a ${name(Suite)}`, async t => { 16 | await t.throwsAsync(Thread.init(invalidSuite.filepath), { 17 | instanceOf: TypeError, 18 | message: `Expected "${invalidSuite.filepath}" to export a Suite instance`, 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /test/thread/run.test.ts: -------------------------------------------------------------------------------- 1 | import {createHistogram} from 'node:perf_hooks'; 2 | import {fileURLToPath} from 'node:url'; 3 | import test from 'ava'; 4 | import {Thread} from '../../src/thread.js'; 5 | 6 | test('runs', async t => { 7 | const thread = await Thread.init(fileURLToPath(new URL('fixtures/suites/regular.js', import.meta.url))); 8 | 9 | const results = await thread.run(); 10 | 11 | t.deepEqual( 12 | results, 13 | new Map([ 14 | ['test a', createHistogram()], 15 | ['test b', createHistogram()], 16 | ]), 17 | ); 18 | }); 19 | 20 | test('handles errors in tests', async t => { 21 | const thread = await Thread.init(fileURLToPath(new URL('fixtures/suites/broken.js', import.meta.url))); 22 | 23 | await t.throwsAsync(thread.run(), {instanceOf: Error, message: 'broken test'}); 24 | }); 25 | 26 | test('handles errors in worker loading', async t => { 27 | await t.throwsAsync(Thread.init('./missing-file.js'), {message: /^Cannot find module '.+missing-file\.js' imported from .+$/}); 28 | }); 29 | 30 | test('uses AbortSignals', async t => { 31 | const ac = new AbortController(); 32 | const thread = await Thread.init(fileURLToPath(new URL('fixtures/suites/regular.js', import.meta.url))); 33 | 34 | // Structured clone algorithm or worker_threads removes the error code property and changes the name 35 | const assertion = t.throwsAsync(thread.run(ac.signal), {name: 'Error', message: 'The operation was aborted'}); 36 | 37 | ac.abort(); 38 | 39 | await assertion; 40 | }); 41 | -------------------------------------------------------------------------------- /test/utils/abort-error.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import {AbortError} from '../../src/utils.js'; 3 | 4 | test('name', t => { 5 | const error = new AbortError(); 6 | 7 | t.is(error.name, 'AbortError'); 8 | }); 9 | 10 | test('message', t => { 11 | const error = new AbortError(); 12 | 13 | t.is(error.message, 'The operation was aborted'); 14 | }); 15 | 16 | test('code', t => { 17 | const error = new AbortError(); 18 | 19 | t.is(error.code, 'ABORT_ERR'); 20 | }); 21 | -------------------------------------------------------------------------------- /test/utils/compatible-import.test.ts: -------------------------------------------------------------------------------- 1 | import {fileURLToPath} from 'node:url'; 2 | import test from 'ava'; 3 | import {compatibleImport} from '../../src/utils.js'; 4 | 5 | test('imports ESM modules', async t => { 6 | t.deepEqual(await compatibleImport(fileURLToPath(new URL('fixtures/esm.js', import.meta.url))), {key: 'value'}); 7 | }); 8 | -------------------------------------------------------------------------------- /test/utils/fixtures/esm.ts: -------------------------------------------------------------------------------- 1 | const mod = {key: 'value'}; 2 | 3 | export default mod; 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "declarationDir": "tsc_types", 5 | "importHelpers": true, 6 | "lib": ["WebWorker"], 7 | "outDir": "tsc_output", 8 | "sourceMap": true 9 | }, 10 | "exclude": ["node_modules", "tsc_output", "tsc_types", "coverage"], 11 | "extends": "@tsconfig/node16", 12 | "include": ["src", "examples", "test"] 13 | } 14 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": ["./src/index.ts"], 3 | "out": "docs_out", 4 | "excludePrivate": true 5 | } 6 | -------------------------------------------------------------------------------- /xo.config.mjs: -------------------------------------------------------------------------------- 1 | import base from '@jonahsnider/xo-config'; 2 | 3 | /** @type {import('xo').FlatXoConfig} */ 4 | const config = [ 5 | { 6 | ignores: ['examples/'], 7 | }, 8 | ...(Array.isArray(base) ? base : [base]), 9 | 10 | { 11 | rules: { 12 | 'node/no-unsupported-features': 'off', 13 | 'node/no-unsupported-features/es-syntax': 'off', 14 | }, 15 | }, 16 | 17 | { 18 | files: ['test/**/*.test.ts'], 19 | rules: { 20 | // AVA isn't used to run tests written in TypeScript directly 21 | 'ava/no-ignored-test-files': 'off', 22 | }, 23 | }, 24 | ]; 25 | 26 | export default config; 27 | --------------------------------------------------------------------------------