├── .editorconfig ├── .github └── workflows │ ├── checks.yml │ ├── labels.yml │ ├── release.yml │ └── stale.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── LICENSE.md ├── README.md ├── eslint.config.js ├── examples ├── dot.ts ├── ndjson.ts └── spec.ts ├── factories ├── create_dummy_tests.ts ├── main.ts └── runner.ts ├── index.ts ├── modules └── core │ ├── main.ts │ ├── reporters │ └── base.ts │ └── types.ts ├── package.json ├── src ├── cli_parser.ts ├── config_manager.ts ├── create_test.ts ├── debug.ts ├── exceptions_manager.ts ├── files_manager.ts ├── helpers.ts ├── hooks.ts ├── planner.ts ├── plugins │ └── retry.ts ├── reporters │ ├── dot.ts │ ├── github.ts │ ├── main.ts │ ├── ndjson.ts │ └── spec.ts ├── types.ts └── validator.ts ├── tests ├── base_reporter.spec.ts ├── cli_parser.spec.ts ├── config_manager.spec.ts ├── core.spec.ts ├── files_manager.spec.ts ├── github_reporter.spec.ts ├── helpers.ts ├── planner.spec.ts └── runner.spec.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | [*] 4 | indent_style = space 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 | [*.json] 12 | insert_final_newline = ignore 13 | 14 | [**.min.js] 15 | indent_style = ignore 16 | insert_final_newline = ignore 17 | 18 | [MakeFile] 19 | indent_style = space 20 | 21 | [*.md] 22 | trim_trailing_whitespace = false 23 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | - push 4 | - pull_request 5 | - workflow_call 6 | 7 | jobs: 8 | test: 9 | uses: japa/.github/.github/workflows/test.yml@main 10 | lint: 11 | uses: japa/.github/.github/workflows/lint.yml@main 12 | typecheck: 13 | uses: japa/.github/.github/workflows/typecheck.yml@main 14 | -------------------------------------------------------------------------------- /.github/workflows/labels.yml: -------------------------------------------------------------------------------- 1 | name: Sync labels 2 | on: 3 | workflow_dispatch: 4 | permissions: 5 | issues: write 6 | jobs: 7 | labels: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: EndBug/label-sync@v2 12 | with: 13 | config-file: 'https://raw.githubusercontent.com/thetutlage/static/main/labels.yml' 14 | delete-other-labels: true 15 | token: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: workflow_dispatch 3 | permissions: 4 | contents: write 5 | id-token: write 6 | jobs: 7 | checks: 8 | uses: ./.github/workflows/checks.yml 9 | release: 10 | needs: checks 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: 20 19 | - name: git config 20 | run: | 21 | git config user.name "${GITHUB_ACTOR}" 22 | git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" 23 | - name: Init npm config 24 | run: npm config set //registry.npmjs.org/:_authToken $NPM_TOKEN 25 | env: 26 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 27 | - run: npm install 28 | - run: npm run release -- --ci 29 | env: 30 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 33 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues and PRs' 2 | on: 3 | schedule: 4 | - cron: '30 0 * * *' 5 | 6 | jobs: 7 | stale: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/stale@v9 11 | with: 12 | stale-issue-message: 'This issue has been marked as stale because it has been inactive for more than 21 days. Please reopen if you still need help on this issue' 13 | stale-pr-message: 'This pull request has been marked as stale because it has been inactive for more than 21 days. Please reopen if you still intend to submit this pull request' 14 | close-issue-message: 'This issue has been automatically closed because it has been inactive for more than 4 weeks. Please reopen if you still need help on this issue' 15 | close-pr-message: 'This pull request has been automatically closed because it has been inactive for more than 4 weeks. Please reopen if you still intend to submit this pull request' 16 | days-before-stale: 21 17 | days-before-close: 5 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | test/__app 4 | .DS_STORE 5 | .nyc_output 6 | .idea 7 | .vscode/ 8 | *.sublime-project 9 | *.sublime-workspace 10 | *.log 11 | build 12 | dist 13 | shrinkwrap.yaml 14 | .env 15 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | docs 3 | *.md 4 | config.json 5 | .eslintrc.json 6 | package.json 7 | *.html 8 | *.txt 9 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright (c) 2023 Harminder Virk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @japa/runner 2 | > A simple yet powerful testing framework for Node.js 3 | 4 | [![github-actions-image]][github-actions-url] [![npm-image]][npm-url] [![license-image]][license-url] [![typescript-image]][typescript-url] 5 | 6 | Japa comes with everything you need to test your backend applications. Be it writing JSON API tests using an Open API schema or writing browser tests using Playwright. 7 | 8 | Unlike other testing frameworks born out of the frontend ecosystem, Japa focuses only on testing backend applications and libraries. Therefore, Japa is **simpler**, **faster**, and **bloatware free**. 9 | 10 | Japa 11 | 12 | #### 💁 Please visit https://japa.dev for documentation 13 | 14 |
15 |
16 | 17 | ![](https://raw.githubusercontent.com/thetutlage/static/main/sponsorkit/sponsors.png) 18 | 19 | [github-actions-image]: https://img.shields.io/github/actions/workflow/status/japa/runner/checks.yml?style=for-the-badge "github-actions" 20 | 21 | [github-actions-url]: https://github.com/japa/runner/actions/workflows/checks.yml 22 | 23 | [npm-image]: https://img.shields.io/npm/v/@japa/runner.svg?style=for-the-badge&logo=npm 24 | [npm-url]: https://npmjs.org/package/@japa/runner "npm" 25 | 26 | [license-image]: https://img.shields.io/npm/l/@japa/runner?color=blueviolet&style=for-the-badge 27 | [license-url]: LICENSE.md "license" 28 | 29 | [typescript-image]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript 30 | [typescript-url]: "typescript" 31 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { configPkg } from '@adonisjs/eslint-config' 2 | export default configPkg({ 3 | ignores: ['coverage'], 4 | }) 5 | -------------------------------------------------------------------------------- /examples/dot.ts: -------------------------------------------------------------------------------- 1 | import { dot } from '../src/reporters/main.js' 2 | import { createDummyTests, runner } from '../factories/main.js' 3 | 4 | await runner() 5 | .configure({ 6 | files: [], 7 | reporters: { 8 | list: [dot()], 9 | activated: ['dot'], 10 | }, 11 | }) 12 | .runSuites(createDummyTests) 13 | -------------------------------------------------------------------------------- /examples/ndjson.ts: -------------------------------------------------------------------------------- 1 | import { ndjson } from '../src/reporters/main.js' 2 | import { createDummyTests, runner } from '../factories/main.js' 3 | 4 | await runner() 5 | .configure({ 6 | files: [], 7 | reporters: { 8 | list: [ndjson()], 9 | activated: ['ndjson'], 10 | }, 11 | }) 12 | .runSuites(createDummyTests) 13 | -------------------------------------------------------------------------------- /examples/spec.ts: -------------------------------------------------------------------------------- 1 | import { spec } from '../src/reporters/main.js' 2 | import { createDummyTests, runner } from '../factories/main.js' 3 | 4 | await runner() 5 | .configure({ 6 | files: [], 7 | reporters: { 8 | list: [spec()], 9 | activated: ['spec'], 10 | }, 11 | }) 12 | .runSuites(createDummyTests) 13 | -------------------------------------------------------------------------------- /factories/create_dummy_tests.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import { Suite, Emitter, Refiner } from '../modules/core/main.js' 3 | import { createTest, createTestGroup } from '../src/create_test.js' 4 | 5 | /** 6 | * Creates a unit tests suite with bunch of dummy tests 7 | * reproducing different tests behavior 8 | */ 9 | function createUnitTestsSuite(emitter: Emitter, refiner: Refiner, file?: string) { 10 | const suite = new Suite('unit', emitter, refiner) 11 | const group = createTestGroup('Maths#add', emitter, refiner, { 12 | suite, 13 | file, 14 | }) 15 | 16 | createTest('A top level test inside a suite', emitter, refiner, { 17 | suite, 18 | file, 19 | }).run(() => {}) 20 | 21 | createTest('add two numbers', emitter, refiner, { group, file }).run(() => { 22 | assert.equal(2 + 2, 4) 23 | }) 24 | createTest('add three numbers', emitter, refiner, { 25 | group, 26 | file, 27 | }).run(() => { 28 | assert.equal(2 + 2 + 2, 6) 29 | }) 30 | 31 | createTest('add group of numbers', emitter, refiner, { group, file }) 32 | createTest('use math.js lib', emitter, refiner, { group, file }).skip( 33 | true, 34 | 'Library work pending' 35 | ) 36 | createTest('add multiple numbers', emitter, refiner, { 37 | file, 38 | group, 39 | }).run(() => { 40 | assert.equal(2 + 2 + 2 + 2, 6) 41 | }) 42 | createTest('add floating numbers', emitter, refiner, { group, file }) 43 | .run(() => { 44 | assert.equal(2 + 2.2 + 2.1, 6) 45 | }) 46 | .fails('Have to add support for floating numbers') 47 | 48 | createTest('regression test that is passing', emitter, refiner, { group, file }) 49 | .run(() => { 50 | assert.equal(2 + 2.2 + 2.1, 2 + 2.2 + 2.1) 51 | }) 52 | .fails('Have to add support for floating numbers') 53 | 54 | createTest('A test with an error that is not an AssertionError', emitter, refiner, { 55 | group, 56 | file, 57 | }).run(() => { 58 | throw new Error('This is an error') 59 | }) 60 | 61 | return suite 62 | } 63 | 64 | /** 65 | * Creates a unit functional suite with bunch of dummy tests 66 | * reproducing different tests behavior 67 | */ 68 | function createFunctionalTestsSuite(emitter: Emitter, refiner: Refiner, file?: string) { 69 | const suite = new Suite('functional', emitter, refiner) 70 | 71 | const group = createTestGroup('Users/store', emitter, refiner, { 72 | suite, 73 | file: file, 74 | }) 75 | createTest('Validate user data', emitter, refiner, { 76 | group, 77 | file: file, 78 | }).run(() => {}) 79 | createTest('Disallow duplicate emails', emitter, refiner, { 80 | group, 81 | file: file, 82 | }).run(() => {}) 83 | createTest('Disallow duplicate emails across tenants', emitter, refiner, { 84 | group, 85 | file: file, 86 | }).run(() => { 87 | const users = ['', ''] 88 | assert.equal(users.length, 1) 89 | }) 90 | createTest('Normalize email before persisting it', emitter, refiner, { 91 | group, 92 | file: file, 93 | }).skip(true, 'Have to build a normalizer') 94 | createTest('Send email verification mail', emitter, refiner, { 95 | group, 96 | file: file, 97 | }) 98 | 99 | createTest('Test that times out', emitter, refiner, { 100 | group, 101 | file: file, 102 | }).run(() => { 103 | return new Promise((resolve) => { 104 | setTimeout(resolve, 2100) 105 | }) 106 | }) 107 | 108 | const usersListGroup = createTestGroup('Users/list', emitter, refiner, { 109 | suite, 110 | file: file, 111 | }) 112 | usersListGroup.setup(() => { 113 | throw new Error('Unable to cleanup database') 114 | }) 115 | createTest('A test that will never run because the group hooks fails', emitter, refiner, { 116 | group: usersListGroup, 117 | }) 118 | 119 | createTest('A top level test inside functional suite', emitter, refiner, { 120 | suite, 121 | file: file, 122 | }).run(() => {}) 123 | 124 | return suite 125 | } 126 | 127 | /** 128 | * Returns an array of suites with dummy tests reproducting 129 | * different test behavior 130 | */ 131 | export function createDummyTests(emitter: Emitter, refiner: Refiner, file?: string): Suite[] { 132 | return [ 133 | createUnitTestsSuite(emitter, refiner, file), 134 | createFunctionalTestsSuite(emitter, refiner, file), 135 | ] 136 | } 137 | -------------------------------------------------------------------------------- /factories/main.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { ReporterContract } from '../src/types.js' 11 | import { RunnerFactory } from './runner.js' 12 | 13 | /** 14 | * Create an instance of the runner factory 15 | */ 16 | export const runner = () => new RunnerFactory() 17 | export { createDummyTests } from './create_dummy_tests.js' 18 | export const syncReporter: ReporterContract = { 19 | name: 'sync', 20 | handler(r, emitter) { 21 | emitter.on('runner:end', function () { 22 | const summary = r.getSummary() 23 | if (summary.hasError) { 24 | if (summary.failureTree[0].errors.length) { 25 | throw summary.failureTree[0].errors[0].error 26 | } 27 | if (summary.failureTree[0].children[0].errors.length) { 28 | throw summary.failureTree[0].children[0].errors[0].error 29 | } 30 | } 31 | }) 32 | }, 33 | } 34 | -------------------------------------------------------------------------------- /factories/runner.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { fileURLToPath } from 'node:url' 11 | 12 | import { Planner } from '../src/planner.js' 13 | import { GlobalHooks } from '../src/hooks.js' 14 | import { CliParser } from '../src/cli_parser.js' 15 | import { createTest } from '../src/create_test.js' 16 | import { ConfigManager } from '../src/config_manager.js' 17 | import { Suite, Runner, Emitter, TestContext, Refiner } from '../modules/core/main.js' 18 | import type { 19 | Config, 20 | CLIArgs, 21 | TestExecutor, 22 | RunnerSummary, 23 | NormalizedConfig, 24 | } from '../src/types.js' 25 | 26 | /** 27 | * Runner factory exposes the API to run dummy suites, groups and tests. 28 | * You might want to use the factory for testing reporters and 29 | * plugins usage 30 | */ 31 | export class RunnerFactory { 32 | #emitter = new Emitter() 33 | #config?: NormalizedConfig 34 | #cliArgs?: CLIArgs 35 | #file = fileURLToPath(import.meta.url) 36 | #bail: boolean = false 37 | 38 | get #refiner() { 39 | return this.#config!.refiner 40 | } 41 | 42 | /** 43 | * Registers plugins 44 | */ 45 | async #registerPlugins(runner: Runner) { 46 | for (let plugin of this.#config!.plugins) { 47 | await plugin({ 48 | config: this.#config!, 49 | runner, 50 | emitter: this.#emitter, 51 | cliArgs: this.#cliArgs!, 52 | }) 53 | } 54 | } 55 | 56 | /** 57 | * Configure runner 58 | */ 59 | configure(config: Config, argv?: string[]) { 60 | this.#cliArgs = new CliParser().parse(argv || []) 61 | this.#config = new ConfigManager(config, this.#cliArgs).hydrate() 62 | return this 63 | } 64 | 65 | /** 66 | * Define a custom emitter instance to use 67 | */ 68 | useEmitter(emitter: Emitter) { 69 | this.#emitter = emitter 70 | return this 71 | } 72 | 73 | /** 74 | * Run a test using the runner 75 | */ 76 | async runTest( 77 | title: string, 78 | callback: TestExecutor 79 | ): Promise { 80 | return this.runSuites((emitter, refiner, file) => { 81 | const defaultSuite = new Suite('default', emitter, refiner) 82 | 83 | createTest(title, emitter, refiner, { 84 | suite: defaultSuite, 85 | file: file, 86 | }).run(callback) 87 | 88 | return [defaultSuite] 89 | }) 90 | } 91 | 92 | /** 93 | * Enable/disable the bail mode 94 | */ 95 | bail(toggle: boolean = true) { 96 | this.#bail = toggle 97 | return this 98 | } 99 | 100 | /** 101 | * Run dummy tests. You might use 102 | */ 103 | async runSuites( 104 | suites: (emitter: Emitter, refiner: Refiner, file?: string) => Suite[] 105 | ): Promise { 106 | const runner = new Runner(this.#emitter) 107 | runner.bail(this.#bail) 108 | 109 | await this.#registerPlugins(runner) 110 | 111 | const { config, reporters, refinerFilters } = await new Planner(this.#config!).plan() 112 | const globalHooks = new GlobalHooks() 113 | globalHooks.apply(config) 114 | 115 | reporters.forEach((reporter) => { 116 | runner.registerReporter(reporter) 117 | }) 118 | 119 | refinerFilters.forEach((filter) => { 120 | config.refiner.add(filter.layer, filter.filters) 121 | }) 122 | 123 | suites(this.#emitter, this.#refiner, this.#file).forEach((suite) => runner.add(suite)) 124 | 125 | await globalHooks.setup(runner) 126 | await runner.start() 127 | await runner.exec() 128 | await runner.end() 129 | await globalHooks.teardown(null, runner) 130 | 131 | return runner.getSummary() 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { fileURLToPath } from 'node:url' 11 | import { ErrorsPrinter } from '@japa/errors-printer' 12 | import type { TestExecutor } from '@japa/core/types' 13 | 14 | import debug from './src/debug.js' 15 | import validator from './src/validator.js' 16 | import { Planner } from './src/planner.js' 17 | import { GlobalHooks } from './src/hooks.js' 18 | import { CliParser } from './src/cli_parser.js' 19 | import { retryPlugin } from './src/plugins/retry.js' 20 | import { ConfigManager } from './src/config_manager.js' 21 | import { ExceptionsManager } from './src/exceptions_manager.js' 22 | import { createTest, createTestGroup } from './src/create_test.js' 23 | import type { CLIArgs, Config, NormalizedConfig } from './src/types.js' 24 | import { Emitter, Group, Runner, Suite, Test, TestContext } from './modules/core/main.js' 25 | 26 | type OmitFirstArg = F extends [_: any, ...args: infer R] ? R : never 27 | 28 | /** 29 | * Global emitter instance used by the test 30 | */ 31 | const emitter = new Emitter() 32 | 33 | /** 34 | * The current active test 35 | */ 36 | let activeTest: Test | undefined 37 | 38 | /** 39 | * Parsed commandline arguments 40 | */ 41 | let cliArgs: CLIArgs = {} 42 | 43 | /** 44 | * Hydrated config 45 | */ 46 | let runnerConfig: NormalizedConfig | undefined 47 | 48 | /** 49 | * The state refers to the phase where we configure suites and import 50 | * test files. We stick this metadata to the test instance one can 51 | * later reference within the test. 52 | */ 53 | const executionPlanState: { 54 | phase: 'idle' | 'planning' | 'executing' 55 | file?: string 56 | suite?: Suite 57 | group?: Group 58 | timeout?: number 59 | retries?: number 60 | } = { 61 | phase: 'idle', 62 | } 63 | 64 | /** 65 | * Create a Japa test. Defining a test without the callback 66 | * will create a todo test. 67 | */ 68 | export function test(title: string, callback?: TestExecutor) { 69 | validator.ensureIsInPlanningPhase(executionPlanState.phase) 70 | 71 | const testInstance = createTest(title, emitter, runnerConfig!.refiner, executionPlanState) 72 | testInstance.setup((t) => { 73 | activeTest = t 74 | return () => { 75 | activeTest = undefined 76 | } 77 | }) 78 | 79 | if (callback) { 80 | testInstance.run(callback, new Error()) 81 | } 82 | 83 | return testInstance 84 | } 85 | 86 | /** 87 | * Create a Japa test group 88 | */ 89 | test.group = function (title: string, callback: (group: Group) => void) { 90 | validator.ensureIsInPlanningPhase(executionPlanState.phase) 91 | 92 | executionPlanState.group = createTestGroup( 93 | title, 94 | emitter, 95 | runnerConfig!.refiner, 96 | executionPlanState 97 | ) 98 | 99 | /** 100 | * Enable bail on the group an when bailLayer is set to "group" 101 | */ 102 | if (cliArgs.bail && cliArgs.bailLayer === 'group') { 103 | executionPlanState.group.bail(true) 104 | } 105 | 106 | callback(executionPlanState.group) 107 | executionPlanState.group = undefined 108 | } 109 | 110 | /** 111 | * Create a test bound macro. Within the macro, you can access the 112 | * currently executed test to read its context values or define 113 | * cleanup hooks 114 | */ 115 | test.macro = function any>( 116 | callback: T 117 | ): (...args: OmitFirstArg>) => ReturnType { 118 | return (...args) => { 119 | if (!activeTest) { 120 | throw new Error('Cannot invoke macro outside of the test callback') 121 | } 122 | return callback(activeTest, ...args) 123 | } 124 | } 125 | 126 | /** 127 | * Get the test of currently running test 128 | */ 129 | export function getActiveTest() { 130 | return activeTest 131 | } 132 | 133 | /** 134 | * Make Japa process command line arguments. Later the parsed output 135 | * will be used by Japa to compute the configuration 136 | */ 137 | export function processCLIArgs(argv: string[]) { 138 | cliArgs = new CliParser().parse(argv) 139 | } 140 | 141 | /** 142 | * Configure the tests runner with inline configuration. You must 143 | * call configure method before the run method. 144 | * 145 | * Do note: The CLI flags will overwrite the options provided 146 | * to the configure method. 147 | */ 148 | export function configure(options: Config) { 149 | runnerConfig = new ConfigManager(options, cliArgs).hydrate() 150 | } 151 | 152 | /** 153 | * Execute Japa tests. Calling this function will import the test 154 | * files behind the scenes 155 | */ 156 | export async function run() { 157 | /** 158 | * Display help when help flag is used 159 | */ 160 | if (cliArgs.help) { 161 | console.log(new CliParser().getHelp()) 162 | return 163 | } 164 | 165 | validator.ensureIsConfigured(runnerConfig) 166 | 167 | executionPlanState.phase = 'planning' 168 | const runner = new Runner(emitter) 169 | 170 | /** 171 | * Enable bail on the runner and all the layers after the 172 | * runner when no specific bailLayer is specified 173 | */ 174 | if (cliArgs.bail && cliArgs.bailLayer === '') { 175 | runner.bail(true) 176 | } 177 | 178 | const globalHooks = new GlobalHooks() 179 | const exceptionsManager = new ExceptionsManager() 180 | 181 | try { 182 | /** 183 | * Executing the retry plugin as the first thing 184 | */ 185 | await retryPlugin({ config: runnerConfig!, runner, emitter, cliArgs }) 186 | 187 | /** 188 | * Step 1: Executing plugins before creating a plan, so that it can mutate 189 | * the config 190 | */ 191 | for (let plugin of runnerConfig!.plugins) { 192 | debug('executing "%s" plugin', plugin.name || 'anonymous') 193 | await plugin({ runner, emitter, cliArgs, config: runnerConfig! }) 194 | } 195 | 196 | /** 197 | * Step 2: Creating an execution plan. The output is the result of 198 | * applying all the filters and validations. 199 | */ 200 | const { config, reporters, suites, refinerFilters } = await new Planner(runnerConfig!).plan() 201 | 202 | /** 203 | * Step 3: Registering reporters and filters with the runner 204 | */ 205 | reporters.forEach((reporter) => { 206 | debug('registering "%s" reporter', reporter.name) 207 | runner.registerReporter(reporter) 208 | }) 209 | refinerFilters.forEach((filter) => { 210 | debug('apply %s filters "%O" ', filter.layer, filter.filters) 211 | config.refiner.add(filter.layer, filter.filters) 212 | }) 213 | config.refiner.matchAllTags(cliArgs.matchAll ?? false) 214 | runner.onSuite(config.configureSuite) 215 | 216 | /** 217 | * Step 4: Running the setup hooks 218 | */ 219 | debug('executing global hooks') 220 | globalHooks.apply(config) 221 | await globalHooks.setup(runner) 222 | 223 | /** 224 | * Step 5: Register suites and import test files 225 | */ 226 | for (let suite of suites) { 227 | /** 228 | * Creating and configuring the suite 229 | */ 230 | debug('initiating suite %s', suite.name) 231 | executionPlanState.suite = new Suite(suite.name, emitter, config.refiner) 232 | executionPlanState.retries = suite.retries 233 | executionPlanState.timeout = suite.timeout 234 | if (typeof suite.configure === 'function') { 235 | suite.configure(executionPlanState.suite) 236 | } 237 | 238 | /** 239 | * Enable bail on the suite and all the layers after the 240 | * suite when bailLayer is set to "suite" 241 | */ 242 | if (cliArgs.bail && cliArgs.bailLayer === 'suite') { 243 | debug('enabling bail mode for the suite %s', suite.name) 244 | executionPlanState.suite.bail(true) 245 | } 246 | runner.add(executionPlanState.suite) 247 | 248 | /** 249 | * Importing suite files 250 | */ 251 | for (let fileURL of suite.filesURLs) { 252 | executionPlanState.file = fileURLToPath(fileURL) 253 | debug('importing test file %s', executionPlanState.file) 254 | await config.importer(fileURL) 255 | } 256 | 257 | /** 258 | * Resetting global state 259 | */ 260 | executionPlanState.suite = undefined 261 | } 262 | 263 | /** 264 | * Onto execution phase 265 | */ 266 | executionPlanState.phase = 'executing' 267 | 268 | /** 269 | * Monitor for unhandled erorrs and rejections 270 | */ 271 | exceptionsManager.monitor() 272 | 273 | await runner.start() 274 | await runner.exec() 275 | 276 | await globalHooks.teardown(null, runner) 277 | await runner.end() 278 | 279 | /** 280 | * Print unhandled errors 281 | */ 282 | await exceptionsManager.report() 283 | 284 | const summary = runner.getSummary() 285 | if (summary.hasError || exceptionsManager.hasErrors) { 286 | debug( 287 | 'updating exit code to 1. summary.hasError %s, process.hasError', 288 | summary.hasError, 289 | exceptionsManager.hasErrors 290 | ) 291 | process.exitCode = 1 292 | } 293 | if (config.forceExit) { 294 | debug('force exiting process') 295 | process.exit() 296 | } 297 | } catch (error) { 298 | debug('error running tests %O', error) 299 | await globalHooks.teardown(error, runner) 300 | const printer = new ErrorsPrinter() 301 | await printer.printError(error) 302 | 303 | /** 304 | * Print unhandled errors in case the code inside 305 | * the try block never got triggered 306 | */ 307 | await exceptionsManager.report() 308 | 309 | process.exitCode = 1 310 | if (runnerConfig!.forceExit) { 311 | debug('force exiting process') 312 | process.exit() 313 | } 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /modules/core/main.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { 11 | Emitter, 12 | Refiner, 13 | Test as BaseTest, 14 | Suite as BaseSuite, 15 | Group as BaseGroup, 16 | Runner as BaseRunner, 17 | TestContext as BaseTestContext, 18 | } from '@japa/core' 19 | import { inspect } from 'node:util' 20 | import { AssertionError } from 'node:assert' 21 | import { BaseReporter } from './reporters/base.js' 22 | import type { DataSetNode, TestHooksCleanupHandler } from './types.js' 23 | 24 | declare module '@japa/core' { 25 | interface Test, TestData extends DataSetNode = undefined> { 26 | /** 27 | * Assert the test throws an exception with a certain error message 28 | * and optionally is an instance of a given Error class. 29 | */ 30 | throws(message: string | RegExp, errorConstructor?: any): this 31 | } 32 | interface TestContext { 33 | /** 34 | * Register a cleanup function that runs after the test finishes 35 | * successfully or with an error. 36 | */ 37 | cleanup: (cleanupCallback: TestHooksCleanupHandler) => void 38 | } 39 | } 40 | 41 | export { Emitter, Refiner, BaseReporter } 42 | 43 | /** 44 | * Test context carries context data for a given test. 45 | */ 46 | export class TestContext extends BaseTestContext { 47 | /** 48 | * Register a cleanup function that runs after the test finishes 49 | * successfully or with an error. 50 | */ 51 | declare cleanup: (cleanupCallback: TestHooksCleanupHandler) => void 52 | 53 | constructor(public test: Test) { 54 | super() 55 | this.cleanup = (cleanupCallback: TestHooksCleanupHandler) => { 56 | test.cleanup(cleanupCallback) 57 | } 58 | } 59 | } 60 | 61 | /** 62 | * Test class represents an individual test and exposes API to tweak 63 | * its runtime behavior. 64 | */ 65 | export class Test extends BaseTest< 66 | TestContext, 67 | TestData 68 | > { 69 | /** 70 | * @inheritdoc 71 | */ 72 | static executedCallbacks = [] 73 | 74 | /** 75 | * @inheritdoc 76 | */ 77 | static executingCallbacks = [] 78 | 79 | /** 80 | * Assert the test throws an exception with a certain error message 81 | * and optionally is an instance of a given Error class. 82 | */ 83 | throws(message: string | RegExp, errorConstructor?: any) { 84 | const errorInPoint = new AssertionError({}) 85 | const existingExecutor = this.options.executor 86 | if (!existingExecutor) { 87 | throw new Error('Cannot use "test.throws" method without a test callback') 88 | } 89 | 90 | /** 91 | * Overwriting existing callback 92 | */ 93 | this.options.executor = async (...args: [any, any, any]) => { 94 | let raisedException: any 95 | try { 96 | await existingExecutor(...args) 97 | } catch (error) { 98 | raisedException = error 99 | } 100 | 101 | /** 102 | * Notify no exception has been raised 103 | */ 104 | if (!raisedException) { 105 | errorInPoint.message = 'Expected test to throw an exception' 106 | throw errorInPoint 107 | } 108 | 109 | /** 110 | * Constructor mis-match 111 | */ 112 | if (errorConstructor && !(raisedException instanceof errorConstructor)) { 113 | errorInPoint.message = `Expected test to throw "${inspect(errorConstructor)}"` 114 | throw errorInPoint 115 | } 116 | 117 | /** 118 | * Error does not have a message property 119 | */ 120 | const exceptionMessage: unknown = raisedException.message 121 | if (!exceptionMessage || typeof exceptionMessage !== 'string') { 122 | errorInPoint.message = 'Expected test to throw an exception with message property' 123 | throw errorInPoint 124 | } 125 | 126 | /** 127 | * Message does not match 128 | */ 129 | if (typeof message === 'string') { 130 | if (exceptionMessage !== message) { 131 | errorInPoint.message = `Expected test to throw "${message}". Instead received "${raisedException.message}"` 132 | errorInPoint.actual = raisedException.message 133 | errorInPoint.expected = message 134 | throw errorInPoint 135 | } 136 | return 137 | } 138 | 139 | if (!message.test(exceptionMessage)) { 140 | errorInPoint.message = `Expected test error to match "${message}" regular expression` 141 | throw errorInPoint 142 | } 143 | } 144 | 145 | return this 146 | } 147 | } 148 | 149 | /** 150 | * TestGroup is used to bulk configure a collection of tests and 151 | * define lifecycle hooks for them 152 | */ 153 | export class Group extends BaseGroup {} 154 | 155 | /** 156 | * A suite is a collection of tests created around a given 157 | * testing type. For example: A suite for unit tests, a 158 | * suite for functional tests and so on. 159 | */ 160 | export class Suite extends BaseSuite {} 161 | 162 | /** 163 | * Runner class is used to execute the tests 164 | */ 165 | export class Runner extends BaseRunner {} 166 | -------------------------------------------------------------------------------- /modules/core/reporters/base.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import ms from 'ms' 11 | import { ErrorsPrinter } from '@japa/errors-printer' 12 | 13 | import { Emitter, Runner } from '../main.js' 14 | import { colors } from '../../../src/helpers.js' 15 | import type { 16 | TestEndNode, 17 | SuiteEndNode, 18 | GroupEndNode, 19 | TestStartNode, 20 | RunnerSummary, 21 | RunnerEndNode, 22 | GroupStartNode, 23 | SuiteStartNode, 24 | RunnerStartNode, 25 | BaseReporterOptions, 26 | } from '../types.js' 27 | 28 | /** 29 | * Base reporter to build custom reporters on top of 30 | */ 31 | export abstract class BaseReporter { 32 | runner?: Runner 33 | 34 | /** 35 | * Path to the file for which the tests are getting executed 36 | */ 37 | currentFileName?: string 38 | 39 | /** 40 | * Suite for which the tests are getting executed 41 | */ 42 | currentSuiteName?: string 43 | 44 | /** 45 | * Group for which the tests are getting executed 46 | */ 47 | currentGroupName?: string 48 | 49 | protected options: BaseReporterOptions 50 | 51 | constructor(options: BaseReporterOptions = {}) { 52 | this.options = Object.assign({ stackLinesCount: 2 }, options) 53 | } 54 | 55 | /** 56 | * Pretty prints the aggregates 57 | */ 58 | protected printAggregates(summary: RunnerSummary) { 59 | const tests: string[] = [] 60 | 61 | /** 62 | * Set value for tests row 63 | */ 64 | if (summary.aggregates.passed) { 65 | tests.push(colors.green(`${summary.aggregates.passed} passed`)) 66 | } 67 | if (summary.aggregates.failed) { 68 | tests.push(colors.red(`${summary.aggregates.failed} failed`)) 69 | } 70 | if (summary.aggregates.todo) { 71 | tests.push(colors.cyan(`${summary.aggregates.todo} todo`)) 72 | } 73 | if (summary.aggregates.skipped) { 74 | tests.push(colors.yellow(`${summary.aggregates.skipped} skipped`)) 75 | } 76 | if (summary.aggregates.regression) { 77 | tests.push(colors.magenta(`${summary.aggregates.regression} regression`)) 78 | } 79 | 80 | this.runner!.summaryBuilder.use(() => { 81 | return [ 82 | { 83 | key: colors.dim('Tests'), 84 | value: `${tests.join(', ')} ${colors.dim(`(${summary.aggregates.total})`)}`, 85 | }, 86 | { 87 | key: colors.dim('Time'), 88 | value: colors.dim(ms(summary.duration)), 89 | }, 90 | ] 91 | }) 92 | 93 | console.log(this.runner!.summaryBuilder.build().join('\n')) 94 | } 95 | 96 | /** 97 | * Aggregates errors tree to a flat array 98 | */ 99 | protected aggregateErrors(summary: RunnerSummary) { 100 | const errorsList: { phase: string; title: string; error: Error }[] = [] 101 | 102 | summary.failureTree.forEach((suite) => { 103 | suite.errors.forEach((error) => errorsList.push({ title: suite.name, ...error })) 104 | 105 | suite.children.forEach((testOrGroup) => { 106 | /** 107 | * Suite child is a test 108 | */ 109 | if (testOrGroup.type === 'test') { 110 | testOrGroup.errors.forEach((error) => { 111 | errorsList.push({ title: `${suite.name} / ${testOrGroup.title}`, ...error }) 112 | }) 113 | return 114 | } 115 | 116 | /** 117 | * Suite child is a group 118 | */ 119 | testOrGroup.errors.forEach((error) => { 120 | errorsList.push({ title: testOrGroup.name, ...error }) 121 | }) 122 | testOrGroup.children.forEach((test) => { 123 | test.errors.forEach((error) => { 124 | errorsList.push({ title: `${testOrGroup.name} / ${test.title}`, ...error }) 125 | }) 126 | }) 127 | }) 128 | }) 129 | 130 | return errorsList 131 | } 132 | 133 | /** 134 | * Pretty print errors 135 | */ 136 | protected async printErrors(summary: RunnerSummary) { 137 | if (!summary.failureTree.length) { 138 | return 139 | } 140 | 141 | const errorPrinter = new ErrorsPrinter({ 142 | framesMaxLimit: this.options.framesMaxLimit, 143 | }) 144 | 145 | errorPrinter.printSectionHeader('ERRORS') 146 | await errorPrinter.printErrors(this.aggregateErrors(summary)) 147 | } 148 | 149 | /** 150 | * Handlers to capture events 151 | */ 152 | protected onTestStart(_: TestStartNode): void {} 153 | protected onTestEnd(_: TestEndNode) {} 154 | 155 | protected onGroupStart(_: GroupStartNode) {} 156 | protected onGroupEnd(_: GroupEndNode) {} 157 | 158 | protected onSuiteStart(_: SuiteStartNode) {} 159 | protected onSuiteEnd(_: SuiteEndNode) {} 160 | 161 | protected async start(_: RunnerStartNode) {} 162 | protected async end(_: RunnerEndNode) {} 163 | 164 | /** 165 | * Print tests summary 166 | */ 167 | protected async printSummary(summary: RunnerSummary) { 168 | await this.printErrors(summary) 169 | 170 | console.log('') 171 | if (summary.aggregates.total === 0 && !summary.hasError) { 172 | console.log(colors.bgYellow().black(' NO TESTS EXECUTED ')) 173 | return 174 | } 175 | 176 | if (summary.hasError) { 177 | console.log(colors.bgRed().black(' FAILED ')) 178 | } else { 179 | console.log(colors.bgGreen().black(' PASSED ')) 180 | } 181 | console.log('') 182 | this.printAggregates(summary) 183 | } 184 | 185 | /** 186 | * Invoked by the tests runner when tests are about to start 187 | */ 188 | boot(runner: Runner, emitter: Emitter) { 189 | this.runner = runner 190 | 191 | emitter.on('test:start', (payload) => { 192 | this.currentFileName = payload.meta.fileName 193 | this.onTestStart(payload) 194 | }) 195 | 196 | emitter.on('test:end', (payload) => { 197 | this.onTestEnd(payload) 198 | }) 199 | 200 | emitter.on('group:start', (payload) => { 201 | this.currentGroupName = payload.title 202 | this.currentFileName = payload.meta.fileName 203 | this.onGroupStart(payload) 204 | }) 205 | 206 | emitter.on('group:end', (payload) => { 207 | this.currentGroupName = undefined 208 | this.onGroupEnd(payload) 209 | }) 210 | 211 | emitter.on('suite:start', (payload) => { 212 | this.currentSuiteName = payload.name 213 | this.onSuiteStart(payload) 214 | }) 215 | 216 | emitter.on('suite:end', (payload) => { 217 | this.currentSuiteName = undefined 218 | this.onSuiteEnd(payload) 219 | }) 220 | 221 | emitter.on('runner:start', async (payload) => { 222 | await this.start(payload) 223 | }) 224 | 225 | emitter.on('runner:end', async (payload) => { 226 | await this.end(payload) 227 | }) 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /modules/core/types.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | export * from '@japa/core/types' 11 | 12 | export type BaseReporterOptions = { 13 | framesMaxLimit?: number 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@japa/runner", 3 | "description": "A simple yet powerful testing framework for Node.js", 4 | "version": "4.2.0", 5 | "engines": { 6 | "node": ">=18.16.0" 7 | }, 8 | "main": "build/index.js", 9 | "type": "module", 10 | "files": [ 11 | "build", 12 | "!build/examples", 13 | "!build/tests" 14 | ], 15 | "exports": { 16 | ".": "./build/index.js", 17 | "./types": "./build/src/types.js", 18 | "./reporters": "./build/src/reporters/main.js", 19 | "./factories": "./build/factories/main.js", 20 | "./core": "./build/modules/core/main.js" 21 | }, 22 | "scripts": { 23 | "pretest": "npm run lint", 24 | "test": "cross-env NODE_DEBUG=japa:runner c8 npm run quick:test", 25 | "lint": "eslint .", 26 | "format": "prettier --write .", 27 | "typecheck": "tsc --noEmit", 28 | "clean": "del-cli build", 29 | "precompile": "npm run lint && npm run clean", 30 | "compile": "tsup-node && tsc --emitDeclarationOnly --declaration", 31 | "build": "npm run compile", 32 | "version": "npm run build", 33 | "prepublishOnly": "npm run build", 34 | "release": "release-it", 35 | "quick:test": "glob -c \"node --import=ts-node-maintained/register/esm --enable-source-maps --test-reporter=spec --test\" \"tests/*.spec.ts\"" 36 | }, 37 | "devDependencies": { 38 | "@adonisjs/eslint-config": "^2.0.0-beta.7", 39 | "@adonisjs/prettier-config": "^1.4.0", 40 | "@adonisjs/tsconfig": "^1.4.0", 41 | "@release-it/conventional-changelog": "^10.0.0", 42 | "@swc/core": "1.10.7", 43 | "@types/chai": "^5.0.1", 44 | "@types/chai-subset": "^1.3.5", 45 | "@types/find-cache-dir": "^5.0.2", 46 | "@types/ms": "^2.1.0", 47 | "@types/node": "^22.12.0", 48 | "c8": "^10.1.3", 49 | "chai": "^5.1.2", 50 | "chai-subset": "^1.6.0", 51 | "cross-env": "^7.0.3", 52 | "del-cli": "^6.0.0", 53 | "eslint": "^9.19.0", 54 | "glob": "^11.0.1", 55 | "prettier": "^3.4.2", 56 | "release-it": "^18.1.2", 57 | "ts-node-maintained": "^10.9.5", 58 | "tsup": "^8.3.6", 59 | "typescript": "^5.7.3" 60 | }, 61 | "dependencies": { 62 | "@japa/core": "^10.3.0", 63 | "@japa/errors-printer": "^4.1.2", 64 | "@poppinss/colors": "^4.1.4", 65 | "@poppinss/hooks": "^7.2.5", 66 | "fast-glob": "^3.3.3", 67 | "find-cache-dir": "^5.0.0", 68 | "getopts": "^2.3.0", 69 | "ms": "^2.1.3", 70 | "serialize-error": "^12.0.0", 71 | "slash": "^5.1.0", 72 | "supports-color": "^10.0.0" 73 | }, 74 | "homepage": "https://github.com/japa/runner#readme", 75 | "repository": { 76 | "type": "git", 77 | "url": "git+https://github.com/japa/runner.git" 78 | }, 79 | "bugs": { 80 | "url": "https://github.com/japa/runner/issues" 81 | }, 82 | "keywords": [ 83 | "japa", 84 | "tests", 85 | "test-runner" 86 | ], 87 | "author": "Harminder Virk ", 88 | "license": "MIT", 89 | "publishConfig": { 90 | "access": "public", 91 | "provenance": true 92 | }, 93 | "tsup": { 94 | "entry": [ 95 | "./index.ts", 96 | "./src/types.ts", 97 | "./src/reporters/main.ts", 98 | "./factories/main.ts", 99 | "./modules/core/main.ts" 100 | ], 101 | "outDir": "./build", 102 | "clean": true, 103 | "format": "esm", 104 | "dts": false, 105 | "sourcemap": false, 106 | "target": "esnext" 107 | }, 108 | "release-it": { 109 | "git": { 110 | "requireCleanWorkingDir": true, 111 | "requireUpstream": true, 112 | "commitMessage": "chore(release): ${version}", 113 | "tagAnnotation": "v${version}", 114 | "push": true, 115 | "tagName": "v${version}" 116 | }, 117 | "github": { 118 | "release": true 119 | }, 120 | "npm": { 121 | "publish": true, 122 | "skipChecks": true 123 | }, 124 | "plugins": { 125 | "@release-it/conventional-changelog": { 126 | "preset": { 127 | "name": "angular" 128 | } 129 | } 130 | } 131 | }, 132 | "c8": { 133 | "reporter": [ 134 | "text", 135 | "html" 136 | ], 137 | "exclude": [ 138 | "tests/**", 139 | "tests_helpers/**", 140 | "build/**", 141 | "factories/**", 142 | "modules/core/**", 143 | "src/reporters/**" 144 | ] 145 | }, 146 | "prettier": "@adonisjs/prettier-config" 147 | } 148 | -------------------------------------------------------------------------------- /src/cli_parser.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | // @ts-ignore-error 11 | import getopts from 'getopts' 12 | import { colors } from './helpers.js' 13 | import type { CLIArgs } from './types.js' 14 | 15 | /** 16 | * Known commandline options. The user can still define additional flags and they 17 | * will be parsed aswell, but without any normalization 18 | */ 19 | const OPTIONS = { 20 | string: ['tests', 'groups', 'tags', 'files', 'timeout', 'retries', 'reporters', 'bailLayer'], 21 | boolean: ['help', 'matchAll', 'failed', 'bail'], 22 | alias: { 23 | forceExit: 'force-exit', 24 | matchAll: 'match-all', 25 | bailLayer: 'bail-layer', 26 | help: 'h', 27 | }, 28 | } 29 | 30 | /** 31 | * Help string to display when the `--help flag is used` 32 | */ 33 | const GET_HELP = () => ` 34 | ${colors.yellow('@japa/runner v2.3.0')} 35 | 36 | ${colors.green('--tests')} ${colors.dim('Filter tests by the test title')} 37 | ${colors.green('--groups')} ${colors.dim('Filter tests by the group title')} 38 | ${colors.green('--tags')} ${colors.dim('Filter tests by tags')} 39 | ${colors.green('--match-all')} ${colors.dim('Run tests that matches all the supplied tags')} 40 | ${colors.green('--files')} ${colors.dim('Filter tests by the file name')} 41 | ${colors.green('--force-exit')} ${colors.dim('Forcefully exit the process')} 42 | ${colors.green('--timeout')} ${colors.dim('Define default timeout for all tests')} 43 | ${colors.green('--retries')} ${colors.dim('Define default retries for all tests')} 44 | ${colors.green('--reporters')} ${colors.dim('Activate one or more test reporters')} 45 | ${colors.green('--failed')} ${colors.dim('Run tests failed during the last run')} 46 | ${colors.green('--bail')} ${colors.dim('Exit early when a test fails')} 47 | ${colors.green('--bail-layer')} ${colors.dim('Specify at which layer to enable the bail mode. Can be "group" or "suite"')} 48 | ${colors.green('-h, --help')} ${colors.dim('View help')} 49 | 50 | ${colors.yellow('Examples:')} 51 | ${colors.dim('node bin/test.js --tags="@github"')} 52 | ${colors.dim('node bin/test.js --tags="~@github"')} 53 | ${colors.dim('node bin/test.js --tags="@github,@slow,@integration" --match-all')} 54 | ${colors.dim('node bin/test.js --force-exit')} 55 | ${colors.dim('node bin/test.js --files="user"')} 56 | ${colors.dim('node bin/test.js --files="functional/user"')} 57 | ${colors.dim('node bin/test.js --files="unit/user"')} 58 | ${colors.dim('node bin/test.js --failed')} 59 | ${colors.dim('node bin/test.js --bail')} 60 | ${colors.dim('node bin/test.js --bail=group')} 61 | 62 | ${colors.yellow('Notes:')} 63 | - When groups and tests filters are applied together. We will first filter the 64 | tests by group title and then apply the tests filter. 65 | - The timeout defined on test object takes precedence over the ${colors.green('--timeout')} flag. 66 | - The retries defined on test object takes precedence over the ${colors.green('--retries')} flag. 67 | - The ${colors.green('--files')} flag checks for the file names ending with the filter substring. 68 | - The ${colors.green('--tags')} filter runs tests that has one or more of the supplied tags. 69 | - You can use the ${colors.green('--match-all')} flag to run tests that has all the supplied tags. 70 | ` 71 | 72 | /** 73 | * CLI Parser is used to parse the commandline argument 74 | */ 75 | export class CliParser { 76 | /** 77 | * Parses command-line arguments 78 | */ 79 | parse(argv: string[]): CLIArgs { 80 | return getopts(argv, OPTIONS) 81 | } 82 | 83 | /** 84 | * Returns the help string 85 | */ 86 | getHelp() { 87 | return GET_HELP() 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/config_manager.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import debug from './debug.js' 11 | import { Refiner } from '../modules/core/main.js' 12 | import { dot, github, ndjson, spec } from './reporters/main.js' 13 | import type { CLIArgs, Config, Filters, NormalizedBaseConfig, NormalizedConfig } from './types.js' 14 | 15 | export const NOOP = () => {} 16 | 17 | /** 18 | * Defaults to use for configuration 19 | */ 20 | const DEFAULTS = { 21 | files: [], 22 | timeout: 2000, 23 | retries: 0, 24 | forceExit: false, 25 | plugins: [], 26 | reporters: { 27 | activated: ['spec'].concat(process.env.GITHUB_ACTIONS === 'true' ? ['github'] : []), 28 | list: [spec(), ndjson(), dot(), github()], 29 | }, 30 | importer: (filePath) => import(filePath.href), 31 | configureSuite: () => {}, 32 | } satisfies Config 33 | 34 | /** 35 | * Config manager is used to hydrate the configuration by merging 36 | * the defaults with the user defined config and the command line 37 | * flags. 38 | * 39 | * The command line flags have the upmost priority 40 | */ 41 | export class ConfigManager { 42 | #config: Config 43 | #cliArgs: CLIArgs 44 | 45 | constructor(config: Config, cliArgs: CLIArgs) { 46 | this.#config = config 47 | this.#cliArgs = cliArgs 48 | } 49 | 50 | /** 51 | * Processes a CLI argument and converts it to an 52 | * array of strings 53 | */ 54 | #processAsArray(value: string | string[], splitByComma: boolean): string[] { 55 | return Array.isArray(value) 56 | ? value 57 | : splitByComma 58 | ? value.split(',').map((item: string) => item.trim()) 59 | : [value] 60 | } 61 | 62 | /** 63 | * Returns a copy of filters based upon the CLI 64 | * arguments. 65 | */ 66 | #getCLIFilters(): Filters { 67 | const filters: Filters = {} 68 | 69 | if (this.#cliArgs.tags) { 70 | filters.tags = this.#processAsArray(this.#cliArgs.tags, true) 71 | } 72 | if (this.#cliArgs.tests) { 73 | filters.tests = this.#processAsArray(this.#cliArgs.tests, false) 74 | } 75 | if (this.#cliArgs.files) { 76 | filters.files = this.#processAsArray(this.#cliArgs.files, true) 77 | } 78 | if (this.#cliArgs.groups) { 79 | filters.groups = this.#processAsArray(this.#cliArgs.groups, false) 80 | } 81 | if (this.#cliArgs._ && this.#cliArgs._.length) { 82 | filters.suites = this.#processAsArray(this.#cliArgs._, true) 83 | } 84 | 85 | return filters 86 | } 87 | 88 | /** 89 | * Returns the timeout from the CLI args 90 | */ 91 | #getCLITimeout(): number | undefined { 92 | if (this.#cliArgs.timeout) { 93 | const value = Number(this.#cliArgs.timeout) 94 | if (!Number.isNaN(value)) { 95 | return value 96 | } 97 | } 98 | } 99 | 100 | /** 101 | * Returns the retries from the CLI args 102 | */ 103 | #getCLIRetries(): number | undefined { 104 | if (this.#cliArgs.retries) { 105 | const value = Number(this.#cliArgs.retries) 106 | if (!Number.isNaN(value)) { 107 | return value 108 | } 109 | } 110 | } 111 | 112 | /** 113 | * Returns the forceExit property from the CLI args 114 | */ 115 | #getCLIForceExit(): boolean | undefined { 116 | if (this.#cliArgs.forceExit) { 117 | return true 118 | } 119 | } 120 | 121 | /** 122 | * Returns reporters selected using the commandline 123 | * --reporter flag 124 | */ 125 | #getCLIReporters(): string[] | undefined { 126 | if (this.#cliArgs.reporters) { 127 | return this.#processAsArray(this.#cliArgs.reporters, true) 128 | } 129 | } 130 | 131 | /** 132 | * Hydrates the config with user defined options and the 133 | * command-line flags. 134 | */ 135 | hydrate(): NormalizedConfig { 136 | const cliFilters = this.#getCLIFilters() 137 | const cliRetries = this.#getCLIRetries() 138 | const cliTimeout = this.#getCLITimeout() 139 | const cliReporters = this.#getCLIReporters() 140 | const cliForceExit = this.#getCLIForceExit() 141 | 142 | debug('filters applied using CLI flags %O', cliFilters) 143 | 144 | const baseConfig: NormalizedBaseConfig = { 145 | cwd: this.#config.cwd ?? process.cwd(), 146 | exclude: this.#config.exclude || ['node_modules/**', '.git/**', 'coverage/**'], 147 | filters: Object.assign({}, this.#config.filters ?? {}, cliFilters), 148 | importer: this.#config.importer ?? DEFAULTS.importer, 149 | refiner: this.#config.refiner ?? new Refiner(), 150 | retries: cliRetries ?? this.#config.retries ?? DEFAULTS.retries, 151 | timeout: cliTimeout ?? this.#config.timeout ?? DEFAULTS.timeout, 152 | plugins: this.#config.plugins ?? DEFAULTS.plugins, 153 | forceExit: cliForceExit ?? this.#config.forceExit ?? DEFAULTS.forceExit, 154 | reporters: this.#config.reporters 155 | ? { 156 | activated: this.#config.reporters.activated, 157 | list: this.#config.reporters.list || DEFAULTS.reporters.list, 158 | } 159 | : DEFAULTS.reporters, 160 | configureSuite: this.#config.configureSuite ?? DEFAULTS.configureSuite, 161 | setup: this.#config.setup || [], 162 | teardown: this.#config.teardown || [], 163 | } 164 | 165 | /** 166 | * Overwrite activated reporters when defined using CLI 167 | * flag 168 | */ 169 | if (cliReporters) { 170 | baseConfig.reporters.activated = cliReporters 171 | } 172 | 173 | if ('files' in this.#config) { 174 | return { 175 | files: this.#config.files, 176 | ...baseConfig, 177 | } 178 | } 179 | 180 | return { 181 | suites: this.#config.suites.map((suite) => { 182 | return { 183 | name: suite.name, 184 | files: suite.files, 185 | timeout: cliTimeout ?? suite.timeout ?? baseConfig.timeout, 186 | retries: cliRetries ?? suite.retries ?? baseConfig.retries, 187 | configure: suite.configure || NOOP, 188 | } 189 | }), 190 | ...baseConfig, 191 | } 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/create_test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { Emitter, Group, Refiner, Suite, Test, TestContext } from '../modules/core/main.js' 11 | 12 | /** 13 | * Function to create the test context for the test 14 | */ 15 | const contextBuilder = (testInstance: Test) => new TestContext(testInstance) 16 | 17 | /** 18 | * Create a new instance of the Test 19 | */ 20 | export function createTest( 21 | title: string, 22 | emitter: Emitter, 23 | refiner: Refiner, 24 | options: { 25 | group?: Group 26 | suite?: Suite 27 | file?: string 28 | timeout?: number 29 | retries?: number 30 | } 31 | ) { 32 | const testInstance = new Test(title, contextBuilder, emitter, refiner, options.group) 33 | testInstance.options.meta.suite = options.suite 34 | testInstance.options.meta.group = options.group 35 | testInstance.options.meta.fileName = options.file 36 | 37 | if (options.timeout !== undefined) { 38 | testInstance.timeout(options.timeout) 39 | } 40 | if (options.retries !== undefined) { 41 | testInstance.retry(options.retries) 42 | } 43 | 44 | /** 45 | * Register test as a child either with the group or the suite 46 | */ 47 | if (options.group) { 48 | options.group.add(testInstance) 49 | } else if (options.suite) { 50 | options.suite.add(testInstance) 51 | } 52 | 53 | return testInstance 54 | } 55 | 56 | /** 57 | * Create a new instance of the Group 58 | */ 59 | export function createTestGroup( 60 | title: string, 61 | emitter: Emitter, 62 | refiner: Refiner, 63 | options: { 64 | group?: Group 65 | suite?: Suite 66 | file?: string 67 | timeout?: number 68 | retries?: number 69 | } 70 | ) { 71 | if (options.group) { 72 | throw new Error('Nested groups are not supported by Japa') 73 | } 74 | 75 | const group = new Group(title, emitter, refiner) 76 | group.options.meta.suite = options.suite 77 | group.options.meta.fileName = options.file 78 | 79 | if (options.suite) { 80 | options.suite.add(group) 81 | } 82 | 83 | return group 84 | } 85 | -------------------------------------------------------------------------------- /src/debug.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { debuglog } from 'node:util' 11 | export default debuglog('japa:runner') 12 | -------------------------------------------------------------------------------- /src/exceptions_manager.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { ErrorsPrinter } from '@japa/errors-printer' 11 | import debug from './debug.js' 12 | 13 | /** 14 | * Handles uncaught exceptions and prints them to the 15 | * console 16 | */ 17 | export class ExceptionsManager { 18 | #exceptionsBuffer: any[] = [] 19 | #rejectionsBuffer: any[] = [] 20 | #state: 'watching' | 'reporting' = 'watching' 21 | #errorsPrinter = new ErrorsPrinter({ stackLinesCount: 2, framesMaxLimit: 4 }) 22 | 23 | hasErrors: boolean = false 24 | 25 | /** 26 | * Monitors unhandled exceptions and rejections. The exceptions 27 | * are stacked in a buffer, so that we do not clutter the 28 | * tests output and once the tests are over, we will 29 | * print them to the console. 30 | * 31 | * In case the tests are completed, we will print errors as they 32 | * happen. 33 | */ 34 | monitor() { 35 | process.on('uncaughtException', async (error) => { 36 | debug('received uncaught exception %O', error) 37 | this.hasErrors = true 38 | if (this.#state === 'watching') { 39 | this.#exceptionsBuffer.push(error) 40 | } else { 41 | this.#errorsPrinter.printSectionBorder('[Unhandled Error]') 42 | await this.#errorsPrinter.printError(error) 43 | process.exitCode = 1 44 | } 45 | }) 46 | 47 | process.on('unhandledRejection', async (error) => { 48 | debug('received unhandled rejection %O', error) 49 | this.hasErrors = true 50 | if (this.#state === 'watching') { 51 | this.#rejectionsBuffer.push(error) 52 | } else { 53 | this.#errorsPrinter.printSectionBorder('[Unhandled Rejection]') 54 | await this.#errorsPrinter.printError(error) 55 | process.exitCode = 1 56 | } 57 | }) 58 | } 59 | 60 | async report() { 61 | if (this.#state === 'reporting') { 62 | return 63 | } 64 | 65 | this.#state = 'reporting' 66 | 67 | /** 68 | * Print exceptions 69 | */ 70 | if (this.#exceptionsBuffer.length) { 71 | let exceptionsCount = this.#exceptionsBuffer.length 72 | let exceptionsIndex = this.#exceptionsBuffer.length 73 | this.#errorsPrinter.printSectionHeader('Unhandled Errors') 74 | for (let exception of this.#exceptionsBuffer) { 75 | await this.#errorsPrinter.printError(exception) 76 | this.#errorsPrinter.printSectionBorder(`[${++exceptionsIndex}/${exceptionsCount}]`) 77 | } 78 | this.#exceptionsBuffer = [] 79 | } 80 | 81 | /** 82 | * Print rejections 83 | */ 84 | if (this.#rejectionsBuffer.length) { 85 | let rejectionsCount = this.#exceptionsBuffer.length 86 | let rejectionsIndex = this.#exceptionsBuffer.length 87 | this.#errorsPrinter.printSectionBorder('Unhandled Rejections') 88 | for (let rejection of this.#rejectionsBuffer) { 89 | await this.#errorsPrinter.printError(rejection) 90 | this.#errorsPrinter.printSectionBorder(`[${++rejectionsIndex}/${rejectionsCount}]`) 91 | } 92 | this.#rejectionsBuffer = [] 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/files_manager.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import slash from 'slash' 11 | import fastGlob from 'fast-glob' 12 | import { pathToFileURL } from 'node:url' 13 | import type { TestFiles } from './types.js' 14 | 15 | /** 16 | * The expression to remove file extension and optionally 17 | * .spec|.test from the test file name 18 | */ 19 | const FILE_SUFFIX_EXPRESSION = /(\.spec|\.test)?\.[js|ts|jsx|tsx|mjs|mts|cjs|cts]+$/ 20 | 21 | /** 22 | * Files manager exposes the API to collect, filter and import test 23 | * files based upon the config 24 | */ 25 | export class FilesManager { 26 | /** 27 | * Returns a collection of files from the user defined 28 | * glob or the implementation function 29 | */ 30 | async getFiles(cwd: string, files: TestFiles, excludes: string[]): Promise { 31 | if (Array.isArray(files) || typeof files === 'string') { 32 | const testFiles = await fastGlob(files, { 33 | absolute: true, 34 | onlyFiles: true, 35 | cwd: cwd, 36 | ignore: excludes, 37 | }) 38 | return testFiles.map((file) => pathToFileURL(file)) 39 | } 40 | 41 | return await files() 42 | } 43 | 44 | /** 45 | * Applies file name filter on a collection of file 46 | * URLs 47 | */ 48 | grep(files: URL[], filters: string[]): URL[] { 49 | return files.filter((file) => { 50 | const filename = slash(file.pathname) 51 | const filenameWithoutTestSuffix = filename.replace(FILE_SUFFIX_EXPRESSION, '') 52 | 53 | return !!filters.find((filter) => { 54 | if (filename.endsWith(filter)) { 55 | return true 56 | } 57 | 58 | const filterSegments = filter.split('/').reverse() 59 | const fileSegments = filenameWithoutTestSuffix.split('/').reverse() 60 | 61 | return filterSegments.every((segment, index) => { 62 | return fileSegments[index] && (segment === '*' || fileSegments[index].endsWith(segment)) 63 | }) 64 | }) 65 | }) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import useColors from '@poppinss/colors' 11 | import supportsColor from 'supports-color' 12 | import { Colors } from '@poppinss/colors/types' 13 | 14 | export const colors: Colors = supportsColor.stdout ? useColors.ansi() : useColors.silent() 15 | 16 | /** 17 | * A collection of platform specific icons 18 | */ 19 | export const icons = 20 | process.platform === 'win32' && !process.env.WT_SESSION 21 | ? { 22 | tick: '√', 23 | cross: '×', 24 | bullet: '*', 25 | nodejs: '♦', 26 | pointer: '>', 27 | info: 'i', 28 | warning: '‼', 29 | branch: ' -', 30 | squareSmallFilled: '[█]', 31 | } 32 | : { 33 | tick: '✔', 34 | cross: '✖', 35 | bullet: '●', 36 | nodejs: '⬢', 37 | pointer: '❯', 38 | info: 'ℹ', 39 | warning: '⚠', 40 | branch: '└──', 41 | squareSmallFilled: '◼', 42 | } 43 | -------------------------------------------------------------------------------- /src/hooks.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import Hooks from '@poppinss/hooks' 11 | import type { Runner as HooksRunner } from '@poppinss/hooks/types' 12 | 13 | import { Runner } from '../modules/core/main.js' 14 | import type { HooksEvents, SetupHookState, NormalizedConfig, TeardownHookState } from './types.js' 15 | 16 | /** 17 | * Exposes API for working with global hooks 18 | */ 19 | export class GlobalHooks { 20 | #hooks = new Hooks() 21 | #setupRunner: HooksRunner | undefined 22 | #teardownRunner: HooksRunner | undefined 23 | 24 | /** 25 | * Apply hooks from the config 26 | */ 27 | apply(config: NormalizedConfig) { 28 | config.setup.forEach((hook) => this.#hooks.add('setup', hook)) 29 | config.teardown.forEach((hook) => this.#hooks.add('teardown', hook)) 30 | } 31 | 32 | /** 33 | * Perform setup 34 | */ 35 | async setup(runner: Runner) { 36 | this.#setupRunner = this.#hooks.runner('setup') 37 | this.#teardownRunner = this.#hooks.runner('teardown') 38 | await this.#setupRunner.run(runner) 39 | } 40 | 41 | /** 42 | * Perform cleanup 43 | */ 44 | async teardown(error: Error | null, runner: Runner) { 45 | if (this.#setupRunner) { 46 | await this.#setupRunner.cleanup(error, runner) 47 | } 48 | if (this.#teardownRunner) { 49 | if (!error) { 50 | await this.#teardownRunner.run(runner) 51 | } 52 | await this.#teardownRunner.cleanup(error, runner) 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/planner.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import validator from './validator.js' 11 | import { FilesManager } from './files_manager.js' 12 | import type { NamedReporterContract, NormalizedConfig, TestFiles, TestSuite } from './types.js' 13 | 14 | /** 15 | * The tests planner is used to plan the tests by doing all 16 | * the heavy lifting of executing plugins, registering 17 | * reporters, filtering tests and so on. 18 | */ 19 | export class Planner { 20 | #config: NormalizedConfig 21 | #fileManager = new FilesManager() 22 | 23 | constructor(config: NormalizedConfig) { 24 | validator.validateActivatedReporters(config!) 25 | validator.validateSuitesFilter(config!) 26 | validator.validateSuitesForUniqueness(config!) 27 | this.#config = config 28 | } 29 | 30 | /** 31 | * Returns a list of reporters based upon the activated 32 | * reporters list. 33 | */ 34 | #getActivatedReporters(): NamedReporterContract[] { 35 | return this.#config.reporters.activated.map((activated) => { 36 | return this.#config.reporters.list.find(({ name }) => activated === name)! 37 | }) 38 | } 39 | 40 | /** 41 | * A generic method to collect files from the user defined 42 | * files glob and apply the files filter 43 | */ 44 | async #collectFiles(files: TestFiles) { 45 | let filesURLs = await this.#fileManager.getFiles(this.#config.cwd, files, this.#config.exclude) 46 | if (this.#config.filters.files && this.#config.filters.files.length) { 47 | filesURLs = this.#fileManager.grep(filesURLs, this.#config.filters.files) 48 | } 49 | 50 | return filesURLs 51 | } 52 | 53 | /** 54 | * Returns a collection of suites and their associated 55 | * test files by applying all the filters 56 | */ 57 | async #getSuites(): Promise<(TestSuite & { filesURLs: URL[] })[]> { 58 | let suites: (TestSuite & { filesURLs: URL[] })[] = [] 59 | let suitesFilters = this.#config.filters.suites || [] 60 | 61 | if ('files' in this.#config) { 62 | suites.push({ 63 | name: 'default', 64 | files: this.#config.files, 65 | timeout: this.#config.timeout, 66 | retries: this.#config.retries, 67 | filesURLs: await this.#collectFiles(this.#config.files), 68 | }) 69 | } 70 | 71 | if ('suites' in this.#config) { 72 | for (let suite of this.#config.suites) { 73 | if (!suitesFilters.length || suitesFilters.includes(suite.name)) { 74 | suites.push({ 75 | ...suite, 76 | filesURLs: await this.#collectFiles(suite.files), 77 | }) 78 | } 79 | } 80 | } 81 | 82 | return suites 83 | } 84 | 85 | /** 86 | * Returns a list of filters to the passed to the refiner 87 | */ 88 | #getRefinerFilters() { 89 | return Object.keys(this.#config.filters).reduce( 90 | (result, layer) => { 91 | if (layer === 'tests' || layer === 'tags' || layer === 'groups') { 92 | result.push({ layer, filters: this.#config.filters[layer]! }) 93 | } 94 | return result 95 | }, 96 | [] as { layer: 'tags' | 'tests' | 'groups'; filters: string[] }[] 97 | ) 98 | } 99 | 100 | /** 101 | * Creates a plan for running the tests 102 | */ 103 | async plan() { 104 | const suites = await this.#getSuites() 105 | const reporters = this.#getActivatedReporters() 106 | const refinerFilters = this.#getRefinerFilters() 107 | return { 108 | reporters, 109 | suites, 110 | refinerFilters, 111 | config: this.#config, 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/plugins/retry.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { join } from 'node:path' 11 | import findCacheDirectory from 'find-cache-dir' 12 | import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises' 13 | 14 | import { colors } from '../helpers.js' 15 | import type { PluginFn } from '../types.js' 16 | 17 | /** 18 | * Paths to the cache directory and the summary file 19 | */ 20 | const CACHE_DIR = findCacheDirectory({ name: '@japa/runner' }) 21 | const SUMMARY_FILE = CACHE_DIR ? join(CACHE_DIR, 'summary.json') : undefined 22 | 23 | /** 24 | * Returns an object with the title of the tests failed during 25 | * the last run. 26 | */ 27 | export async function getFailedTests(): Promise<{ tests?: string[] }> { 28 | try { 29 | const summary = await readFile(SUMMARY_FILE!, 'utf-8') 30 | return JSON.parse(summary) 31 | } catch (error) { 32 | if (error.code === 'ENOENT') { 33 | return {} 34 | } 35 | throw new Error('Unable to read failed tests cache file', { cause: error }) 36 | } 37 | } 38 | 39 | /** 40 | * Writes failing tests to the cache directory 41 | */ 42 | export async function cacheFailedTests(tests: string[]) { 43 | await mkdir(CACHE_DIR!, { recursive: true }) 44 | await writeFile(SUMMARY_FILE!, JSON.stringify({ tests: tests })) 45 | } 46 | 47 | /** 48 | * Clears the cache dir 49 | */ 50 | export async function clearCache() { 51 | await unlink(SUMMARY_FILE!) 52 | } 53 | 54 | /** 55 | * Exposes the API to run failing tests using the "failed" CLI flag. 56 | */ 57 | export const retryPlugin: PluginFn = async function retry({ config, cliArgs }) { 58 | if (!SUMMARY_FILE) { 59 | return 60 | } 61 | 62 | config.teardown.push(async (runner) => { 63 | const summary = runner.getSummary() 64 | await cacheFailedTests(summary.failedTestsTitles) 65 | }) 66 | 67 | if (cliArgs.failed) { 68 | try { 69 | const { tests } = await getFailedTests() 70 | if (!tests || !tests.length) { 71 | console.log(colors.bgYellow().black(' No failing tests found. Running all the tests ')) 72 | return 73 | } 74 | config.filters.tests = tests 75 | } catch (error) { 76 | console.log(colors.bgRed().black(' Unable to read failed tests. Running all the tests ')) 77 | console.log(colors.red(error)) 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/reporters/dot.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { colors, icons } from '../helpers.js' 11 | import type { TestEndNode } from '../../modules/core/types.js' 12 | import { BaseReporter } from '../../modules/core/reporters/base.js' 13 | 14 | /** 15 | * Minimal reporter that prints each test as an icon. 16 | */ 17 | export class DotReporter extends BaseReporter { 18 | /** 19 | * When a test ended 20 | */ 21 | protected onTestEnd(payload: TestEndNode) { 22 | let output = '' 23 | if (payload.isTodo) { 24 | output = colors.cyan(icons.info) 25 | } else if (payload.hasError) { 26 | output = colors.red(icons.cross) 27 | } else if (payload.isSkipped) { 28 | output = colors.yellow(icons.bullet) 29 | } else if (payload.isFailing) { 30 | output = colors.magenta(icons.squareSmallFilled) 31 | } else { 32 | output = colors.green(icons.tick) 33 | } 34 | 35 | process.stdout.write(`${output}`) 36 | } 37 | 38 | /** 39 | * When test runner ended 40 | */ 41 | protected async end() { 42 | console.log('') 43 | await this.printSummary(this.runner!.getSummary()) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/reporters/github.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import slash from 'slash' 11 | import { relative } from 'node:path' 12 | import { stripVTControlCharacters } from 'node:util' 13 | import { ErrorsPrinter } from '@japa/errors-printer' 14 | import { BaseReporter } from '../../modules/core/main.js' 15 | 16 | /** 17 | * Prints annotations when executing tests within Github actions 18 | */ 19 | export class GithubReporter extends BaseReporter { 20 | /** 21 | * Performs string escape on annotation message as per 22 | * https://github.com/actions/toolkit/blob/f1d9b4b985e6f0f728b4b766db73498403fd5ca3/packages/core/src/command.ts#L80-L85 23 | */ 24 | protected escapeMessage(value: string): string { 25 | return value.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A') 26 | } 27 | 28 | /** 29 | * Performs string escape on annotation properties as per 30 | * https://github.com/actions/toolkit/blob/f1d9b4b985e6f0f728b4b766db73498403fd5ca3/packages/core/src/command.ts#L80-L85 31 | */ 32 | protected escapeProperty(value: string): string { 33 | return value 34 | .replace(/%/g, '%25') 35 | .replace(/\r/g, '%0D') 36 | .replace(/\n/g, '%0A') 37 | .replace(/:/g, '%3A') 38 | .replace(/,/g, '%2C') 39 | } 40 | 41 | /** 42 | * Formats the message as per the Github annotations spec. 43 | * https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-error-message 44 | */ 45 | protected formatMessage({ 46 | command, 47 | properties, 48 | message, 49 | }: { 50 | command: string 51 | properties: Record 52 | message: string 53 | }): string { 54 | let result = `::${command}` 55 | Object.entries(properties).forEach(([k, v], i) => { 56 | result += i === 0 ? ' ' : ',' 57 | result += `${k}=${this.escapeProperty(v)}` 58 | }) 59 | result += `::${this.escapeMessage(message)}` 60 | return result 61 | } 62 | 63 | /** 64 | * Prints Github annotation for a given error 65 | */ 66 | async getErrorAnnotation( 67 | printer: ErrorsPrinter, 68 | error: { phase: string; title: string; error: Error } 69 | ) { 70 | const parsedError = await printer.parseError(error.error) 71 | if (!('frames' in parsedError)) { 72 | return 73 | } 74 | 75 | const mainFrame = parsedError.frames.find((frame) => frame.type === 'app') 76 | if (!mainFrame) { 77 | return 78 | } 79 | 80 | return this.formatMessage({ 81 | command: 'error', 82 | properties: { 83 | file: slash(relative(process.cwd(), mainFrame.fileName!)), 84 | title: error.title, 85 | line: String(mainFrame.lineNumber!), 86 | column: String(mainFrame.columnNumber!), 87 | }, 88 | message: stripVTControlCharacters(parsedError.message), 89 | }) 90 | } 91 | 92 | async end() { 93 | const summary = this.runner!.getSummary() 94 | const errorsList = this.aggregateErrors(summary) 95 | const errorPrinter = new ErrorsPrinter(this.options) 96 | 97 | for (let error of errorsList) { 98 | const formatted = await this.getErrorAnnotation(errorPrinter, error) 99 | if (formatted) { 100 | console.log(`\n${formatted}`) 101 | } 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/reporters/main.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { DotReporter } from './dot.js' 11 | import { SpecReporter } from './spec.js' 12 | import { NdJSONReporter } from './ndjson.js' 13 | import type { BaseReporterOptions, NamedReporterContract } from '../types.js' 14 | import { GithubReporter } from './github.js' 15 | 16 | /** 17 | * Create an instance of the spec reporter 18 | */ 19 | export const spec: (options?: BaseReporterOptions) => NamedReporterContract = (options) => { 20 | return { 21 | name: 'spec', 22 | handler: (...args) => new SpecReporter(options).boot(...args), 23 | } 24 | } 25 | 26 | /** 27 | * Create an instance of the dot reporter 28 | */ 29 | export const dot: (options?: BaseReporterOptions) => NamedReporterContract = (options) => { 30 | return { 31 | name: 'dot', 32 | handler: (...args) => new DotReporter(options).boot(...args), 33 | } 34 | } 35 | 36 | /** 37 | * Create an instance of the ndjson reporter 38 | */ 39 | export const ndjson: (options?: BaseReporterOptions) => NamedReporterContract = (options) => { 40 | return { 41 | name: 'ndjson', 42 | handler: (...args) => new NdJSONReporter(options).boot(...args), 43 | } 44 | } 45 | 46 | /** 47 | * Create an instance of the github reporter 48 | */ 49 | export const github: (options?: BaseReporterOptions) => NamedReporterContract = (options) => { 50 | return { 51 | name: 'github', 52 | handler: (...args) => new GithubReporter(options).boot(...args), 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/reporters/ndjson.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { relative } from 'node:path' 11 | import { serializeError } from 'serialize-error' 12 | 13 | import { BaseReporter } from '../../modules/core/main.js' 14 | import type { 15 | TestEndNode, 16 | SuiteEndNode, 17 | GroupEndNode, 18 | SuiteStartNode, 19 | GroupStartNode, 20 | } from '../../modules/core/types.js' 21 | 22 | /** 23 | * Prints tests progress as JSON. Each event is emitted 24 | * independently 25 | */ 26 | export class NdJSONReporter extends BaseReporter { 27 | /** 28 | * Returns the filename relative from the current working dir 29 | */ 30 | #getRelativeFilename(fileName: string) { 31 | return relative(process.cwd(), fileName) 32 | } 33 | 34 | /** 35 | * Serialize errors to JSON 36 | */ 37 | #serializeErrors( 38 | errors: TestEndNode['errors'] | GroupEndNode['errors'] | SuiteEndNode['errors'] 39 | ) { 40 | return errors.map((error) => ({ 41 | phase: error.phase, 42 | error: serializeError(error.error), 43 | })) 44 | } 45 | 46 | protected onTestEnd(payload: TestEndNode): void { 47 | console.log( 48 | JSON.stringify({ 49 | event: 'test:end', 50 | filePath: this.currentFileName, 51 | relativePath: this.currentFileName 52 | ? this.#getRelativeFilename(this.currentFileName) 53 | : undefined, 54 | title: payload.title, 55 | duration: payload.duration, 56 | failReason: payload.failReason, 57 | isFailing: payload.isFailing, 58 | skipReason: payload.skipReason, 59 | isSkipped: payload.isSkipped, 60 | isTodo: payload.isTodo, 61 | isPinned: payload.isPinned, 62 | retryAttempt: payload.retryAttempt, 63 | retries: payload.retries, 64 | errors: this.#serializeErrors(payload.errors), 65 | }) 66 | ) 67 | } 68 | 69 | protected onGroupStart(payload: GroupStartNode): void { 70 | console.log( 71 | JSON.stringify({ 72 | event: 'group:start', 73 | title: payload.title, 74 | }) 75 | ) 76 | } 77 | 78 | protected onGroupEnd(payload: GroupEndNode): void { 79 | JSON.stringify({ 80 | event: 'group:end', 81 | title: payload.title, 82 | errors: this.#serializeErrors(payload.errors), 83 | }) 84 | } 85 | 86 | protected onSuiteStart(payload: SuiteStartNode): void { 87 | console.log( 88 | JSON.stringify({ 89 | event: 'suite:start', 90 | ...payload, 91 | }) 92 | ) 93 | } 94 | 95 | protected onSuiteEnd(payload: SuiteEndNode): void { 96 | console.log( 97 | JSON.stringify({ 98 | event: 'suite:end', 99 | name: payload.name, 100 | hasError: payload.hasError, 101 | errors: this.#serializeErrors(payload.errors), 102 | }) 103 | ) 104 | } 105 | 106 | protected async end() { 107 | const summary = this.runner!.getSummary() 108 | console.log( 109 | JSON.stringify({ 110 | aggregates: summary.aggregates, 111 | duration: summary.duration, 112 | failedTestsTitles: summary.failedTestsTitles, 113 | hasError: summary.hasError, 114 | }) 115 | ) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/reporters/spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import ms from 'ms' 11 | import { relative } from 'node:path' 12 | 13 | import { colors, icons } from '../helpers.js' 14 | import { BaseReporter } from '../../modules/core/main.js' 15 | import { GroupStartNode, TestEndNode } from '../../modules/core/types.js' 16 | 17 | /** 18 | * Pretty prints the tests on the console 19 | */ 20 | export class SpecReporter extends BaseReporter { 21 | /** 22 | * Tracking if the first event we get is for a test without any parent group 23 | * We need this to decide the display style for tests without groups. 24 | */ 25 | #isFirstLoneTest = true 26 | 27 | /** 28 | * Returns the icon for the test 29 | */ 30 | #getTestIcon(payload: TestEndNode) { 31 | if (payload.isTodo) { 32 | return colors.cyan(icons.info) 33 | } 34 | 35 | if (payload.hasError) { 36 | return colors.red(icons.cross) 37 | } 38 | 39 | if (payload.isSkipped) { 40 | return colors.yellow(icons.bullet) 41 | } 42 | 43 | if (payload.isFailing) { 44 | return colors.magenta(icons.squareSmallFilled) 45 | } 46 | 47 | return colors.green(icons.tick) 48 | } 49 | 50 | /** 51 | * Returns the test message 52 | */ 53 | #getTestMessage(payload: TestEndNode) { 54 | const message = payload.title.expanded 55 | 56 | if (payload.isTodo) { 57 | return colors.blue(message) 58 | } 59 | 60 | if (payload.hasError) { 61 | return colors.red(message) 62 | } 63 | 64 | if (payload.isSkipped) { 65 | return colors.yellow(message) 66 | } 67 | 68 | if (payload.isFailing) { 69 | return colors.magenta(message) 70 | } 71 | 72 | return colors.grey(message) 73 | } 74 | 75 | /** 76 | * Returns the subtext message for the test 77 | */ 78 | #getSubText(payload: TestEndNode): string | undefined { 79 | if (payload.isSkipped && payload.skipReason) { 80 | return colors.dim(`${icons.branch} ${colors.italic(payload.skipReason)}`) 81 | } 82 | 83 | if (!payload.isFailing) { 84 | return 85 | } 86 | 87 | if (payload.hasError) { 88 | const message = 89 | payload.errors.find((error) => error.phase === 'test')?.error.message ?? 90 | `Test marked with ".fails()" must finish with an error` 91 | 92 | return colors.dim(`${icons.branch} ${colors.italic(message)}`) 93 | } 94 | 95 | if (payload.failReason) { 96 | return colors.dim(`${icons.branch} ${colors.italic(payload.failReason)}`) 97 | } 98 | 99 | const testErrorMessage = payload.errors.find((error) => error.phase === 'test') 100 | if (testErrorMessage && testErrorMessage.error) { 101 | return colors.dim(`${icons.branch} ${colors.italic(testErrorMessage.error.message)}`) 102 | } 103 | } 104 | 105 | /** 106 | * Returns the filename relative from the current working dir 107 | */ 108 | #getRelativeFilename(fileName: string) { 109 | return relative(process.cwd(), fileName) 110 | } 111 | 112 | /** 113 | * Prints the test details 114 | */ 115 | #printTest(payload: TestEndNode) { 116 | const icon = this.#getTestIcon(payload) 117 | const message = this.#getTestMessage(payload) 118 | const prefix = payload.isPinned ? colors.yellow('[PINNED] ') : '' 119 | const indentation = this.currentFileName || this.currentGroupName ? ' ' : '' 120 | const duration = colors.dim(`(${ms(Number(payload.duration.toFixed(2)))})`) 121 | const retries = 122 | payload.retryAttempt && payload.retryAttempt > 1 123 | ? colors.dim(`(x${payload.retryAttempt}) `) 124 | : '' 125 | 126 | let subText = this.#getSubText(payload) 127 | subText = subText ? `\n${indentation} ${subText}` : '' 128 | 129 | console.log(`${indentation}${icon} ${prefix}${retries}${message} ${duration}${subText}`) 130 | } 131 | 132 | /** 133 | * Prints the group name 134 | */ 135 | #printGroup(payload: GroupStartNode) { 136 | const title = 137 | this.currentSuiteName !== 'default' 138 | ? `${this.currentSuiteName} / ${payload.title}` 139 | : payload.title 140 | 141 | const suffix = this.currentFileName 142 | ? colors.dim(` (${this.#getRelativeFilename(this.currentFileName)})`) 143 | : '' 144 | 145 | console.log(`\n${title}${suffix}`) 146 | } 147 | 148 | protected onTestStart(): void { 149 | /** 150 | * Display the filename when 151 | * 152 | * - The filename exists 153 | * - The test is not under a group 154 | * - Test is first in a sequence 155 | */ 156 | if (this.currentFileName && this.#isFirstLoneTest) { 157 | console.log(`\n${colors.dim(this.#getRelativeFilename(this.currentFileName))}`) 158 | } 159 | this.#isFirstLoneTest = false 160 | } 161 | 162 | protected onTestEnd(payload: TestEndNode): void { 163 | this.#printTest(payload) 164 | } 165 | 166 | protected onGroupStart(payload: GroupStartNode): void { 167 | /** 168 | * When a group starts, we mark the upcoming test as NOT a 169 | * lone test 170 | */ 171 | this.#isFirstLoneTest = false 172 | this.#printGroup(payload) 173 | } 174 | 175 | protected onGroupEnd(): void { 176 | /** 177 | * When the group ends we assume that the next test can 178 | * be out of the group, hence a lone test. 179 | * 180 | * If this assumption is false, then the `onGroupStart` method 181 | * will toggle the boolean 182 | */ 183 | this.#isFirstLoneTest = true 184 | } 185 | 186 | protected async end() { 187 | const summary = this.runner!.getSummary() 188 | await this.printSummary(summary) 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import type { HookHandler } from '@poppinss/hooks/types' 11 | 12 | import type { Emitter, Refiner, Runner, Suite } from '../modules/core/main.js' 13 | import type { FilteringOptions, NamedReporterContract } from '../modules/core/types.js' 14 | 15 | export * from '../modules/core/types.js' 16 | 17 | /** 18 | * Global setup hook 19 | */ 20 | export type SetupHookState = [[runner: Runner], [error: Error | null, runner: Runner]] 21 | export type SetupHookHandler = HookHandler 22 | 23 | /** 24 | * Global teardown hook 25 | */ 26 | export type TeardownHookState = [[runner: Runner], [error: Error | null, runner: Runner]] 27 | export type TeardownHookHandler = HookHandler 28 | 29 | /** 30 | * Global set of available hooks 31 | */ 32 | export type HooksEvents = { 33 | setup: SetupHookState 34 | teardown: TeardownHookState 35 | } 36 | 37 | /** 38 | * Parsed command-line arguments 39 | */ 40 | export type CLIArgs = { 41 | _?: string[] 42 | tags?: string | string[] 43 | files?: string | string[] 44 | tests?: string | string[] 45 | groups?: string | string[] 46 | timeout?: string 47 | retries?: string 48 | reporters?: string | string[] 49 | forceExit?: boolean 50 | failed?: boolean 51 | help?: boolean 52 | matchAll?: boolean 53 | bail?: boolean 54 | bailLayer?: string 55 | } & Record 56 | 57 | /** 58 | * Set of filters you can apply to run only specific tests 59 | */ 60 | export type Filters = FilteringOptions & { 61 | files?: string[] 62 | suites?: string[] 63 | } 64 | 65 | /** 66 | * Plugin function receives an instance of the runner, 67 | * emitter, config and the hooks 68 | */ 69 | export type PluginFn = (japa: { 70 | config: NormalizedConfig 71 | cliArgs: CLIArgs 72 | runner: Runner 73 | emitter: Emitter 74 | }) => void | Promise 75 | 76 | /** 77 | * Base configuration options 78 | */ 79 | export type BaseConfig = { 80 | /** 81 | * Current working directory. It is required to search for 82 | * the test files 83 | */ 84 | cwd?: string 85 | 86 | /** 87 | * The timeout to apply on all the tests, unless overwritten explicitly 88 | */ 89 | timeout?: number 90 | 91 | /** 92 | * The retries to apply on all the tests, unless overwritten explicitly 93 | */ 94 | retries?: number 95 | 96 | /** 97 | * Test filters to apply 98 | */ 99 | filters?: Filters 100 | 101 | /** 102 | * A hook to configure suites. The callback will be called for each 103 | * suite before it gets executed. 104 | */ 105 | configureSuite?: (suite: Suite) => void 106 | 107 | /** 108 | * A collection of registered reporters. Reporters are not activated by 109 | * default. Either you have to activate them using the commandline, 110 | * or using the `activated` property. 111 | */ 112 | reporters?: { 113 | activated: string[] 114 | list?: NamedReporterContract[] 115 | } 116 | 117 | /** 118 | * A collection of registered plugins 119 | */ 120 | plugins?: PluginFn[] 121 | 122 | /** 123 | * A custom implementation to import test files. 124 | */ 125 | importer?: (filePath: URL) => void | Promise 126 | 127 | /** 128 | * Overwrite tests refiner. Check documentation for refiner 129 | * usage 130 | */ 131 | refiner?: Refiner 132 | 133 | /** 134 | * Enable/disable force exiting. 135 | */ 136 | forceExit?: boolean 137 | 138 | /** 139 | * Global hooks to execute before importing 140 | * the test files 141 | */ 142 | setup?: SetupHookHandler[] 143 | 144 | /** 145 | * Global hooks to execute on teardown 146 | */ 147 | teardown?: TeardownHookHandler[] 148 | 149 | /** 150 | * An array of directories to exclude when searching 151 | * for test files. 152 | * 153 | * For example, if you search for test files inside the entire 154 | * project, you might want to exclude "node_modules" 155 | */ 156 | exclude?: string[] 157 | } 158 | 159 | /** 160 | * A collection of test files defined as a glob or a callback 161 | * function that returns an array of URLs 162 | */ 163 | export type TestFiles = string | string[] | (() => URL[] | Promise) 164 | 165 | /** 166 | * A test suite to register tests under a named suite 167 | */ 168 | export type TestSuite = { 169 | /** 170 | * A unique name for the suite 171 | */ 172 | name: string 173 | 174 | /** 175 | * Collection of files associated with the suite. Files should be 176 | * defined as a glob or a callback function that returns an array of URLs 177 | */ 178 | files: TestFiles 179 | 180 | /** 181 | * A callback functon to configure the suite. The callback is invoked only 182 | * when the runner is going to run the tests for the given suite. 183 | */ 184 | configure?: (suite: Suite) => void 185 | 186 | /** 187 | * The timeout to apply on all the tests in this suite, unless overwritten explicitly 188 | */ 189 | timeout?: number 190 | 191 | /** 192 | * The retries to apply on all the tests in this suite, unless overwritten explicitly 193 | */ 194 | retries?: number 195 | } 196 | 197 | /** 198 | * BaseConfig after normalized by the config manager 199 | */ 200 | export type NormalizedBaseConfig = Required> & { 201 | reporters: { 202 | activated: string[] 203 | list: NamedReporterContract[] 204 | } 205 | } 206 | 207 | /** 208 | * Configuration options 209 | */ 210 | export type Config = BaseConfig & 211 | ( 212 | | { 213 | files: TestFiles 214 | } 215 | | { 216 | suites: TestSuite[] 217 | } 218 | ) 219 | 220 | /** 221 | * Config after normalized by the config manager 222 | */ 223 | export type NormalizedConfig = NormalizedBaseConfig & 224 | ( 225 | | { 226 | files: TestFiles 227 | } 228 | | { 229 | suites: Required[] 230 | } 231 | ) 232 | -------------------------------------------------------------------------------- /src/validator.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { NormalizedConfig } from './types.js' 11 | 12 | /** 13 | * Validator encapsulates the validations to perform before running 14 | * the tests 15 | */ 16 | class Validator { 17 | /** 18 | * Ensures the japa is configured. Otherwise raises an exception 19 | */ 20 | ensureIsConfigured(config: NormalizedConfig | undefined) { 21 | if (!config) { 22 | throw new Error( 23 | `Cannot run tests. Make sure to call "configure" method before the "run" method` 24 | ) 25 | } 26 | } 27 | 28 | /** 29 | * Ensures the japa is in planning phase 30 | */ 31 | ensureIsInPlanningPhase(phase: 'idle' | 'planning' | 'executing') { 32 | if (phase !== 'planning') { 33 | throw new Error( 34 | `Cannot import japa test file directly. It must be imported by calling the "japa.run" method` 35 | ) 36 | } 37 | } 38 | 39 | /** 40 | * Ensures the suites filter uses a subset of the user configured suites. 41 | */ 42 | validateSuitesFilter(config: NormalizedConfig) { 43 | /** 44 | * Do not perform any validation if no filters are applied 45 | * in the first place 46 | */ 47 | if (!config.filters.suites || !config.filters.suites.length) { 48 | return 49 | } 50 | 51 | /** 52 | * Notify user they have applied the suites filter but forgot to define 53 | * suites 54 | */ 55 | if (!('suites' in config) || !config.suites.length) { 56 | throw new Error(`Cannot apply suites filter. You have not configured any test suites`) 57 | } 58 | 59 | const suites = config.suites.map(({ name }) => name) 60 | 61 | /** 62 | * Find unknown suites and report the error 63 | */ 64 | const unknownSuites = config.filters.suites.filter((suite) => !suites.includes(suite)) 65 | if (unknownSuites.length) { 66 | throw new Error(`Cannot apply suites filter. "${unknownSuites[0]}" suite is not configured`) 67 | } 68 | } 69 | 70 | /** 71 | * Ensure there are unique suites 72 | */ 73 | validateSuitesForUniqueness(config: NormalizedConfig) { 74 | if (!('suites' in config)) { 75 | return 76 | } 77 | 78 | const suites: Set = new Set() 79 | config.suites.forEach(({ name }) => { 80 | if (suites.has(name)) { 81 | throw new Error(`Duplicate suite "${name}"`) 82 | } 83 | suites.add(name) 84 | }) 85 | 86 | suites.clear() 87 | } 88 | 89 | /** 90 | * Ensure the activated reporters are in the list of defined 91 | * reporters 92 | */ 93 | validateActivatedReporters(config: NormalizedConfig) { 94 | const reportersList = config.reporters.list.map(({ name }) => name) 95 | const unknownReporters = config.reporters.activated.filter( 96 | (name) => !reportersList.includes(name) 97 | ) 98 | 99 | if (unknownReporters.length) { 100 | throw new Error( 101 | `Invalid reporter "${unknownReporters[0]}". Make sure to register it first inside the "reporters.list" array` 102 | ) 103 | } 104 | } 105 | } 106 | 107 | export default new Validator() 108 | -------------------------------------------------------------------------------- /tests/base_reporter.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from 'node:test' 11 | import chaiSubset from 'chai-subset' 12 | import { assert, use } from 'chai' 13 | 14 | use(chaiSubset) 15 | 16 | import { wrapAssertions } from './helpers.js' 17 | import { 18 | TestEndNode, 19 | GroupEndNode, 20 | GroupOptions, 21 | SuiteEndNode, 22 | RunnerSummary, 23 | TestStartNode, 24 | SuiteStartNode, 25 | } from '../modules/core/types.js' 26 | import { 27 | Test, 28 | Suite, 29 | Group, 30 | Runner, 31 | Refiner, 32 | Emitter, 33 | TestContext, 34 | BaseReporter, 35 | } from '../modules/core/main.js' 36 | 37 | test.describe('Base reporter', () => { 38 | test('extend base reporter to create a custom reporter', async () => { 39 | const stack: string[] = [] 40 | class MyReporter extends BaseReporter { 41 | protected async start() { 42 | stack.push('reporter started') 43 | } 44 | } 45 | 46 | const emitter = new Emitter() 47 | const runner = new Runner(emitter) 48 | runner.registerReporter((r, e) => new MyReporter({}).boot(r, e)) 49 | 50 | await runner.start() 51 | await wrapAssertions(() => { 52 | assert.deepEqual(stack, ['reporter started']) 53 | }) 54 | }) 55 | 56 | test('invoke handlers when suite, groups and tests are executed', async () => { 57 | const stack: string[] = [] 58 | let summary: RunnerSummary | undefined 59 | 60 | class MyReporter extends BaseReporter { 61 | protected async start() { 62 | stack.push('reporter started') 63 | } 64 | 65 | protected async end() { 66 | summary = this.runner!.getSummary() 67 | stack.push('reporter ended') 68 | } 69 | 70 | protected onTestStart(t: TestStartNode): void { 71 | assert.equal(t.title.expanded, '2 + 2') 72 | assert.equal(this.currentSuiteName, 'unit') 73 | assert.equal(this.currentGroupName, 'default') 74 | stack.push('test started') 75 | } 76 | 77 | protected onTestEnd(t: TestEndNode): void { 78 | assert.equal(t.title.expanded, '2 + 2') 79 | assert.isFalse(t.hasError) 80 | stack.push('test ended') 81 | } 82 | 83 | protected onGroupStart(g: GroupOptions): void { 84 | assert.equal(g.title, 'default') 85 | assert.equal(this.currentSuiteName, 'unit') 86 | stack.push('group started') 87 | } 88 | 89 | protected onGroupEnd(g: GroupEndNode): void { 90 | assert.equal(g.title, 'default') 91 | assert.isFalse(g.hasError) 92 | stack.push('group ended') 93 | } 94 | 95 | protected onSuiteStart(s: SuiteStartNode): void { 96 | assert.equal(s.name, 'unit') 97 | stack.push('suite started') 98 | } 99 | 100 | protected onSuiteEnd(s: SuiteEndNode): void { 101 | assert.equal(s.name, 'unit') 102 | stack.push('suite ended') 103 | } 104 | } 105 | 106 | const emitter = new Emitter() 107 | const runner = new Runner(emitter) 108 | const refiner = new Refiner() 109 | const suite = new Suite('unit', emitter, refiner) 110 | const group = new Group('default', emitter, refiner) 111 | const t = new Test('2 + 2', (_t) => new TestContext(_t), emitter, refiner, group) 112 | 113 | group.add(t) 114 | suite.add(group) 115 | runner.add(suite) 116 | 117 | runner.registerReporter((r, e) => new MyReporter({}).boot(r, e)) 118 | await runner.start() 119 | await runner.exec() 120 | await runner.end() 121 | 122 | await wrapAssertions(() => { 123 | assert.deepEqual(stack, [ 124 | 'reporter started', 125 | 'suite started', 126 | 'group started', 127 | 'test started', 128 | 'test ended', 129 | 'group ended', 130 | 'suite ended', 131 | 'reporter ended', 132 | ]) 133 | 134 | assert.containSubset(summary!, { 135 | aggregates: { 136 | total: 1, 137 | failed: 0, 138 | passed: 0, 139 | regression: 0, 140 | skipped: 0, 141 | todo: 1, 142 | }, 143 | hasError: false, 144 | failureTree: [], 145 | failedTestsTitles: [], 146 | }) 147 | }) 148 | }) 149 | }) 150 | -------------------------------------------------------------------------------- /tests/cli_parser.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { assert } from 'chai' 11 | import { test } from 'node:test' 12 | 13 | import { colors } from '../src/helpers.js' 14 | import type { CLIArgs } from '../src/types.js' 15 | import { CliParser } from '../src/cli_parser.js' 16 | import { wrapAssertions } from './helpers.js' 17 | 18 | const DATASET: [CLIArgs, CLIArgs][] = [ 19 | [ 20 | new CliParser().parse([]), 21 | { 22 | '_': [] as string[], 23 | 'files': '', 24 | 'groups': '', 25 | 'reporters': '', 26 | 'h': false, 27 | 'help': false, 28 | 'failed': false, 29 | 'bail': false, 30 | 'bailLayer': '', 31 | 'bail-layer': '', 32 | 'match-all': false, 33 | 'matchAll': false, 34 | 'retries': '', 35 | 'tags': '', 36 | 'tests': '', 37 | 'timeout': '', 38 | }, 39 | ], 40 | [ 41 | new CliParser().parse(['unit', 'functional']), 42 | { 43 | '_': ['unit', 'functional'] as string[], 44 | 'files': '', 45 | 'reporters': '', 46 | 'groups': '', 47 | 'h': false, 48 | 'bail': false, 49 | 'bailLayer': '', 50 | 'bail-layer': '', 51 | 'help': false, 52 | 'failed': false, 53 | 'match-all': false, 54 | 'matchAll': false, 55 | 'retries': '', 56 | 'tags': '', 57 | 'tests': '', 58 | 'timeout': '', 59 | }, 60 | ], 61 | [ 62 | new CliParser().parse(['--timeout', '1000']), 63 | { 64 | '_': [] as string[], 65 | 'files': '', 66 | 'reporters': '', 67 | 'groups': '', 68 | 'h': false, 69 | 'bail': false, 70 | 'bailLayer': '', 71 | 'bail-layer': '', 72 | 'help': false, 73 | 'failed': false, 74 | 'match-all': false, 75 | 'matchAll': false, 76 | 'retries': '', 77 | 'tags': '', 78 | 'tests': '', 79 | 'timeout': '1000', 80 | }, 81 | ], 82 | [ 83 | new CliParser().parse(['--timeout', '1000', '--retries', '2']), 84 | { 85 | '_': [] as string[], 86 | 'files': '', 87 | 'reporters': '', 88 | 'groups': '', 89 | 'h': false, 90 | 'bail': false, 91 | 'bailLayer': '', 92 | 'bail-layer': '', 93 | 'help': false, 94 | 'failed': false, 95 | 'match-all': false, 96 | 'matchAll': false, 97 | 'retries': '2', 98 | 'tags': '', 99 | 'tests': '', 100 | 'timeout': '1000', 101 | }, 102 | ], 103 | [ 104 | new CliParser().parse(['--match-all']), 105 | { 106 | '_': [] as string[], 107 | 'files': '', 108 | 'reporters': '', 109 | 'groups': '', 110 | 'h': false, 111 | 'bail': false, 112 | 'bailLayer': '', 113 | 'bail-layer': '', 114 | 'help': false, 115 | 'failed': false, 116 | 'match-all': true, 117 | 'matchAll': true, 118 | 'retries': '', 119 | 'tags': '', 120 | 'tests': '', 121 | 'timeout': '', 122 | }, 123 | ], 124 | [ 125 | new CliParser().parse(['--force-exit']), 126 | { 127 | '_': [] as string[], 128 | 'files': '', 129 | 'force-exit': true, 130 | 'forceExit': true, 131 | 'reporters': '', 132 | 'groups': '', 133 | 'h': false, 134 | 'bail': false, 135 | 'bailLayer': '', 136 | 'bail-layer': '', 137 | 'help': false, 138 | 'failed': false, 139 | 'match-all': false, 140 | 'matchAll': false, 141 | 'retries': '', 142 | 'tags': '', 143 | 'tests': '', 144 | 'timeout': '', 145 | }, 146 | ], 147 | [ 148 | new CliParser().parse(['--browser', 'chrome', '--browser', 'firefox']), 149 | { 150 | '_': [] as string[], 151 | 'files': '', 152 | 'browser': ['chrome', 'firefox'], 153 | 'reporters': '', 154 | 'groups': '', 155 | 'h': false, 156 | 'bail': false, 157 | 'bailLayer': '', 158 | 'bail-layer': '', 159 | 'help': false, 160 | 'failed': false, 161 | 'match-all': false, 162 | 'matchAll': false, 163 | 'retries': '', 164 | 'tags': '', 165 | 'tests': '', 166 | 'timeout': '', 167 | }, 168 | ], 169 | [ 170 | new CliParser().parse(['--reporters', 'spec']), 171 | { 172 | '_': [] as string[], 173 | 'files': '', 174 | 'reporters': 'spec', 175 | 'groups': '', 176 | 'h': false, 177 | 'bail': false, 178 | 'bailLayer': '', 179 | 'bail-layer': '', 180 | 'help': false, 181 | 'failed': false, 182 | 'match-all': false, 183 | 'matchAll': false, 184 | 'retries': '', 185 | 'tags': '', 186 | 'tests': '', 187 | 'timeout': '', 188 | }, 189 | ], 190 | [ 191 | new CliParser().parse(['--reporters', 'spec', '--reporters', 'dot']), 192 | { 193 | '_': [] as string[], 194 | 'files': '', 195 | 'reporters': ['spec', 'dot'], 196 | 'groups': '', 197 | 'h': false, 198 | 'bail': false, 199 | 'bailLayer': '', 200 | 'bail-layer': '', 201 | 'help': false, 202 | 'failed': false, 203 | 'match-all': false, 204 | 'matchAll': false, 205 | 'retries': '', 206 | 'tags': '', 207 | 'tests': '', 208 | 'timeout': '', 209 | }, 210 | ], 211 | [ 212 | new CliParser().parse(['--reporters', 'spec,dot']), 213 | { 214 | '_': [] as string[], 215 | 'files': '', 216 | 'reporters': 'spec,dot', 217 | 'groups': '', 218 | 'h': false, 219 | 'bail': false, 220 | 'bailLayer': '', 221 | 'bail-layer': '', 222 | 'help': false, 223 | 'failed': false, 224 | 'match-all': false, 225 | 'matchAll': false, 226 | 'retries': '', 227 | 'tags': '', 228 | 'tests': '', 229 | 'timeout': '', 230 | }, 231 | ], 232 | [ 233 | new CliParser().parse(['--failed']), 234 | { 235 | '_': [] as string[], 236 | 'files': '', 237 | 'reporters': '', 238 | 'groups': '', 239 | 'h': false, 240 | 'bail': false, 241 | 'bailLayer': '', 242 | 'bail-layer': '', 243 | 'help': false, 244 | 'failed': true, 245 | 'match-all': false, 246 | 'matchAll': false, 247 | 'retries': '', 248 | 'tags': '', 249 | 'tests': '', 250 | 'timeout': '', 251 | }, 252 | ], 253 | [ 254 | new CliParser().parse(['--bail']), 255 | { 256 | '_': [] as string[], 257 | 'files': '', 258 | 'groups': '', 259 | 'reporters': '', 260 | 'h': false, 261 | 'help': false, 262 | 'failed': false, 263 | 'bail': true, 264 | 'bailLayer': '', 265 | 'bail-layer': '', 266 | 'match-all': false, 267 | 'matchAll': false, 268 | 'retries': '', 269 | 'tags': '', 270 | 'tests': '', 271 | 'timeout': '', 272 | }, 273 | ], 274 | [ 275 | new CliParser().parse(['--bail', '--bail-layer=group']), 276 | { 277 | '_': [] as string[], 278 | 'files': '', 279 | 'groups': '', 280 | 'reporters': '', 281 | 'h': false, 282 | 'help': false, 283 | 'failed': false, 284 | 'bail': true, 285 | 'bail-layer': 'group', 286 | 'bailLayer': 'group', 287 | 'match-all': false, 288 | 'matchAll': false, 289 | 'retries': '', 290 | 'tags': '', 291 | 'tests': '', 292 | 'timeout': '', 293 | }, 294 | ], 295 | ] 296 | 297 | test.describe('CLI parser', () => { 298 | test('parse CLI arguments', async () => { 299 | for (let [cliArgs, output] of DATASET) { 300 | await wrapAssertions(() => { 301 | assert.deepEqual(cliArgs, output) 302 | }) 303 | } 304 | }) 305 | 306 | if (!process.env.CI) { 307 | test('display help', async () => { 308 | console.log(new CliParser().getHelp()) 309 | await wrapAssertions(() => { 310 | assert.deepEqual(new CliParser().getHelp().split('\n'), [ 311 | '', 312 | colors.yellow('@japa/runner v2.3.0'), 313 | '', 314 | `${colors.green('--tests')} ${colors.dim( 315 | 'Filter tests by the test title' 316 | )}`, 317 | `${colors.green('--groups')} ${colors.dim( 318 | 'Filter tests by the group title' 319 | )}`, 320 | `${colors.green('--tags')} ${colors.dim('Filter tests by tags')}`, 321 | `${colors.green('--match-all')} ${colors.dim('Run tests that matches all the supplied tags')}`, 322 | `${colors.green('--files')} ${colors.dim( 323 | 'Filter tests by the file name' 324 | )}`, 325 | `${colors.green('--force-exit')} ${colors.dim('Forcefully exit the process')}`, 326 | `${colors.green('--timeout')} ${colors.dim( 327 | 'Define default timeout for all tests' 328 | )}`, 329 | `${colors.green('--retries')} ${colors.dim( 330 | 'Define default retries for all tests' 331 | )}`, 332 | `${colors.green('--reporters')} ${colors.dim( 333 | 'Activate one or more test reporters' 334 | )}`, 335 | `${colors.green('--failed')} ${colors.dim( 336 | 'Run tests failed during the last run' 337 | )}`, 338 | `${colors.green('--bail')} ${colors.dim( 339 | 'Exit early when a test fails' 340 | )}`, 341 | `${colors.green('--bail-layer')} ${colors.dim('Specify at which layer to enable the bail mode. Can be "group" or "suite"')}`, 342 | `${colors.green('-h, --help')} ${colors.dim('View help')}`, 343 | ``, 344 | `${colors.yellow('Examples:')}`, 345 | `${colors.dim('node bin/test.js --tags="@github"')}`, 346 | `${colors.dim('node bin/test.js --tags="~@github"')}`, 347 | `${colors.dim('node bin/test.js --tags="@github,@slow,@integration" --match-all')}`, 348 | `${colors.dim('node bin/test.js --force-exit')}`, 349 | `${colors.dim('node bin/test.js --files="user"')}`, 350 | `${colors.dim('node bin/test.js --files="functional/user"')}`, 351 | `${colors.dim('node bin/test.js --files="unit/user"')}`, 352 | `${colors.dim('node bin/test.js --failed')}`, 353 | `${colors.dim('node bin/test.js --bail')}`, 354 | `${colors.dim('node bin/test.js --bail=group')}`, 355 | ``, 356 | `${colors.yellow('Notes:')}`, 357 | `- When groups and tests filters are applied together. We will first filter the`, 358 | ` tests by group title and then apply the tests filter.`, 359 | `- The timeout defined on test object takes precedence over the ${colors.green( 360 | '--timeout' 361 | )} flag.`, 362 | `- The retries defined on test object takes precedence over the ${colors.green( 363 | '--retries' 364 | )} flag.`, 365 | `- The ${colors.green( 366 | '--files' 367 | )} flag checks for the file names ending with the filter substring.`, 368 | `- The ${colors.green('--tags')} filter runs tests that has one or more of the supplied tags.`, 369 | `- You can use the ${colors.green('--match-all')} flag to run tests that has all the supplied tags.`, 370 | ``, 371 | ]) 372 | }) 373 | }) 374 | } 375 | }) 376 | -------------------------------------------------------------------------------- /tests/config_manager.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from 'node:test' 11 | import { assert, use } from 'chai' 12 | import chaiSubset from 'chai-subset' 13 | import { Refiner } from '@japa/core' 14 | 15 | import { wrapAssertions } from './helpers.js' 16 | import { CliParser } from '../src/cli_parser.js' 17 | import type { CLIArgs, Config } from '../src/types.js' 18 | import { ConfigManager, NOOP } from '../src/config_manager.js' 19 | import { ndjson, spec, dot, github } from '../src/reporters/main.js' 20 | 21 | use(chaiSubset) 22 | 23 | const USER_DEFINED_CONFIG_DATASET: [Config, Config][] = [ 24 | [ 25 | { 26 | files: [], 27 | }, 28 | { 29 | cwd: process.cwd(), 30 | files: [], 31 | filters: {}, 32 | exclude: ['node_modules/**', '.git/**', 'coverage/**'], 33 | forceExit: false, 34 | refiner: new Refiner(), 35 | reporters: { 36 | activated: ['spec'].concat(process.env.GITHUB_ACTIONS === 'true' ? ['github'] : []), 37 | list: [spec(), ndjson(), dot(), github()], 38 | }, 39 | setup: [], 40 | teardown: [], 41 | plugins: [], 42 | retries: 0, 43 | timeout: 2000, 44 | }, 45 | ], 46 | [ 47 | { 48 | files: ['tests/unit/**.spec.ts'], 49 | }, 50 | { 51 | cwd: process.cwd(), 52 | files: ['tests/unit/**.spec.ts'], 53 | filters: {}, 54 | exclude: ['node_modules/**', '.git/**', 'coverage/**'], 55 | forceExit: false, 56 | refiner: new Refiner(), 57 | reporters: { 58 | activated: ['spec'].concat(process.env.GITHUB_ACTIONS === 'true' ? ['github'] : []), 59 | list: [spec(), ndjson(), dot(), github()], 60 | }, 61 | plugins: [], 62 | setup: [], 63 | teardown: [], 64 | retries: 0, 65 | timeout: 2000, 66 | }, 67 | ], 68 | [ 69 | { 70 | files: ['tests/unit/**.spec.ts'], 71 | timeout: 4000, 72 | }, 73 | { 74 | cwd: process.cwd(), 75 | files: ['tests/unit/**.spec.ts'], 76 | filters: {}, 77 | exclude: ['node_modules/**', '.git/**', 'coverage/**'], 78 | forceExit: false, 79 | refiner: new Refiner(), 80 | reporters: { 81 | activated: ['spec'].concat(process.env.GITHUB_ACTIONS === 'true' ? ['github'] : []), 82 | list: [spec(), ndjson(), dot(), github()], 83 | }, 84 | plugins: [], 85 | setup: [], 86 | teardown: [], 87 | retries: 0, 88 | timeout: 4000, 89 | }, 90 | ], 91 | [ 92 | { 93 | files: ['tests/unit/**.spec.ts'], 94 | timeout: 4000, 95 | retries: 2, 96 | }, 97 | { 98 | cwd: process.cwd(), 99 | files: ['tests/unit/**.spec.ts'], 100 | filters: {}, 101 | exclude: ['node_modules/**', '.git/**', 'coverage/**'], 102 | forceExit: false, 103 | refiner: new Refiner(), 104 | reporters: { 105 | activated: ['spec'].concat(process.env.GITHUB_ACTIONS === 'true' ? ['github'] : []), 106 | list: [spec(), ndjson(), dot(), github()], 107 | }, 108 | plugins: [], 109 | setup: [], 110 | teardown: [], 111 | retries: 2, 112 | timeout: 4000, 113 | }, 114 | ], 115 | [ 116 | { 117 | files: ['tests/unit/**.spec.ts'], 118 | timeout: 4000, 119 | retries: 2, 120 | }, 121 | { 122 | cwd: process.cwd(), 123 | files: ['tests/unit/**.spec.ts'], 124 | filters: {}, 125 | exclude: ['node_modules/**', '.git/**', 'coverage/**'], 126 | forceExit: false, 127 | refiner: new Refiner(), 128 | reporters: { 129 | activated: ['spec'].concat(process.env.GITHUB_ACTIONS === 'true' ? ['github'] : []), 130 | list: [spec(), ndjson(), dot(), github()], 131 | }, 132 | plugins: [], 133 | setup: [], 134 | teardown: [], 135 | retries: 2, 136 | timeout: 4000, 137 | }, 138 | ], 139 | [ 140 | { 141 | files: ['tests/unit/**.spec.ts'], 142 | timeout: 4000, 143 | retries: 2, 144 | filters: { 145 | tags: ['@integration', '~@slow'], 146 | tests: ['users list'], 147 | }, 148 | }, 149 | { 150 | cwd: process.cwd(), 151 | files: ['tests/unit/**.spec.ts'], 152 | filters: { 153 | tags: ['@integration', '~@slow'], 154 | tests: ['users list'], 155 | }, 156 | exclude: ['node_modules/**', '.git/**', 'coverage/**'], 157 | forceExit: false, 158 | refiner: new Refiner(), 159 | reporters: { 160 | activated: ['spec'].concat(process.env.GITHUB_ACTIONS === 'true' ? ['github'] : []), 161 | list: [spec(), ndjson(), dot(), github()], 162 | }, 163 | plugins: [], 164 | setup: [], 165 | teardown: [], 166 | retries: 2, 167 | timeout: 4000, 168 | }, 169 | ], 170 | [ 171 | { 172 | files: ['tests/unit/**.spec.ts'], 173 | timeout: 4000, 174 | retries: 2, 175 | filters: { 176 | tags: ['@integration', '~@slow'], 177 | tests: ['users list'], 178 | }, 179 | forceExit: true, 180 | }, 181 | { 182 | cwd: process.cwd(), 183 | files: ['tests/unit/**.spec.ts'], 184 | filters: { 185 | tags: ['@integration', '~@slow'], 186 | tests: ['users list'], 187 | }, 188 | exclude: ['node_modules/**', '.git/**', 'coverage/**'], 189 | forceExit: true, 190 | refiner: new Refiner(), 191 | reporters: { 192 | activated: ['spec'].concat(process.env.GITHUB_ACTIONS === 'true' ? ['github'] : []), 193 | list: [spec(), ndjson(), dot(), github()], 194 | }, 195 | plugins: [], 196 | setup: [], 197 | teardown: [], 198 | retries: 2, 199 | timeout: 4000, 200 | }, 201 | ], 202 | [ 203 | { 204 | files: [], 205 | reporters: { 206 | activated: ['dot'], 207 | list: [], 208 | }, 209 | }, 210 | { 211 | cwd: process.cwd(), 212 | files: [], 213 | filters: {}, 214 | exclude: ['node_modules/**', '.git/**', 'coverage/**'], 215 | forceExit: false, 216 | refiner: new Refiner(), 217 | reporters: { 218 | activated: ['dot'], 219 | list: [], 220 | }, 221 | plugins: [], 222 | setup: [], 223 | teardown: [], 224 | retries: 0, 225 | timeout: 2000, 226 | }, 227 | ], 228 | [ 229 | { 230 | files: [], 231 | reporters: { 232 | activated: ['dot'], 233 | }, 234 | }, 235 | { 236 | cwd: process.cwd(), 237 | files: [], 238 | filters: {}, 239 | exclude: ['node_modules/**', '.git/**', 'coverage/**'], 240 | forceExit: false, 241 | refiner: new Refiner(), 242 | reporters: { 243 | activated: ['dot'], 244 | list: [spec(), ndjson(), dot(), github()], 245 | }, 246 | plugins: [], 247 | setup: [], 248 | teardown: [], 249 | retries: 0, 250 | timeout: 2000, 251 | }, 252 | ], 253 | ] 254 | 255 | const USER_DEFINED_CONFIG_DATASET_WITH_CLI_ARGS: [Config, CLIArgs, Config][] = [ 256 | [ 257 | { 258 | files: [], 259 | }, 260 | new CliParser().parse([]), 261 | { 262 | cwd: process.cwd(), 263 | files: [], 264 | filters: {}, 265 | exclude: ['node_modules/**', '.git/**', 'coverage/**'], 266 | forceExit: false, 267 | refiner: new Refiner(), 268 | reporters: { 269 | activated: ['spec'].concat(process.env.GITHUB_ACTIONS === 'true' ? ['github'] : []), 270 | list: [spec(), ndjson(), dot(), github()], 271 | }, 272 | plugins: [], 273 | setup: [], 274 | teardown: [], 275 | retries: 0, 276 | timeout: 2000, 277 | }, 278 | ], 279 | [ 280 | { 281 | files: ['tests/unit/**.spec.ts'], 282 | }, 283 | new CliParser().parse(['--tags=@slow']), 284 | { 285 | cwd: process.cwd(), 286 | files: ['tests/unit/**.spec.ts'], 287 | filters: { 288 | tags: ['@slow'], 289 | }, 290 | exclude: ['node_modules/**', '.git/**', 'coverage/**'], 291 | forceExit: false, 292 | refiner: new Refiner(), 293 | reporters: { 294 | activated: ['spec'].concat(process.env.GITHUB_ACTIONS === 'true' ? ['github'] : []), 295 | list: [spec(), ndjson(), dot(), github()], 296 | }, 297 | plugins: [], 298 | setup: [], 299 | teardown: [], 300 | retries: 0, 301 | timeout: 2000, 302 | }, 303 | ], 304 | [ 305 | { 306 | files: ['tests/unit/**.spec.ts'], 307 | timeout: 4000, 308 | }, 309 | new CliParser().parse(['--timeout=1000']), 310 | { 311 | cwd: process.cwd(), 312 | files: ['tests/unit/**.spec.ts'], 313 | filters: {}, 314 | exclude: ['node_modules/**', '.git/**', 'coverage/**'], 315 | forceExit: false, 316 | refiner: new Refiner(), 317 | reporters: { 318 | activated: ['spec'].concat(process.env.GITHUB_ACTIONS === 'true' ? ['github'] : []), 319 | list: [spec(), ndjson(), dot(), github()], 320 | }, 321 | plugins: [], 322 | setup: [], 323 | teardown: [], 324 | retries: 0, 325 | timeout: 1000, 326 | }, 327 | ], 328 | [ 329 | { 330 | suites: [ 331 | { 332 | name: 'unit', 333 | files: 'tests/unit/**.spec.ts', 334 | timeout: 3000, 335 | }, 336 | ], 337 | timeout: 4000, 338 | }, 339 | new CliParser().parse(['--timeout=1000']), 340 | { 341 | cwd: process.cwd(), 342 | suites: [ 343 | { 344 | name: 'unit', 345 | files: 'tests/unit/**.spec.ts', 346 | timeout: 1000, 347 | retries: 0, 348 | configure: NOOP, 349 | }, 350 | ], 351 | filters: {}, 352 | exclude: ['node_modules/**', '.git/**', 'coverage/**'], 353 | forceExit: false, 354 | refiner: new Refiner(), 355 | reporters: { 356 | activated: ['spec'].concat(process.env.GITHUB_ACTIONS === 'true' ? ['github'] : []), 357 | list: [spec(), ndjson(), dot(), github()], 358 | }, 359 | plugins: [], 360 | setup: [], 361 | teardown: [], 362 | retries: 0, 363 | timeout: 1000, 364 | }, 365 | ], 366 | [ 367 | { 368 | files: ['tests/unit/**.spec.ts'], 369 | timeout: 4000, 370 | retries: 2, 371 | }, 372 | new CliParser().parse(['--retries=4']), 373 | { 374 | cwd: process.cwd(), 375 | files: ['tests/unit/**.spec.ts'], 376 | filters: {}, 377 | exclude: ['node_modules/**', '.git/**', 'coverage/**'], 378 | forceExit: false, 379 | refiner: new Refiner(), 380 | reporters: { 381 | activated: ['spec'].concat(process.env.GITHUB_ACTIONS === 'true' ? ['github'] : []), 382 | list: [spec(), ndjson(), dot(), github()], 383 | }, 384 | plugins: [], 385 | setup: [], 386 | teardown: [], 387 | retries: 4, 388 | timeout: 4000, 389 | }, 390 | ], 391 | [ 392 | { 393 | suites: [ 394 | { 395 | name: 'unit', 396 | files: 'tests/unit/**.spec.ts', 397 | retries: 2, 398 | }, 399 | ], 400 | retries: 0, 401 | }, 402 | new CliParser().parse(['--retries=4']), 403 | { 404 | cwd: process.cwd(), 405 | suites: [ 406 | { 407 | name: 'unit', 408 | files: 'tests/unit/**.spec.ts', 409 | timeout: 2000, 410 | retries: 4, 411 | configure: NOOP, 412 | }, 413 | ], 414 | filters: {}, 415 | exclude: ['node_modules/**', '.git/**', 'coverage/**'], 416 | forceExit: false, 417 | refiner: new Refiner(), 418 | reporters: { 419 | activated: ['spec'].concat(process.env.GITHUB_ACTIONS === 'true' ? ['github'] : []), 420 | list: [spec(), ndjson(), dot(), github()], 421 | }, 422 | plugins: [], 423 | setup: [], 424 | teardown: [], 425 | retries: 4, 426 | timeout: 2000, 427 | }, 428 | ], 429 | [ 430 | { 431 | files: ['tests/unit/**.spec.ts'], 432 | timeout: 4000, 433 | retries: 2, 434 | }, 435 | new CliParser().parse(['--timeout=1000', '--retries=4']), 436 | { 437 | cwd: process.cwd(), 438 | files: ['tests/unit/**.spec.ts'], 439 | filters: {}, 440 | exclude: ['node_modules/**', '.git/**', 'coverage/**'], 441 | forceExit: false, 442 | refiner: new Refiner(), 443 | reporters: { 444 | activated: ['spec'].concat(process.env.GITHUB_ACTIONS === 'true' ? ['github'] : []), 445 | list: [spec(), ndjson(), dot(), github()], 446 | }, 447 | plugins: [], 448 | setup: [], 449 | teardown: [], 450 | retries: 4, 451 | timeout: 1000, 452 | }, 453 | ], 454 | [ 455 | { 456 | files: ['tests/unit/**.spec.ts'], 457 | timeout: 4000, 458 | retries: 2, 459 | filters: { 460 | tags: ['@integration', '~@slow'], 461 | tests: ['users list'], 462 | }, 463 | }, 464 | new CliParser().parse(['--tags=@slow']), 465 | { 466 | cwd: process.cwd(), 467 | files: ['tests/unit/**.spec.ts'], 468 | filters: { 469 | tags: ['@slow'], 470 | tests: ['users list'], 471 | }, 472 | exclude: ['node_modules/**', '.git/**', 'coverage/**'], 473 | forceExit: false, 474 | refiner: new Refiner(), 475 | reporters: { 476 | activated: ['spec'].concat(process.env.GITHUB_ACTIONS === 'true' ? ['github'] : []), 477 | list: [spec(), ndjson(), dot(), github()], 478 | }, 479 | plugins: [], 480 | setup: [], 481 | teardown: [], 482 | retries: 2, 483 | timeout: 4000, 484 | }, 485 | ], 486 | [ 487 | { 488 | files: ['tests/unit/**.spec.ts'], 489 | timeout: 4000, 490 | retries: 2, 491 | filters: { 492 | tags: ['@integration', '~@slow'], 493 | tests: ['users list'], 494 | }, 495 | forceExit: true, 496 | }, 497 | new CliParser().parse(['--tests=users']), 498 | { 499 | cwd: process.cwd(), 500 | files: ['tests/unit/**.spec.ts'], 501 | filters: { 502 | tags: ['@integration', '~@slow'], 503 | tests: ['users'], 504 | }, 505 | exclude: ['node_modules/**', '.git/**', 'coverage/**'], 506 | forceExit: true, 507 | refiner: new Refiner(), 508 | reporters: { 509 | activated: ['spec'].concat(process.env.GITHUB_ACTIONS === 'true' ? ['github'] : []), 510 | list: [spec(), ndjson(), dot(), github()], 511 | }, 512 | plugins: [], 513 | setup: [], 514 | teardown: [], 515 | retries: 2, 516 | timeout: 4000, 517 | }, 518 | ], 519 | [ 520 | { 521 | files: ['tests/unit/**.spec.ts'], 522 | timeout: 4000, 523 | retries: 2, 524 | filters: { 525 | tags: ['@integration', '~@slow'], 526 | groups: ['user'], 527 | }, 528 | forceExit: true, 529 | }, 530 | new CliParser().parse(['--groups=customers']), 531 | { 532 | cwd: process.cwd(), 533 | files: ['tests/unit/**.spec.ts'], 534 | filters: { 535 | tags: ['@integration', '~@slow'], 536 | groups: ['customers'], 537 | }, 538 | exclude: ['node_modules/**', '.git/**', 'coverage/**'], 539 | forceExit: true, 540 | refiner: new Refiner(), 541 | reporters: { 542 | activated: ['spec'].concat(process.env.GITHUB_ACTIONS === 'true' ? ['github'] : []), 543 | list: [spec(), ndjson(), dot(), github()], 544 | }, 545 | plugins: [], 546 | setup: [], 547 | teardown: [], 548 | retries: 2, 549 | timeout: 4000, 550 | }, 551 | ], 552 | [ 553 | { 554 | files: ['tests/unit/**.spec.ts'], 555 | timeout: 4000, 556 | retries: 2, 557 | filters: { 558 | tags: ['@integration', '~@slow'], 559 | files: ['unit/users/*'], 560 | }, 561 | forceExit: true, 562 | }, 563 | new CliParser().parse(['--files=*']), 564 | { 565 | cwd: process.cwd(), 566 | files: ['tests/unit/**.spec.ts'], 567 | filters: { 568 | tags: ['@integration', '~@slow'], 569 | files: ['*'], 570 | }, 571 | exclude: ['node_modules/**', '.git/**', 'coverage/**'], 572 | forceExit: true, 573 | refiner: new Refiner(), 574 | reporters: { 575 | activated: ['spec'].concat(process.env.GITHUB_ACTIONS === 'true' ? ['github'] : []), 576 | list: [spec(), ndjson(), dot(), github()], 577 | }, 578 | plugins: [], 579 | setup: [], 580 | teardown: [], 581 | retries: 2, 582 | timeout: 4000, 583 | }, 584 | ], 585 | [ 586 | { 587 | files: ['tests/unit/**.spec.ts'], 588 | timeout: 4000, 589 | retries: 2, 590 | filters: { 591 | tags: ['@integration', '~@slow'], 592 | tests: ['users list'], 593 | suites: ['unit'], 594 | }, 595 | forceExit: true, 596 | }, 597 | new CliParser().parse(['unit', 'functional']), 598 | { 599 | cwd: process.cwd(), 600 | files: ['tests/unit/**.spec.ts'], 601 | filters: { 602 | tags: ['@integration', '~@slow'], 603 | tests: ['users list'], 604 | suites: ['unit', 'functional'], 605 | }, 606 | exclude: ['node_modules/**', '.git/**', 'coverage/**'], 607 | forceExit: true, 608 | refiner: new Refiner(), 609 | reporters: { 610 | activated: ['spec'].concat(process.env.GITHUB_ACTIONS === 'true' ? ['github'] : []), 611 | list: [spec(), ndjson(), dot(), github()], 612 | }, 613 | plugins: [], 614 | setup: [], 615 | teardown: [], 616 | retries: 2, 617 | timeout: 4000, 618 | }, 619 | ], 620 | [ 621 | { 622 | files: [], 623 | reporters: { 624 | activated: ['dot'], 625 | list: [spec()], 626 | }, 627 | }, 628 | new CliParser().parse(['--reporters', 'progress']), 629 | { 630 | cwd: process.cwd(), 631 | files: [], 632 | filters: {}, 633 | exclude: ['node_modules/**', '.git/**', 'coverage/**'], 634 | forceExit: false, 635 | refiner: new Refiner(), 636 | reporters: { 637 | activated: ['progress'], 638 | list: [spec()], 639 | }, 640 | plugins: [], 641 | setup: [], 642 | teardown: [], 643 | retries: 0, 644 | timeout: 2000, 645 | }, 646 | ], 647 | [ 648 | { 649 | files: [], 650 | reporters: { 651 | activated: ['spec'].concat(process.env.GITHUB_ACTIONS === 'true' ? ['github'] : []), 652 | list: [spec()], 653 | }, 654 | }, 655 | new CliParser().parse(['--force-exit']), 656 | { 657 | cwd: process.cwd(), 658 | files: [], 659 | filters: {}, 660 | exclude: ['node_modules/**', '.git/**', 'coverage/**'], 661 | forceExit: true, 662 | refiner: new Refiner(), 663 | reporters: { 664 | activated: ['spec'].concat(process.env.GITHUB_ACTIONS === 'true' ? ['github'] : []), 665 | list: [spec()], 666 | }, 667 | plugins: [], 668 | setup: [], 669 | teardown: [], 670 | retries: 0, 671 | timeout: 2000, 672 | }, 673 | ], 674 | [ 675 | { 676 | files: [], 677 | reporters: { 678 | activated: ['spec'].concat(process.env.GITHUB_ACTIONS === 'true' ? ['github'] : []), 679 | list: [spec()], 680 | }, 681 | }, 682 | new CliParser().parse(['--force-exit=true']), 683 | { 684 | cwd: process.cwd(), 685 | files: [], 686 | filters: {}, 687 | exclude: ['node_modules/**', '.git/**', 'coverage/**'], 688 | forceExit: true, 689 | refiner: new Refiner(), 690 | reporters: { 691 | activated: ['spec'].concat(process.env.GITHUB_ACTIONS === 'true' ? ['github'] : []), 692 | list: [spec()], 693 | }, 694 | plugins: [], 695 | setup: [], 696 | teardown: [], 697 | retries: 0, 698 | timeout: 2000, 699 | }, 700 | ], 701 | [ 702 | { 703 | files: [], 704 | reporters: { 705 | activated: ['spec'].concat(process.env.GITHUB_ACTIONS === 'true' ? ['github'] : []), 706 | list: [spec()], 707 | }, 708 | }, 709 | new CliParser().parse(['--force-exit=false']), 710 | { 711 | cwd: process.cwd(), 712 | files: [], 713 | filters: {}, 714 | exclude: ['node_modules/**', '.git/**', 'coverage/**'], 715 | forceExit: false, 716 | refiner: new Refiner(), 717 | reporters: { 718 | activated: ['spec'].concat(process.env.GITHUB_ACTIONS === 'true' ? ['github'] : []), 719 | list: [spec()], 720 | }, 721 | plugins: [], 722 | setup: [], 723 | teardown: [], 724 | retries: 0, 725 | timeout: 2000, 726 | }, 727 | ], 728 | ] 729 | 730 | test.describe('Config manager', () => { 731 | test('hydrate config from user defined config', async () => { 732 | let index = -1 733 | for (let [userConfig, output] of USER_DEFINED_CONFIG_DATASET) { 734 | index++ 735 | const manager = new ConfigManager(userConfig, {}) 736 | 737 | const config = manager.hydrate() as Config 738 | await wrapAssertions(() => { 739 | const actualReporters = output.reporters 740 | const expectedReporters = config.reporters 741 | const actualReportersList = actualReporters?.list?.map((r) => r.name) 742 | const expectedReportersList = expectedReporters?.list?.map((r) => r.name) 743 | 744 | delete config.importer 745 | delete config.configureSuite 746 | delete config.reporters 747 | delete output.reporters 748 | 749 | assert.deepEqual(config, output) 750 | assert.deepEqual(actualReporters?.activated, expectedReporters?.activated) 751 | assert.deepEqual(actualReportersList, expectedReportersList) 752 | }) 753 | } 754 | }) 755 | 756 | test('hydrate config from user defined config and CLI args', async () => { 757 | let index = -1 758 | for (let [userConfig, cliArgs, output] of USER_DEFINED_CONFIG_DATASET_WITH_CLI_ARGS) { 759 | index++ 760 | const manager = new ConfigManager(userConfig, cliArgs) 761 | 762 | const config = manager.hydrate() as Config 763 | await wrapAssertions(() => { 764 | const actualReporters = output.reporters 765 | const expectedReporters = config.reporters 766 | const actualReportersList = actualReporters?.list?.map((r) => r.name) 767 | const expectedReportersList = expectedReporters?.list?.map((r) => r.name) 768 | 769 | delete config.importer 770 | delete config.configureSuite 771 | delete config.reporters 772 | delete output.reporters 773 | 774 | assert.deepEqual(config, output) 775 | assert.deepEqual(actualReporters?.activated, expectedReporters?.activated) 776 | assert.deepEqual(actualReportersList, expectedReportersList) 777 | }) 778 | } 779 | }) 780 | }) 781 | -------------------------------------------------------------------------------- /tests/core.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { assert } from 'chai' 11 | import { test } from 'node:test' 12 | import { wrapAssertions } from './helpers.js' 13 | import { Emitter, Refiner, Test, TestContext } from '../modules/core/main.js' 14 | 15 | test.describe('Core', () => { 16 | test('define test cleanup callback using the test context', async () => { 17 | let stack: string[] = [] 18 | 19 | const context = (t: Test) => new TestContext(t) 20 | const emitter = new Emitter() 21 | const refiner = new Refiner() 22 | const t = new Test('foo', context, emitter, refiner) 23 | 24 | t.run(({ cleanup }) => { 25 | cleanup(() => { 26 | stack.push('cleanup') 27 | }) 28 | stack.push('executed') 29 | }) 30 | 31 | await t.exec() 32 | await wrapAssertions(() => { 33 | assert.deepEqual(stack, ['executed', 'cleanup']) 34 | }) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /tests/files_manager.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { assert } from 'chai' 11 | import { test } from 'node:test' 12 | import { fileURLToPath, pathToFileURL } from 'node:url' 13 | 14 | import { FilesManager } from '../src/files_manager.js' 15 | import { wrapAssertions } from './helpers.js' 16 | 17 | const FILTERING_DATASET: { files: URL[]; filters: string[]; output: URL[] }[] = [ 18 | { 19 | files: [ 20 | pathToFileURL('tests/unit/create_user.spec.js'), 21 | pathToFileURL('tests/unit/list_user.spec.js'), 22 | pathToFileURL('tests/unit/edit_user.spec.js'), 23 | pathToFileURL('tests/functional/user.spec.js'), 24 | pathToFileURL('tests/functional/register_user.spec.js'), 25 | ], 26 | filters: ['user'], 27 | output: [ 28 | pathToFileURL('tests/unit/create_user.spec.js'), 29 | pathToFileURL('tests/unit/list_user.spec.js'), 30 | pathToFileURL('tests/unit/edit_user.spec.js'), 31 | pathToFileURL('tests/functional/user.spec.js'), 32 | pathToFileURL('tests/functional/register_user.spec.js'), 33 | ], 34 | }, 35 | { 36 | files: [ 37 | pathToFileURL('tests/unit/create_user.spec.js'), 38 | pathToFileURL('tests/unit/list_user.spec.js'), 39 | pathToFileURL('tests/unit/edit_user.spec.js'), 40 | pathToFileURL('tests/functional/user.spec.js'), 41 | pathToFileURL('tests/functional/register_user.spec.js'), 42 | ], 43 | filters: ['unit/user'], 44 | output: [ 45 | pathToFileURL('tests/unit/create_user.spec.js'), 46 | pathToFileURL('tests/unit/list_user.spec.js'), 47 | pathToFileURL('tests/unit/edit_user.spec.js'), 48 | ], 49 | }, 50 | { 51 | files: [ 52 | pathToFileURL('tests/unit/create_user.spec.js'), 53 | pathToFileURL('tests/unit/list_user.spec.js'), 54 | pathToFileURL('tests/unit/edit_user.spec.js'), 55 | pathToFileURL('tests/functional/user.spec.js'), 56 | pathToFileURL('tests/functional/register_user.spec.js'), 57 | ], 58 | filters: ['onal/user'], 59 | output: [ 60 | pathToFileURL('tests/functional/user.spec.js'), 61 | pathToFileURL('tests/functional/register_user.spec.js'), 62 | ], 63 | }, 64 | { 65 | files: [ 66 | pathToFileURL('tests/unit/users/create.spec.js'), 67 | pathToFileURL('tests/unit/users/edit.spec.js'), 68 | pathToFileURL('tests/unit/users/delete.spec.js'), 69 | pathToFileURL('tests/functional/users.spec.js'), 70 | pathToFileURL('tests/functional/register_user.spec.js'), 71 | ], 72 | filters: ['unit/users/*'], 73 | output: [ 74 | pathToFileURL('tests/unit/users/create.spec.js'), 75 | pathToFileURL('tests/unit/users/edit.spec.js'), 76 | pathToFileURL('tests/unit/users/delete.spec.js'), 77 | ], 78 | }, 79 | { 80 | files: [ 81 | pathToFileURL('tests/unit/users/create.spec.js'), 82 | pathToFileURL('tests/unit/users/edit.spec.js'), 83 | pathToFileURL('tests/unit/users/delete.spec.js'), 84 | pathToFileURL('tests/functional/users.spec.js'), 85 | pathToFileURL('tests/functional/register_user.spec.js'), 86 | ], 87 | filters: ['users/create.spec.js'], 88 | output: [pathToFileURL('tests/unit/users/create.spec.js')], 89 | }, 90 | ] 91 | 92 | test.describe('Files manager | grep', () => { 93 | test('apply filter on the file name', async () => { 94 | for (let { files, filters, output } of FILTERING_DATASET) { 95 | await wrapAssertions(() => { 96 | assert.deepEqual(new FilesManager().grep(files, filters), output) 97 | }) 98 | } 99 | }) 100 | }) 101 | 102 | test.describe('Files manager | getFiles', () => { 103 | test('get files for the glob pattern', async () => { 104 | const cwd = new URL('../', import.meta.url) 105 | const files = await new FilesManager().getFiles(fileURLToPath(cwd), ['tests/**/*.spec.ts'], []) 106 | 107 | await wrapAssertions(() => { 108 | assert.deepEqual(files, [ 109 | new URL('tests/base_reporter.spec.ts', cwd), 110 | new URL('tests/cli_parser.spec.ts', cwd), 111 | new URL('tests/config_manager.spec.ts', cwd), 112 | new URL('tests/core.spec.ts', cwd), 113 | new URL('tests/files_manager.spec.ts', cwd), 114 | new URL('tests/github_reporter.spec.ts', cwd), 115 | new URL('tests/planner.spec.ts', cwd), 116 | new URL('tests/runner.spec.ts', cwd), 117 | ]) 118 | }) 119 | }) 120 | 121 | test('get files from multiple glob patterns', async () => { 122 | const cwd = new URL('../', import.meta.url) 123 | const files = await new FilesManager().getFiles( 124 | fileURLToPath(cwd), 125 | ['tests/**/*.spec.ts', 'modules/**/*.ts'], 126 | [] 127 | ) 128 | 129 | await wrapAssertions(() => { 130 | assert.deepEqual(files, [ 131 | new URL('tests/base_reporter.spec.ts', cwd), 132 | new URL('tests/cli_parser.spec.ts', cwd), 133 | new URL('tests/config_manager.spec.ts', cwd), 134 | new URL('tests/core.spec.ts', cwd), 135 | new URL('tests/files_manager.spec.ts', cwd), 136 | new URL('tests/github_reporter.spec.ts', cwd), 137 | new URL('tests/planner.spec.ts', cwd), 138 | new URL('tests/runner.spec.ts', cwd), 139 | new URL('modules/core/main.ts', cwd), 140 | new URL('modules/core/types.ts', cwd), 141 | new URL('modules/core/reporters/base.ts', cwd), 142 | ]) 143 | }) 144 | }) 145 | 146 | test('ignore files using the exclude pattern', async () => { 147 | const cwd = new URL('../', import.meta.url) 148 | const files = await new FilesManager().getFiles( 149 | fileURLToPath(cwd), 150 | ['tests/**/*.spec.ts', 'modules/**/*.ts'], 151 | ['modules/**'] 152 | ) 153 | 154 | await wrapAssertions(() => { 155 | assert.deepEqual(files, [ 156 | new URL('tests/base_reporter.spec.ts', cwd), 157 | new URL('tests/cli_parser.spec.ts', cwd), 158 | new URL('tests/config_manager.spec.ts', cwd), 159 | new URL('tests/core.spec.ts', cwd), 160 | new URL('tests/files_manager.spec.ts', cwd), 161 | new URL('tests/github_reporter.spec.ts', cwd), 162 | new URL('tests/planner.spec.ts', cwd), 163 | new URL('tests/runner.spec.ts', cwd), 164 | ]) 165 | }) 166 | }) 167 | 168 | test('get files from a custom implementation', async () => { 169 | const cwd = new URL('../', import.meta.url) 170 | const files = await new FilesManager().getFiles( 171 | fileURLToPath(cwd), 172 | () => { 173 | return [ 174 | new URL('tests/cli_parser.spec.ts', cwd), 175 | new URL('tests/config_manager.spec.ts', cwd), 176 | new URL('tests/files_manager.spec.ts', cwd), 177 | new URL('tests/github_reporter.spec.ts', cwd), 178 | new URL('modules/core/main.ts', cwd), 179 | ] 180 | }, 181 | [] 182 | ) 183 | 184 | await wrapAssertions(() => { 185 | assert.deepEqual(files, [ 186 | new URL('tests/cli_parser.spec.ts', cwd), 187 | new URL('tests/config_manager.spec.ts', cwd), 188 | new URL('tests/files_manager.spec.ts', cwd), 189 | new URL('tests/github_reporter.spec.ts', cwd), 190 | new URL('modules/core/main.ts', cwd), 191 | ]) 192 | }) 193 | }) 194 | }) 195 | -------------------------------------------------------------------------------- /tests/github_reporter.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from 'node:test' 11 | import { assert, use } from 'chai' 12 | import chaiSubset from 'chai-subset' 13 | import { ErrorsPrinter } from '@japa/errors-printer' 14 | 15 | import { wrapAssertions } from './helpers.js' 16 | import { GithubReporter } from '../src/reporters/github.js' 17 | 18 | use(chaiSubset) 19 | 20 | test.describe('Github reporter', () => { 21 | test('report errors in correct format', async () => { 22 | const reporter = new GithubReporter() 23 | const errorPrinter = new ErrorsPrinter() 24 | const annotation = await reporter.getErrorAnnotation(errorPrinter, { 25 | phase: 'test', 26 | title: '2 + 2 is 4', 27 | error: new Error('Expected 5 to equal 4'), 28 | }) 29 | 30 | wrapAssertions(() => { 31 | assert.equal( 32 | annotation, 33 | '::error file=tests/github_reporter.spec.ts,title=2 + 2 is 4,line=27,column=14::Expected 5 to equal 4' 34 | ) 35 | }) 36 | }) 37 | 38 | test('do not report values other than errors', async () => { 39 | const reporter = new GithubReporter() 40 | const errorPrinter = new ErrorsPrinter() 41 | const annotation = await reporter.getErrorAnnotation(errorPrinter, { 42 | phase: 'test', 43 | title: '2 + 2 is 4', 44 | error: 22, 45 | } as any) 46 | 47 | wrapAssertions(() => { 48 | assert.isUndefined(annotation) 49 | }) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /tests/helpers.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { ErrorsPrinter } from '@japa/errors-printer' 11 | import { Emitter } from '../modules/core/main.js' 12 | import { RunnerEvents } from '../src/types.js' 13 | 14 | export async function wrapAssertions(fn: () => void | Promise) { 15 | try { 16 | await fn() 17 | } catch (error) { 18 | await new ErrorsPrinter().printError(error) 19 | throw new Error('Assertion failure') 20 | } 21 | } 22 | 23 | /** 24 | * Promisify an event 25 | */ 26 | export function pEvent( 27 | emitter: Emitter, 28 | event: Name, 29 | timeout: number = 500 30 | ) { 31 | return new Promise((resolve) => { 32 | function handler(data: RunnerEvents[Name]) { 33 | emitter.off(event, handler) 34 | resolve(data) 35 | } 36 | 37 | setTimeout(() => { 38 | emitter.off(event, handler) 39 | resolve(null) 40 | }, timeout) 41 | emitter.on(event, handler) 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /tests/planner.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { assert } from 'chai' 11 | import { test } from 'node:test' 12 | import { Planner } from '../src/planner.js' 13 | import { ConfigManager, NOOP } from '../src/config_manager.js' 14 | import { wrapAssertions } from './helpers.js' 15 | 16 | test.describe('Planner | files', () => { 17 | test('get suites for files', async () => { 18 | const config = new ConfigManager({ files: ['tests/**/*.spec.ts'] }, {}).hydrate() 19 | const { suites } = await new Planner(config).plan() 20 | 21 | await wrapAssertions(() => { 22 | assert.deepEqual(suites, [ 23 | { 24 | name: 'default', 25 | files: ['tests/**/*.spec.ts'], 26 | filesURLs: [ 27 | new URL('../tests/base_reporter.spec.ts', import.meta.url), 28 | new URL('../tests/cli_parser.spec.ts', import.meta.url), 29 | new URL('../tests/config_manager.spec.ts', import.meta.url), 30 | new URL('../tests/core.spec.ts', import.meta.url), 31 | new URL('../tests/files_manager.spec.ts', import.meta.url), 32 | new URL('../tests/github_reporter.spec.ts', import.meta.url), 33 | new URL('../tests/planner.spec.ts', import.meta.url), 34 | new URL('../tests/runner.spec.ts', import.meta.url), 35 | ], 36 | retries: 0, 37 | timeout: 2000, 38 | }, 39 | ]) 40 | }) 41 | }) 42 | 43 | test('apply files filter to the list', async () => { 44 | const config = new ConfigManager( 45 | { files: ['tests/**/*.spec.ts'], filters: { files: ['parser'] } }, 46 | {} 47 | ).hydrate() 48 | const { suites } = await new Planner(config).plan() 49 | 50 | await wrapAssertions(() => { 51 | assert.deepEqual(suites, [ 52 | { 53 | name: 'default', 54 | files: ['tests/**/*.spec.ts'], 55 | filesURLs: [new URL('../tests/cli_parser.spec.ts', import.meta.url)], 56 | retries: 0, 57 | timeout: 2000, 58 | }, 59 | ]) 60 | }) 61 | }) 62 | 63 | test('use inline global timeout', async () => { 64 | const config = new ConfigManager({ files: ['tests/**/*.spec.ts'], timeout: 1000 }, {}).hydrate() 65 | const { suites } = await new Planner(config).plan() 66 | 67 | await wrapAssertions(() => { 68 | assert.deepEqual(suites, [ 69 | { 70 | name: 'default', 71 | files: ['tests/**/*.spec.ts'], 72 | filesURLs: [ 73 | new URL('../tests/base_reporter.spec.ts', import.meta.url), 74 | new URL('../tests/cli_parser.spec.ts', import.meta.url), 75 | new URL('../tests/config_manager.spec.ts', import.meta.url), 76 | new URL('../tests/core.spec.ts', import.meta.url), 77 | new URL('../tests/files_manager.spec.ts', import.meta.url), 78 | new URL('../tests/github_reporter.spec.ts', import.meta.url), 79 | new URL('../tests/planner.spec.ts', import.meta.url), 80 | new URL('../tests/runner.spec.ts', import.meta.url), 81 | ], 82 | retries: 0, 83 | timeout: 1000, 84 | }, 85 | ]) 86 | }) 87 | }) 88 | 89 | test('use cli timeout', async () => { 90 | const config = new ConfigManager( 91 | { files: ['tests/**/*.spec.ts'], timeout: 1000 }, 92 | { 93 | timeout: '3000', 94 | } 95 | ).hydrate() 96 | const { suites } = await new Planner(config).plan() 97 | 98 | await wrapAssertions(() => { 99 | assert.deepEqual(suites, [ 100 | { 101 | name: 'default', 102 | files: ['tests/**/*.spec.ts'], 103 | filesURLs: [ 104 | new URL('../tests/base_reporter.spec.ts', import.meta.url), 105 | new URL('../tests/cli_parser.spec.ts', import.meta.url), 106 | new URL('../tests/config_manager.spec.ts', import.meta.url), 107 | new URL('../tests/core.spec.ts', import.meta.url), 108 | new URL('../tests/files_manager.spec.ts', import.meta.url), 109 | new URL('../tests/github_reporter.spec.ts', import.meta.url), 110 | new URL('../tests/planner.spec.ts', import.meta.url), 111 | new URL('../tests/runner.spec.ts', import.meta.url), 112 | ], 113 | retries: 0, 114 | timeout: 3000, 115 | }, 116 | ]) 117 | }) 118 | }) 119 | 120 | test('use inline global retries', async () => { 121 | const config = new ConfigManager({ files: ['tests/**/*.spec.ts'], retries: 2 }, {}).hydrate() 122 | const { suites } = await new Planner(config).plan() 123 | 124 | await wrapAssertions(() => { 125 | assert.deepEqual(suites, [ 126 | { 127 | name: 'default', 128 | files: ['tests/**/*.spec.ts'], 129 | filesURLs: [ 130 | new URL('../tests/base_reporter.spec.ts', import.meta.url), 131 | new URL('../tests/cli_parser.spec.ts', import.meta.url), 132 | new URL('../tests/config_manager.spec.ts', import.meta.url), 133 | new URL('../tests/core.spec.ts', import.meta.url), 134 | new URL('../tests/files_manager.spec.ts', import.meta.url), 135 | new URL('../tests/github_reporter.spec.ts', import.meta.url), 136 | new URL('../tests/planner.spec.ts', import.meta.url), 137 | new URL('../tests/runner.spec.ts', import.meta.url), 138 | ], 139 | retries: 2, 140 | timeout: 2000, 141 | }, 142 | ]) 143 | }) 144 | }) 145 | 146 | test('use cli retries', async () => { 147 | const config = new ConfigManager( 148 | { files: ['tests/**/*.spec.ts'], retries: 2 }, 149 | { 150 | retries: '3', 151 | } 152 | ).hydrate() 153 | const { suites } = await new Planner(config).plan() 154 | 155 | await wrapAssertions(() => { 156 | assert.deepEqual(suites, [ 157 | { 158 | name: 'default', 159 | files: ['tests/**/*.spec.ts'], 160 | filesURLs: [ 161 | new URL('../tests/base_reporter.spec.ts', import.meta.url), 162 | new URL('../tests/cli_parser.spec.ts', import.meta.url), 163 | new URL('../tests/config_manager.spec.ts', import.meta.url), 164 | new URL('../tests/core.spec.ts', import.meta.url), 165 | new URL('../tests/files_manager.spec.ts', import.meta.url), 166 | new URL('../tests/github_reporter.spec.ts', import.meta.url), 167 | new URL('../tests/planner.spec.ts', import.meta.url), 168 | new URL('../tests/runner.spec.ts', import.meta.url), 169 | ], 170 | retries: 3, 171 | timeout: 2000, 172 | }, 173 | ]) 174 | }) 175 | }) 176 | 177 | test('error when suite filter is applied with files', async () => { 178 | const config = new ConfigManager( 179 | { 180 | files: [], 181 | }, 182 | { 183 | _: ['functional'], 184 | } 185 | ).hydrate() 186 | 187 | await wrapAssertions(() => { 188 | assert.throws( 189 | () => new Planner(config), 190 | 'Cannot apply suites filter. You have not configured any test suites' 191 | ) 192 | }) 193 | }) 194 | }) 195 | 196 | test.describe('Planner | suites', () => { 197 | test('get suites', async () => { 198 | const config = new ConfigManager( 199 | { suites: [{ name: 'unit', files: 'tests/**/*.spec.ts' }] }, 200 | {} 201 | ).hydrate() 202 | const { suites } = await new Planner(config).plan() 203 | 204 | await wrapAssertions(() => { 205 | assert.deepEqual(suites, [ 206 | { 207 | name: 'unit', 208 | files: 'tests/**/*.spec.ts', 209 | filesURLs: [ 210 | new URL('../tests/base_reporter.spec.ts', import.meta.url), 211 | new URL('../tests/cli_parser.spec.ts', import.meta.url), 212 | new URL('../tests/config_manager.spec.ts', import.meta.url), 213 | new URL('../tests/core.spec.ts', import.meta.url), 214 | new URL('../tests/files_manager.spec.ts', import.meta.url), 215 | new URL('../tests/github_reporter.spec.ts', import.meta.url), 216 | new URL('../tests/planner.spec.ts', import.meta.url), 217 | new URL('../tests/runner.spec.ts', import.meta.url), 218 | ], 219 | retries: 0, 220 | timeout: 2000, 221 | configure: NOOP, 222 | }, 223 | ]) 224 | }) 225 | }) 226 | 227 | test('apply files filter to the suites', async () => { 228 | const config = new ConfigManager( 229 | { suites: [{ name: 'unit', files: 'tests/**/*.spec.ts' }], filters: { files: ['manager'] } }, 230 | {} 231 | ).hydrate() 232 | const { suites } = await new Planner(config).plan() 233 | 234 | await wrapAssertions(() => { 235 | assert.deepEqual(suites, [ 236 | { 237 | name: 'unit', 238 | files: 'tests/**/*.spec.ts', 239 | filesURLs: [ 240 | new URL('../tests/config_manager.spec.ts', import.meta.url), 241 | new URL('../tests/files_manager.spec.ts', import.meta.url), 242 | ], 243 | retries: 0, 244 | timeout: 2000, 245 | configure: NOOP, 246 | }, 247 | ]) 248 | }) 249 | }) 250 | 251 | test('use inline timeout', async () => { 252 | const config = new ConfigManager( 253 | { 254 | suites: [{ name: 'unit', files: 'tests/**/*.spec.ts', timeout: 1000 }], 255 | }, 256 | {} 257 | ).hydrate() 258 | const { suites } = await new Planner(config).plan() 259 | 260 | await wrapAssertions(() => { 261 | assert.deepEqual(suites, [ 262 | { 263 | name: 'unit', 264 | files: 'tests/**/*.spec.ts', 265 | filesURLs: [ 266 | new URL('../tests/base_reporter.spec.ts', import.meta.url), 267 | new URL('../tests/cli_parser.spec.ts', import.meta.url), 268 | new URL('../tests/config_manager.spec.ts', import.meta.url), 269 | new URL('../tests/core.spec.ts', import.meta.url), 270 | new URL('../tests/files_manager.spec.ts', import.meta.url), 271 | new URL('../tests/github_reporter.spec.ts', import.meta.url), 272 | new URL('../tests/planner.spec.ts', import.meta.url), 273 | new URL('../tests/runner.spec.ts', import.meta.url), 274 | ], 275 | retries: 0, 276 | timeout: 1000, 277 | configure: NOOP, 278 | }, 279 | ]) 280 | }) 281 | }) 282 | 283 | test('use cli timeout', async () => { 284 | const config = new ConfigManager( 285 | { suites: [{ name: 'unit', files: 'tests/**/*.spec.ts', timeout: 1000 }] }, 286 | { 287 | timeout: '3000', 288 | } 289 | ).hydrate() 290 | const { suites } = await new Planner(config).plan() 291 | 292 | await wrapAssertions(() => { 293 | assert.deepEqual(suites, [ 294 | { 295 | name: 'unit', 296 | files: 'tests/**/*.spec.ts', 297 | filesURLs: [ 298 | new URL('../tests/base_reporter.spec.ts', import.meta.url), 299 | new URL('../tests/cli_parser.spec.ts', import.meta.url), 300 | new URL('../tests/config_manager.spec.ts', import.meta.url), 301 | new URL('../tests/core.spec.ts', import.meta.url), 302 | new URL('../tests/files_manager.spec.ts', import.meta.url), 303 | new URL('../tests/github_reporter.spec.ts', import.meta.url), 304 | new URL('../tests/planner.spec.ts', import.meta.url), 305 | new URL('../tests/runner.spec.ts', import.meta.url), 306 | ], 307 | retries: 0, 308 | timeout: 3000, 309 | configure: NOOP, 310 | }, 311 | ]) 312 | }) 313 | }) 314 | 315 | test('use inline retries', async () => { 316 | const config = new ConfigManager( 317 | { suites: [{ name: 'unit', files: 'tests/**/*.spec.ts', retries: 2 }] }, 318 | {} 319 | ).hydrate() 320 | const { suites } = await new Planner(config).plan() 321 | 322 | await wrapAssertions(() => { 323 | assert.deepEqual(suites, [ 324 | { 325 | name: 'unit', 326 | files: 'tests/**/*.spec.ts', 327 | filesURLs: [ 328 | new URL('../tests/base_reporter.spec.ts', import.meta.url), 329 | new URL('../tests/cli_parser.spec.ts', import.meta.url), 330 | new URL('../tests/config_manager.spec.ts', import.meta.url), 331 | new URL('../tests/core.spec.ts', import.meta.url), 332 | new URL('../tests/files_manager.spec.ts', import.meta.url), 333 | new URL('../tests/github_reporter.spec.ts', import.meta.url), 334 | new URL('../tests/planner.spec.ts', import.meta.url), 335 | new URL('../tests/runner.spec.ts', import.meta.url), 336 | ], 337 | retries: 2, 338 | timeout: 2000, 339 | configure: NOOP, 340 | }, 341 | ]) 342 | }) 343 | }) 344 | 345 | test('use cli retries', async () => { 346 | const config = new ConfigManager( 347 | { suites: [{ name: 'unit', files: 'tests/**/*.spec.ts', retries: 2 }] }, 348 | { retries: '3' } 349 | ).hydrate() 350 | const { suites } = await new Planner(config).plan() 351 | 352 | await wrapAssertions(() => { 353 | assert.deepEqual(suites, [ 354 | { 355 | name: 'unit', 356 | files: 'tests/**/*.spec.ts', 357 | filesURLs: [ 358 | new URL('../tests/base_reporter.spec.ts', import.meta.url), 359 | new URL('../tests/cli_parser.spec.ts', import.meta.url), 360 | new URL('../tests/config_manager.spec.ts', import.meta.url), 361 | new URL('../tests/core.spec.ts', import.meta.url), 362 | new URL('../tests/files_manager.spec.ts', import.meta.url), 363 | new URL('../tests/github_reporter.spec.ts', import.meta.url), 364 | new URL('../tests/planner.spec.ts', import.meta.url), 365 | new URL('../tests/runner.spec.ts', import.meta.url), 366 | ], 367 | retries: 3, 368 | timeout: 2000, 369 | configure: NOOP, 370 | }, 371 | ]) 372 | }) 373 | }) 374 | 375 | test('error on duplicate suites', async () => { 376 | const config = new ConfigManager( 377 | { 378 | suites: [ 379 | { name: 'unit', files: 'tests/**/*.spec.ts' }, 380 | { name: 'unit', files: 'tests/**/*.spec.ts' }, 381 | ], 382 | }, 383 | {} 384 | ).hydrate() 385 | 386 | await wrapAssertions(() => { 387 | assert.throws(() => new Planner(config), 'Duplicate suite "unit"') 388 | }) 389 | }) 390 | 391 | test('error when suite filter mentions non-existing suite', async () => { 392 | const config = new ConfigManager( 393 | { 394 | suites: [{ name: 'unit', files: 'tests/**/*.spec.ts' }], 395 | }, 396 | { 397 | _: ['functional'], 398 | } 399 | ).hydrate() 400 | 401 | await wrapAssertions(() => { 402 | assert.throws( 403 | () => new Planner(config), 404 | 'Cannot apply suites filter. "functional" suite is not configured' 405 | ) 406 | }) 407 | }) 408 | 409 | test('error when suite filter is applied without defining suites', async () => { 410 | const config = new ConfigManager( 411 | { 412 | suites: [], 413 | }, 414 | { 415 | _: ['functional'], 416 | } 417 | ).hydrate() 418 | 419 | await wrapAssertions(() => { 420 | assert.throws( 421 | () => new Planner(config), 422 | 'Cannot apply suites filter. You have not configured any test suites' 423 | ) 424 | }) 425 | }) 426 | 427 | test('apply suites filter', async () => { 428 | const config = new ConfigManager( 429 | { 430 | suites: [ 431 | { name: 'unit', files: 'tests/**/*.spec.ts' }, 432 | { name: 'functional', files: 'tests/**/*.spec.ts' }, 433 | ], 434 | }, 435 | { 436 | _: ['functional'], 437 | } 438 | ).hydrate() 439 | 440 | const { suites } = await new Planner(config).plan() 441 | await wrapAssertions(() => { 442 | assert.deepEqual(suites, [ 443 | { 444 | name: 'functional', 445 | files: 'tests/**/*.spec.ts', 446 | filesURLs: [ 447 | new URL('../tests/base_reporter.spec.ts', import.meta.url), 448 | new URL('../tests/cli_parser.spec.ts', import.meta.url), 449 | new URL('../tests/config_manager.spec.ts', import.meta.url), 450 | new URL('../tests/core.spec.ts', import.meta.url), 451 | new URL('../tests/files_manager.spec.ts', import.meta.url), 452 | new URL('../tests/github_reporter.spec.ts', import.meta.url), 453 | new URL('../tests/planner.spec.ts', import.meta.url), 454 | new URL('../tests/runner.spec.ts', import.meta.url), 455 | ], 456 | retries: 0, 457 | timeout: 2000, 458 | configure: NOOP, 459 | }, 460 | ]) 461 | }) 462 | }) 463 | }) 464 | 465 | test.describe('Planner | reporters', () => { 466 | test('get collection of activated reporters', async () => { 467 | const config = new ConfigManager({ files: ['tests/**/*.spec.ts'] }, {}).hydrate() 468 | const { reporters } = await new Planner(config).plan() 469 | 470 | await wrapAssertions(() => { 471 | assert.deepEqual( 472 | reporters, 473 | [ 474 | { 475 | handler: reporters[0].handler, 476 | name: 'spec', 477 | }, 478 | ].concat( 479 | process.env.GITHUB_ACTIONS === 'true' 480 | ? [ 481 | { 482 | handler: reporters[1].handler, 483 | name: 'github', 484 | }, 485 | ] 486 | : [] 487 | ) 488 | ) 489 | }) 490 | }) 491 | 492 | test('get collection of manually activated reporters', async () => { 493 | const config = new ConfigManager( 494 | { 495 | files: ['tests/**/*.spec.ts'], 496 | reporters: { 497 | activated: ['dot'], 498 | list: [ 499 | { 500 | name: 'spec', 501 | handler: {} as any, 502 | }, 503 | { 504 | name: 'dot', 505 | handler: {} as any, 506 | }, 507 | ], 508 | }, 509 | }, 510 | {} 511 | ).hydrate() 512 | const { reporters } = await new Planner(config).plan() 513 | 514 | await wrapAssertions(() => { 515 | assert.deepEqual(reporters, [ 516 | { 517 | handler: {} as any, 518 | name: 'dot', 519 | }, 520 | ]) 521 | }) 522 | }) 523 | 524 | test('get collection of reporters activated via CLI flag', async () => { 525 | const config = new ConfigManager( 526 | { 527 | files: ['tests/**/*.spec.ts'], 528 | reporters: { 529 | activated: ['dot'], 530 | list: [ 531 | { 532 | name: 'spec', 533 | handler: {} as any, 534 | }, 535 | { 536 | name: 'dot', 537 | handler: {} as any, 538 | }, 539 | ], 540 | }, 541 | }, 542 | { 543 | reporters: 'spec', 544 | } 545 | ).hydrate() 546 | const { reporters } = await new Planner(config).plan() 547 | 548 | await wrapAssertions(() => { 549 | assert.deepEqual(reporters, [ 550 | { 551 | handler: {} as any, 552 | name: 'spec', 553 | }, 554 | ]) 555 | }) 556 | }) 557 | 558 | test('report error when activated reporter is not in the list', async () => { 559 | const config = new ConfigManager( 560 | { 561 | files: ['tests/**/*.spec.ts'], 562 | reporters: { 563 | activated: ['progress'], 564 | list: [ 565 | { 566 | name: 'spec', 567 | handler: {} as any, 568 | }, 569 | { 570 | name: 'dot', 571 | handler: {} as any, 572 | }, 573 | ], 574 | }, 575 | }, 576 | {} 577 | ).hydrate() 578 | 579 | await wrapAssertions(() => { 580 | assert.throws( 581 | () => new Planner(config), 582 | 'Invalid reporter "progress". Make sure to register it first inside the "reporters.list" array' 583 | ) 584 | }) 585 | }) 586 | 587 | test('report error when CLI activated reporter is not in the list', async () => { 588 | const config = new ConfigManager( 589 | { 590 | files: ['tests/**/*.spec.ts'], 591 | reporters: { 592 | activated: ['dot'], 593 | list: [ 594 | { 595 | name: 'spec', 596 | handler: {} as any, 597 | }, 598 | { 599 | name: 'dot', 600 | handler: {} as any, 601 | }, 602 | ], 603 | }, 604 | }, 605 | { 606 | reporters: 'progress', 607 | } 608 | ).hydrate() 609 | 610 | await wrapAssertions(() => { 611 | assert.throws( 612 | () => new Planner(config), 613 | 'Invalid reporter "progress". Make sure to register it first inside the "reporters.list" array' 614 | ) 615 | }) 616 | }) 617 | }) 618 | 619 | test.describe('Planner | refinerFilters', () => { 620 | test('get refinerFilters from the filters list', async () => { 621 | const config = new ConfigManager( 622 | { 623 | files: ['tests/**/*.spec.ts'], 624 | filters: { 625 | tests: ['user'], 626 | tags: ['@slow'], 627 | files: ['manager'], 628 | groups: ['customers'], 629 | }, 630 | }, 631 | {} 632 | ).hydrate() 633 | const { refinerFilters } = await new Planner(config).plan() 634 | 635 | await wrapAssertions(() => { 636 | assert.deepEqual(refinerFilters, [ 637 | { 638 | layer: 'tests', 639 | filters: ['user'], 640 | }, 641 | { 642 | layer: 'tags', 643 | filters: ['@slow'], 644 | }, 645 | { 646 | layer: 'groups', 647 | filters: ['customers'], 648 | }, 649 | ]) 650 | }) 651 | }) 652 | }) 653 | -------------------------------------------------------------------------------- /tests/runner.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/runner 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { assert } from 'chai' 11 | import { test } from 'node:test' 12 | 13 | import { runner } from '../factories/main.js' 14 | import { GlobalHooks } from '../src/hooks.js' 15 | import { ConfigManager } from '../src/config_manager.js' 16 | import { pEvent, wrapAssertions } from './helpers.js' 17 | import { createTest, createTestGroup } from '../src/create_test.js' 18 | import { Emitter, Refiner, Runner, Suite } from '../modules/core/main.js' 19 | import { clearCache, getFailedTests, retryPlugin } from '../src/plugins/retry.js' 20 | 21 | test.describe('Runner | create tests and groups', () => { 22 | test('raise error when defining nested groups', async () => { 23 | const emitter = new Emitter() 24 | const refiner = new Refiner() 25 | const group = createTestGroup('', emitter, refiner, {}) 26 | 27 | await wrapAssertions(() => { 28 | assert.throws( 29 | () => createTestGroup('', emitter, refiner, { group }), 30 | 'Nested groups are not supported by Japa' 31 | ) 32 | }) 33 | }) 34 | 35 | test('add group to the suite when defined', async () => { 36 | const emitter = new Emitter() 37 | const refiner = new Refiner() 38 | const suite = new Suite('', emitter, refiner) 39 | const group = createTestGroup('', emitter, refiner, { suite }) 40 | 41 | await wrapAssertions(() => { 42 | assert.deepEqual(suite.stack, [group]) 43 | }) 44 | }) 45 | 46 | test('add test to the suite when defined', async () => { 47 | const emitter = new Emitter() 48 | const refiner = new Refiner() 49 | const suite = new Suite('', emitter, refiner) 50 | const t = createTest('', emitter, refiner, { suite }) 51 | 52 | await wrapAssertions(() => { 53 | assert.deepEqual(suite.stack, [t]) 54 | }) 55 | }) 56 | 57 | test('add test to the group when group and suite both are defined', async () => { 58 | const emitter = new Emitter() 59 | const refiner = new Refiner() 60 | const suite = new Suite('', emitter, refiner) 61 | 62 | const group = createTestGroup('', emitter, refiner, { suite }) 63 | const t = createTest('', emitter, refiner, { suite, group }) 64 | 65 | await wrapAssertions(() => { 66 | assert.deepEqual(suite.stack, [group]) 67 | assert.deepEqual(group.tests, [t]) 68 | }) 69 | }) 70 | 71 | test('define test timeout from global options', async () => { 72 | const emitter = new Emitter() 73 | const refiner = new Refiner() 74 | const t = createTest('', emitter, refiner, { timeout: 1000 }) 75 | 76 | await wrapAssertions(() => { 77 | assert.equal(t.options.timeout, 1000) 78 | }) 79 | }) 80 | 81 | test('define test retries from global options', async () => { 82 | const emitter = new Emitter() 83 | const refiner = new Refiner() 84 | const t = createTest('', emitter, refiner, { retries: 4 }) 85 | 86 | await wrapAssertions(() => { 87 | assert.equal(t.options.retries, 4) 88 | }) 89 | }) 90 | 91 | test('execute test', async () => { 92 | const stack: string[] = [] 93 | const emitter = new Emitter() 94 | const refiner = new Refiner() 95 | const t = createTest('', emitter, refiner, { retries: 4 }) 96 | t.run(() => { 97 | stack.push('executed') 98 | }) 99 | 100 | await t.exec() 101 | await wrapAssertions(() => { 102 | assert.deepEqual(stack, ['executed']) 103 | }) 104 | }) 105 | 106 | test('assert test throws an exception', async () => { 107 | const emitter = new Emitter() 108 | const refiner = new Refiner() 109 | const t = createTest('', emitter, refiner, {}) 110 | t.run(() => { 111 | throw new Error('Failed') 112 | }).throws('Failed') 113 | 114 | const [event] = await Promise.all([pEvent(emitter, 'test:end'), t.exec()]) 115 | 116 | await wrapAssertions(() => { 117 | assert.equal(event!.hasError, false) 118 | }) 119 | }) 120 | 121 | test('assert error matches the regular expression', async () => { 122 | const emitter = new Emitter() 123 | const refiner = new Refiner() 124 | const t = createTest('', emitter, refiner, {}) 125 | t.run(() => { 126 | throw new Error('Failed') 127 | }).throws(/ed?/) 128 | 129 | const [event] = await Promise.all([pEvent(emitter, 'test:end'), t.exec()]) 130 | 131 | await wrapAssertions(() => { 132 | assert.equal(event!.hasError, false) 133 | }) 134 | }) 135 | 136 | test('throw error when test does not have a callback defined', async () => { 137 | const emitter = new Emitter() 138 | const refiner = new Refiner() 139 | const t = createTest('', emitter, refiner, {}) 140 | 141 | await wrapAssertions(() => { 142 | assert.throws( 143 | () => t.throws(/ed?/), 144 | 'Cannot use "test.throws" method without a test callback' 145 | ) 146 | }) 147 | }) 148 | 149 | test('assert test throws an instance of a given class', async () => { 150 | const emitter = new Emitter() 151 | const refiner = new Refiner() 152 | const t = createTest('', emitter, refiner, {}) 153 | t.run(() => { 154 | throw new Error('Failed') 155 | }).throws('Failed', Error) 156 | 157 | const [event] = await Promise.all([pEvent(emitter, 'test:end'), t.exec()]) 158 | await wrapAssertions(() => { 159 | assert.equal(event!.hasError, false) 160 | }) 161 | }) 162 | 163 | test('fail when test does not throw an exception', async () => { 164 | const emitter = new Emitter() 165 | const refiner = new Refiner() 166 | const t = createTest('', emitter, refiner, {}) 167 | t.run(() => {}).throws('Failed', Error) 168 | 169 | const [event] = await Promise.all([pEvent(emitter, 'test:end'), t.exec()]) 170 | await wrapAssertions(() => { 171 | assert.equal(event!.hasError, true) 172 | assert.equal(event!.errors[0].error.message, 'Expected test to throw an exception') 173 | }) 174 | }) 175 | 176 | test('fail when error constructor mismatch', async () => { 177 | class Exception {} 178 | const emitter = new Emitter() 179 | const refiner = new Refiner() 180 | const t = createTest('', emitter, refiner, {}) 181 | t.run(() => { 182 | throw new Error('Failed') 183 | }).throws('Failed', Exception) 184 | 185 | const [event] = await Promise.all([pEvent(emitter, 'test:end'), t.exec()]) 186 | await wrapAssertions(() => { 187 | assert.equal(event!.hasError, true) 188 | assert.equal(event!.errors[0].error.message, 'Expected test to throw "[class Exception]"') 189 | }) 190 | }) 191 | 192 | test('fail when error message mismatch', async () => { 193 | const emitter = new Emitter() 194 | const refiner = new Refiner() 195 | const t = createTest('', emitter, refiner, {}) 196 | t.run(() => { 197 | throw new Error('Failed') 198 | }).throws('Failure') 199 | 200 | const [event] = await Promise.all([pEvent(emitter, 'test:end'), t.exec()]) 201 | await wrapAssertions(() => { 202 | assert.equal(event!.hasError, true) 203 | assert.equal( 204 | event!.errors[0].error.message, 205 | 'Expected test to throw "Failure". Instead received "Failed"' 206 | ) 207 | }) 208 | }) 209 | 210 | test('fail when error does not match the regular expression', async () => { 211 | const emitter = new Emitter() 212 | const refiner = new Refiner() 213 | const t = createTest('', emitter, refiner, {}) 214 | t.run(() => { 215 | throw new Error('Failed') 216 | }).throws(/lure?/) 217 | 218 | const [event] = await Promise.all([pEvent(emitter, 'test:end'), t.exec()]) 219 | await wrapAssertions(() => { 220 | assert.equal(event!.hasError, true) 221 | assert.equal( 222 | event!.errors[0].error.message, 223 | 'Expected test error to match "/lure?/" regular expression' 224 | ) 225 | }) 226 | }) 227 | }) 228 | 229 | test.describe('Runner | global hooks', () => { 230 | test('do not run teardown hooks when setup hooks were not executed', async () => { 231 | const hooks = new GlobalHooks() 232 | const stack: string[] = [] 233 | 234 | hooks.apply( 235 | new ConfigManager( 236 | { 237 | files: [], 238 | setup: [ 239 | () => { 240 | stack.push('setup') 241 | }, 242 | ], 243 | teardown: [ 244 | () => { 245 | stack.push('teardown') 246 | }, 247 | ], 248 | }, 249 | {} 250 | ).hydrate() 251 | ) 252 | 253 | const emitter = new Emitter() 254 | await hooks.teardown(null, new Runner(emitter)) 255 | 256 | await wrapAssertions(() => { 257 | assert.deepEqual(stack, []) 258 | }) 259 | }) 260 | 261 | test('run teardown hooks when setup hooks were executed', async () => { 262 | const hooks = new GlobalHooks() 263 | const stack: string[] = [] 264 | 265 | hooks.apply( 266 | new ConfigManager( 267 | { 268 | files: [], 269 | setup: [ 270 | () => { 271 | stack.push('setup') 272 | }, 273 | ], 274 | teardown: [ 275 | () => { 276 | stack.push('teardown') 277 | }, 278 | ], 279 | }, 280 | {} 281 | ).hydrate() 282 | ) 283 | 284 | const emitter = new Emitter() 285 | await hooks.setup(new Runner(emitter)) 286 | await hooks.teardown(null, new Runner(emitter)) 287 | 288 | await wrapAssertions(() => { 289 | assert.deepEqual(stack, ['setup', 'teardown']) 290 | }) 291 | }) 292 | 293 | test('do not run teardown hooks in case of error', async () => { 294 | const hooks = new GlobalHooks() 295 | const stack: string[] = [] 296 | 297 | hooks.apply( 298 | new ConfigManager( 299 | { 300 | files: [], 301 | setup: [ 302 | () => { 303 | stack.push('setup') 304 | }, 305 | ], 306 | teardown: [ 307 | () => { 308 | stack.push('teardown') 309 | }, 310 | ], 311 | }, 312 | {} 313 | ).hydrate() 314 | ) 315 | 316 | const emitter = new Emitter() 317 | await hooks.setup(new Runner(emitter)) 318 | await hooks.teardown(new Error('foo'), new Runner(emitter)) 319 | 320 | await wrapAssertions(() => { 321 | assert.deepEqual(stack, ['setup']) 322 | }) 323 | }) 324 | 325 | test('run teardown cleanup methods when teardown hook raises error', async () => { 326 | const hooks = new GlobalHooks() 327 | const stack: string[] = [] 328 | 329 | hooks.apply( 330 | new ConfigManager( 331 | { 332 | files: [], 333 | setup: [ 334 | () => { 335 | stack.push('setup') 336 | }, 337 | ], 338 | teardown: [ 339 | () => { 340 | stack.push('teardown') 341 | return () => { 342 | stack.push('teardown cleanup') 343 | } 344 | }, 345 | () => { 346 | throw new Error('blowup') 347 | }, 348 | ], 349 | }, 350 | {} 351 | ).hydrate() 352 | ) 353 | 354 | const emitter = new Emitter() 355 | await hooks.setup(new Runner(emitter)) 356 | try { 357 | await hooks.teardown(null, new Runner(emitter)) 358 | } catch (error) { 359 | await hooks.teardown(error, new Runner(emitter)) 360 | } 361 | 362 | await wrapAssertions(() => { 363 | assert.deepEqual(stack, ['setup', 'teardown', 'teardown cleanup']) 364 | }) 365 | }) 366 | }) 367 | 368 | test.describe('Runner | retryPlugin', () => { 369 | test('store failing tests inside the cache dir', async () => { 370 | const stack: string[] = [] 371 | 372 | const emitter = new Emitter() 373 | const refiner = new Refiner() 374 | 375 | const suite = new Suite('same', emitter, refiner) 376 | createTest('failing test', emitter, refiner, { suite }).run(() => { 377 | stack.push('executing failing test') 378 | throw new Error('Failing') 379 | }) 380 | createTest('passing test', emitter, refiner, { suite }).run(() => { 381 | stack.push('executing passing test') 382 | }) 383 | 384 | await runner() 385 | .configure({ 386 | files: [], 387 | refiner, 388 | reporters: { 389 | activated: [], 390 | list: [], 391 | }, 392 | plugins: [retryPlugin], 393 | }) 394 | .useEmitter(emitter) 395 | .runSuites(() => [suite]) 396 | 397 | await wrapAssertions(async () => { 398 | assert.deepEqual(await getFailedTests(), { tests: ['failing test'] }) 399 | assert.deepEqual(stack, ['executing failing test', 'executing passing test']) 400 | }) 401 | 402 | await clearCache() 403 | }) 404 | 405 | test('run only failed tests when failed flag is used', async () => { 406 | const stack: string[] = [] 407 | 408 | const emitter = new Emitter() 409 | const refiner = new Refiner() 410 | 411 | function getSuite() { 412 | const suite = new Suite('same', emitter, refiner) 413 | createTest('failing test', emitter, refiner, { suite }).run(() => { 414 | stack.push('executing failing test') 415 | throw new Error('Failing') 416 | }) 417 | createTest('passing test', emitter, refiner, { suite }).run(() => { 418 | stack.push('executing passing test') 419 | }) 420 | 421 | return suite 422 | } 423 | 424 | function getExecutor(argv?: string[]) { 425 | return runner() 426 | .configure( 427 | { 428 | files: [], 429 | refiner, 430 | reporters: { 431 | activated: [], 432 | list: [], 433 | }, 434 | plugins: [retryPlugin], 435 | }, 436 | argv 437 | ) 438 | .useEmitter(emitter) 439 | } 440 | 441 | await getExecutor().runSuites(() => [getSuite()]) 442 | await wrapAssertions(async () => { 443 | assert.deepEqual(await getFailedTests(), { tests: ['failing test'] }) 444 | assert.deepEqual(stack, ['executing failing test', 'executing passing test']) 445 | }) 446 | 447 | await getExecutor(['--failed']).runSuites(() => [getSuite()]) 448 | await wrapAssertions(async () => { 449 | assert.deepEqual(await getFailedTests(), { tests: ['failing test'] }) 450 | assert.deepEqual(stack, [ 451 | 'executing failing test', 452 | 'executing passing test', 453 | 'executing failing test', 454 | ]) 455 | }) 456 | 457 | await clearCache() 458 | }) 459 | 460 | test('run all tests when failed flag is not used', async () => { 461 | const stack: string[] = [] 462 | 463 | const emitter = new Emitter() 464 | const refiner = new Refiner() 465 | 466 | function getSuite() { 467 | const suite = new Suite('same', emitter, refiner) 468 | createTest('failing test', emitter, refiner, { suite }).run(() => { 469 | stack.push('executing failing test') 470 | throw new Error('Failing') 471 | }) 472 | createTest('passing test', emitter, refiner, { suite }).run(() => { 473 | stack.push('executing passing test') 474 | }) 475 | 476 | return suite 477 | } 478 | 479 | function getExecutor(argv?: string[]) { 480 | return runner() 481 | .configure( 482 | { 483 | files: [], 484 | refiner, 485 | reporters: { 486 | activated: [], 487 | list: [], 488 | }, 489 | plugins: [retryPlugin], 490 | }, 491 | argv 492 | ) 493 | .useEmitter(emitter) 494 | } 495 | 496 | await getExecutor().runSuites(() => [getSuite()]) 497 | await wrapAssertions(async () => { 498 | assert.deepEqual(await getFailedTests(), { tests: ['failing test'] }) 499 | assert.deepEqual(stack, ['executing failing test', 'executing passing test']) 500 | }) 501 | 502 | await getExecutor([]).runSuites(() => [getSuite()]) 503 | await wrapAssertions(async () => { 504 | assert.deepEqual(await getFailedTests(), { tests: ['failing test'] }) 505 | assert.deepEqual(stack, [ 506 | 'executing failing test', 507 | 'executing passing test', 508 | 'executing failing test', 509 | 'executing passing test', 510 | ]) 511 | }) 512 | 513 | await clearCache() 514 | }) 515 | 516 | test('run all tests when there are no failing tests', async () => { 517 | const stack: string[] = [] 518 | 519 | const emitter = new Emitter() 520 | const refiner = new Refiner() 521 | 522 | function getSuite() { 523 | const suite = new Suite('same', emitter, refiner) 524 | createTest('passing test', emitter, refiner, { suite }).run(() => { 525 | stack.push('executing passing test') 526 | }) 527 | 528 | return suite 529 | } 530 | 531 | function getExecutor(argv?: string[]) { 532 | return runner() 533 | .configure( 534 | { 535 | files: [], 536 | refiner, 537 | reporters: { 538 | activated: [], 539 | list: [], 540 | }, 541 | plugins: [retryPlugin], 542 | }, 543 | argv 544 | ) 545 | .useEmitter(emitter) 546 | } 547 | 548 | await getExecutor().runSuites(() => [getSuite()]) 549 | await wrapAssertions(async () => { 550 | assert.deepEqual(await getFailedTests(), { tests: [] }) 551 | assert.deepEqual(stack, ['executing passing test']) 552 | }) 553 | 554 | await getExecutor(['--failed']).runSuites(() => [getSuite()]) 555 | await wrapAssertions(async () => { 556 | assert.deepEqual(await getFailedTests(), { tests: [] }) 557 | assert.deepEqual(stack, ['executing passing test', 'executing passing test']) 558 | }) 559 | 560 | await clearCache() 561 | }) 562 | }) 563 | 564 | test.describe('Runner | bail', () => { 565 | test('stop after a failing test', async () => { 566 | const stack: string[] = [] 567 | 568 | const emitter = new Emitter() 569 | const refiner = new Refiner() 570 | 571 | const suite = new Suite('same', emitter, refiner) 572 | createTest('failing test', emitter, refiner, { suite }).run(() => { 573 | stack.push('executing failing test') 574 | throw new Error('Failing') 575 | }) 576 | createTest('passing test', emitter, refiner, { suite }).run(() => { 577 | stack.push('executing passing test') 578 | }) 579 | 580 | await runner() 581 | .bail() 582 | .configure({ 583 | files: [], 584 | refiner, 585 | reporters: { 586 | activated: [], 587 | list: [], 588 | }, 589 | }) 590 | .useEmitter(emitter) 591 | .runSuites(() => [suite]) 592 | 593 | await wrapAssertions(async () => { 594 | assert.deepEqual(stack, ['executing failing test']) 595 | }) 596 | }) 597 | 598 | test('run all suites when bailLayer is set to suite', async () => { 599 | const stack: string[] = [] 600 | 601 | const emitter = new Emitter() 602 | const refiner = new Refiner() 603 | 604 | const unit = new Suite('unit', emitter, refiner) 605 | unit.bail() 606 | const functional = new Suite('functional', emitter, refiner) 607 | functional.bail() 608 | 609 | createTest('failing unit test', emitter, refiner, { suite: unit }).run(() => { 610 | stack.push('executing failing unit test') 611 | throw new Error('Failing') 612 | }) 613 | createTest('passing unit test', emitter, refiner, { suite: unit }).run(() => { 614 | stack.push('executing passing unit test') 615 | }) 616 | 617 | createTest('failing functional test', emitter, refiner, { suite: functional }).run(() => { 618 | stack.push('executing failing functional test') 619 | throw new Error('Failing') 620 | }) 621 | createTest('passing functional test', emitter, refiner, { suite: functional }).run(() => { 622 | stack.push('executing passing functional test') 623 | }) 624 | 625 | await runner() 626 | .configure({ 627 | files: [], 628 | refiner, 629 | reporters: { 630 | activated: [], 631 | list: [], 632 | }, 633 | }) 634 | .useEmitter(emitter) 635 | .runSuites(() => [unit, functional]) 636 | 637 | await wrapAssertions(async () => { 638 | assert.deepEqual(stack, ['executing failing unit test', 'executing failing functional test']) 639 | }) 640 | }) 641 | 642 | test('run all groups when bailLayer is set to group', async () => { 643 | const stack: string[] = [] 644 | 645 | const emitter = new Emitter() 646 | const refiner = new Refiner() 647 | 648 | const unit = new Suite('unit', emitter, refiner) 649 | 650 | const group1 = createTestGroup('group 1', emitter, refiner, { suite: unit }) 651 | group1.bail() 652 | 653 | const group2 = createTestGroup('group 2', emitter, refiner, { suite: unit }) 654 | group2.bail() 655 | 656 | createTest('failing group 1 test', emitter, refiner, { suite: unit, group: group1 }).run(() => { 657 | stack.push('executing failing group 1 test') 658 | throw new Error('Failing') 659 | }) 660 | createTest('passing group 1 test', emitter, refiner, { suite: unit, group: group1 }).run(() => { 661 | stack.push('executing passing group 1 test') 662 | }) 663 | 664 | createTest('failing group 2 test', emitter, refiner, { suite: unit, group: group2 }).run(() => { 665 | stack.push('executing failing group 2 test') 666 | throw new Error('Failing') 667 | }) 668 | createTest('passing group 1 test', emitter, refiner, { suite: unit, group: group2 }).run(() => { 669 | stack.push('executing passing group 2 test') 670 | }) 671 | 672 | await runner() 673 | .configure({ 674 | files: [], 675 | refiner, 676 | reporters: { 677 | activated: [], 678 | list: [], 679 | }, 680 | }) 681 | .useEmitter(emitter) 682 | .runSuites(() => [unit]) 683 | 684 | await wrapAssertions(async () => { 685 | assert.deepEqual(stack, ['executing failing group 1 test', 'executing failing group 2 test']) 686 | }) 687 | }) 688 | }) 689 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@adonisjs/tsconfig/tsconfig.package.json", 3 | "compilerOptions": { 4 | "rootDir": "./", 5 | "outDir": "./build" 6 | } 7 | } 8 | --------------------------------------------------------------------------------