├── .github └── workflows │ ├── codeql.yml │ └── nodejs.yml ├── .gitignore ├── History.md ├── LICENSE ├── README.md ├── _config.yml ├── lib ├── assert.js ├── constant.js ├── index.d.ts ├── logger.js ├── operation.js ├── runner.js ├── utils.js └── validator.js ├── package.json ├── test ├── assert.test.js ├── command.test.js ├── commonjs.test.cjs ├── example.test.js ├── file.test.js ├── fixtures │ ├── command │ │ ├── bin │ │ │ └── cli.js │ │ └── package.json │ ├── example │ │ ├── bin │ │ │ └── cli.js │ │ └── package.json │ ├── file.js │ ├── file │ │ ├── test.json │ │ └── test.md │ ├── logger.js │ ├── long-run.js │ ├── middleware.js │ ├── process.js │ ├── prompt.js │ ├── readline.js │ ├── server │ │ ├── bin │ │ │ └── cli.js │ │ ├── index.js │ │ └── package.json │ ├── version.js │ └── wait.js ├── logger.test.js ├── middleware.test.js ├── operation.test.js ├── plugin.test.js ├── process.test.js ├── prompt.test.js ├── runner.test.js ├── setup.js ├── stack.test.js ├── test-utils.js ├── utils.test.js └── wait.test.js └── vitest.config.js /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | schedule: 9 | - cron: "58 15 * * 4" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ javascript ] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v3 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v2 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v2 40 | with: 41 | category: "/language:${{ matrix.language }}" 42 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | workflow_dispatch: {} 8 | push: 9 | branches: 10 | - main 11 | - master 12 | pull_request: 13 | branches: 14 | - main 15 | - master 16 | schedule: 17 | - cron: '0 2 * * *' 18 | 19 | jobs: 20 | build: 21 | runs-on: ${{ matrix.os }} 22 | 23 | strategy: 24 | fail-fast: false 25 | matrix: 26 | node-version: [14, 16, 18] 27 | os: [ubuntu-latest, windows-latest, macos-latest] 28 | 29 | steps: 30 | - name: Checkout Git Source 31 | uses: actions/checkout@v2 32 | 33 | - name: Use Node.js ${{ matrix.node-version }} 34 | uses: actions/setup-node@v1 35 | with: 36 | node-version: ${{ matrix.node-version }} 37 | 38 | - name: Install Dependencies 39 | run: npm i 40 | 41 | - name: Continuous Integration 42 | run: npm run ci 43 | 44 | - name: Code Coverage 45 | uses: codecov/codecov-action@v1 46 | with: 47 | token: ${{ secrets.CODECOV_TOKEN }} 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | logs/ 2 | npm-debug.log 3 | node_modules/ 4 | coverage/ 5 | .idea/ 6 | run/ 7 | .DS_Store 8 | *.swp 9 | *-lock.json 10 | *-lock.yaml 11 | .vscode/history 12 | .tmp 13 | .vscode 14 | .tempCodeRunnerFile.js 15 | /snippet/ 16 | -------------------------------------------------------------------------------- /History.md: -------------------------------------------------------------------------------- 1 | 2 | 1.0.1 / 2022-08-30 3 | ================== 4 | 5 | **fixes** 6 | * [[`ea0e635`](http://github.com/node-modules/clet/commit/ea0e635759aee55a5b321296e2e50725267ad00d)] - fix: import with ext (#32) (TZ | 天猪 <>) 7 | 8 | **others** 9 | * [[`601e10f`](http://github.com/node-modules/clet/commit/601e10ff593e706c8f70913c520cd4240096d05d)] - test: fix expect at win (TZ <>) 10 | * [[`7888c38`](http://github.com/node-modules/clet/commit/7888c380eb9c32d1872c63ab7e5b8ad5eb77a280)] - chore: github action workflow_dispatch (TZ <>) 11 | * [[`735afdb`](http://github.com/node-modules/clet/commit/735afdb7eb04fda8f5351210b3d05fdbd8647019)] - test: fix @vitest/coverage-c8 (#30) (TZ | 天猪 <>) 12 | * [[`0df1824`](http://github.com/node-modules/clet/commit/0df1824689d7a445b8f9b0a5cf36a687357d7271)] - test: mv jest to vitest (#29) (TZ | 天猪 <>) 13 | 14 | 1.0.0 / 2022-06-06 15 | ================== 16 | 17 | **features** 18 | * [[`5ca767f`](http://github.com/node-modules/clet/commit/5ca767fa61cf089b99a202d955d97c02ac95f189)] - feat: add typings (#27) (TZ | 天猪 <>),fatal: No names found, cannot describe anything. 19 | 20 | **others** 21 | 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 node_modules 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![CLET - Command Line E2E Testing](https://socialify.git.ci/node-modules/clet/image?description=1&descriptionEditable=_______________%20Command%20Line%20E2E%20Testing%20_______________%20%20%20%20%20%20%20%20%20%20%20%20%20%20%F0%9F%92%AA%20Powerful%20%2B%20%F0%9F%9A%80%20Simplified%20%2B%20%F0%9F%8E%A2%20Modern%20&font=Source%20Code%20Pro&language=1&owner=1&theme=Dark) 2 | 3 | # CLET - Command Line E2E Testing 4 | 5 | [![NPM Version](https://img.shields.io/npm/v/clet.svg?style=flat-square)](https://npmjs.org/package/clet) 6 | [![NPM Download](https://img.shields.io/npm/dm/clet.svg?style=flat-square)](https://npmjs.org/package/clet) 7 | [![NPM Quality](http://npm.packagequality.com/shield/clet.svg?style=flat-square)](http://packagequality.com/#?package=clet) 8 | [![GitHub Actions CI](https://github.com/node-modules/clet/actions/workflows/nodejs.yml/badge.svg)](https://github.com/node-modules/clet/actions/workflows/nodejs.yml) 9 | [![Coverage](https://img.shields.io/codecov/c/github/node-modules/clet.svg?style=flat-square)](https://codecov.io/gh/node-modules/clet) 10 | 11 | 12 | **CLET aims to make end-to-end testing for command-line apps as simple as possible.** 13 | 14 | - Powerful, stop writing util functions yourself. 15 | - Simplified, every API is chainable. 16 | - Modern, ESM first, but not leaving commonjs behind. 17 | 18 | Inspired by [coffee](https://github.com/node-modules/coffee) and [nixt](https://github.com/vesln/nixt). 19 | 20 | 21 | ## How it looks 22 | 23 | ### Boilerplate && prompts 24 | 25 | ```js 26 | import { runner, KEYS } from 'clet'; 27 | 28 | it('should works with boilerplate', async () => { 29 | await runner() 30 | .cwd(tmpDir, { init: true }) 31 | .spawn('npm init') 32 | .stdin(/name:/, 'example') // wait for stdout, then respond 33 | .stdin(/version:/, new Array(9).fill(KEYS.ENTER)) 34 | .stdout(/"name": "example"/) // validate stdout 35 | .notStderr(/npm ERR/) 36 | .file('package.json', { name: 'example', version: '1.0.0' }) // validate file 37 | }); 38 | ``` 39 | 40 | ### Command line apps 41 | 42 | ```js 43 | import { runner } from 'clet'; 44 | 45 | it('should works with command-line apps', async () => { 46 | const baseDir = path.resolve('test/fixtures/example'); 47 | await runner() 48 | .cwd(baseDir) 49 | .fork('bin/cli.js', [ '--name=test' ], { execArgv: [ '--no-deprecation' ] }) 50 | .stdout('this is example bin') 51 | .stdout(`cwd=${baseDir}`) 52 | .stdout(/argv=\["--name=\w+"\]/) 53 | .stderr(/this is a warning/); 54 | }); 55 | ``` 56 | 57 | ### Build tools && Long-run servers 58 | 59 | ```js 60 | import { runner } from 'clet'; 61 | import request from 'supertest'; 62 | 63 | it('should works with long-run apps', async () => { 64 | await runner() 65 | .cwd('test/fixtures/server') 66 | .fork('bin/cli.js') 67 | .wait('stdout', /server started/) 68 | .expect(async () => { 69 | // using supertest 70 | return request('http://localhost:3000') 71 | .get('/') 72 | .query({ name: 'tz' }) 73 | .expect(200) 74 | .expect('hi, tz'); 75 | }) 76 | .kill(); // long-run server will not auto exit, so kill it manually after test 77 | }); 78 | ``` 79 | 80 | ### Work with CommonJS 81 | 82 | ```js 83 | describe('test/commonjs.test.cjs', () => { 84 | it('should support spawn', async () => { 85 | const { runner } = await import('clet'); 86 | await runner() 87 | .spawn('npm -v') 88 | .log('result.stdout') 89 | .stdout(/\d+\.\d+\.\d+/); 90 | }); 91 | }); 92 | ``` 93 | 94 | ## Installation 95 | 96 | ```bash 97 | $ npm i --save clet 98 | ``` 99 | 100 | ## Command 101 | 102 | ### fork(cmd, args, opts) 103 | 104 | Execute a Node.js script as a child process. 105 | 106 | ```js 107 | it('should fork', async () => { 108 | await runner() 109 | .cwd(fixtures) 110 | .fork('example.js', [ '--name=test' ], { execArgv: [ '--no-deprecation' ] }) 111 | .stdout('this is example bin') 112 | .stdout(/argv=\["--name=\w+"\]/) 113 | .stdout(/execArgv=\["--no-deprecation"\]/) 114 | .stderr(/this is a warning/); 115 | }); 116 | ``` 117 | 118 | Options: 119 | 120 | - `timeout`: {Number} - will kill after timeout. 121 | - `execArgv`: {Array} - pass to child process's execArgv, default to `process.execArgv`. 122 | - `cwd`: {String} - working directory, prefer to use `.cwd()` instead of this. 123 | - `env`: {Object} - prefer to use `.env()` instead of this. 124 | - `extendEnv`: {Boolean} - whether extend `process.env`, default to true. 125 | - more detail: https://github.com/sindresorhus/execa#options 126 | 127 | ### spawn(cmd, args, opts) 128 | 129 | Execute a shell script as a child process. 130 | 131 | ```js 132 | it('should support spawn', async () => { 133 | await runner() 134 | .spawn('node -v') 135 | .stdout(/v\d+\.\d+\.\d+/); 136 | }); 137 | ``` 138 | 139 | ### cwd(dir, opts) 140 | 141 | Change the current working directory. 142 | 143 | > Notice: it affects the relative path in `fork()`, `file()`, `mkdir()`, etc. 144 | 145 | ```js 146 | it('support cwd()', async () => { 147 | await runner() 148 | .cwd(targetDir) 149 | .fork(cliPath); 150 | }); 151 | ``` 152 | 153 | Support options: 154 | 155 | - `init`: delete and create the directory before tests. 156 | - `clean`: delete the directory after tests. 157 | 158 | > Use `trash` instead of `fs.rm` to prevent misoperation. 159 | 160 | ```js 161 | it('support cwd() with opts', async () => { 162 | await runner() 163 | .cwd(targetDir, { init: true, clean: true }) 164 | .fork(cliPath) 165 | .notFile('should-delete.md') 166 | .file('test.md', /# test/); 167 | }); 168 | ``` 169 | 170 | ### env(key, value) 171 | 172 | Set environment variables. 173 | 174 | > Notice: if you don't want to extend the environment variables, set `opts.extendEnv` to false. 175 | 176 | ```js 177 | it('support env', async () => { 178 | await runner() 179 | .env('DEBUG', 'CLI') 180 | .fork('./example.js', [], { extendEnv: false }); 181 | }); 182 | ``` 183 | 184 | ### timeout(ms) 185 | 186 | Set a timeout. Your application would receive `SIGTERM` and `SIGKILL` in sequent order. 187 | 188 | ```js 189 | it('support timeout', async () => { 190 | await runner() 191 | .timeout(5000) 192 | .fork('./example.js'); 193 | }); 194 | ``` 195 | 196 | ### wait(type, expected) 197 | 198 | Wait for your expectations to pass. It's useful for testing long-run apps such as build tools or http servers. 199 | 200 | - `type`: {String} - support `message` / `stdout` / `stderr` / `close` 201 | - `expected`: {String|RegExp|Object|Function} 202 | - {String}: check whether the specified string is included 203 | - {RegExp}: check whether it matches the specified regexp 204 | - {Object}: check whether it partially includes the specified JSON 205 | - {Function}: check whether it passes the specified function 206 | 207 | > Notice: don't forgot to `wait('end')` or `kill()` later. 208 | 209 | ```js 210 | it('should wait', async () => { 211 | await runner() 212 | .fork('./wait.js') 213 | .wait('stdout', /server started/) 214 | // .wait('message', { action: 'egg-ready' }) // ipc message 215 | .file('logs/web.log') 216 | .kill(); 217 | }); 218 | ``` 219 | 220 | ### kill() 221 | 222 | Kill the child process. It's useful for manually ending long-run apps after validation. 223 | 224 | > Notice: when kill, exit code may be undefined if the command doesn't hook on signal event. 225 | 226 | ```js 227 | it('should kill() manually after test server', async () => { 228 | await runner() 229 | .cwd(fixtures) 230 | .fork('server.js') 231 | .wait('stdout', /server started/) 232 | .kill(); 233 | }); 234 | ``` 235 | 236 | ### stdin(expected, respond) 237 | 238 | Responde to a prompt input. 239 | 240 | - `expected`: {String|RegExp} - test if `stdout` includes a string or matches regexp. 241 | - `respond`: {String|Array} - content to respond. CLET would write each with a delay if an array is set. 242 | 243 | You could use `KEYS.UP` / `KEYS.DOWN` to respond to a prompt that has multiple choices. 244 | 245 | ```js 246 | import { runner, KEYS } from 'clet'; 247 | 248 | it('should support stdin respond', async () => { 249 | await runner() 250 | .cwd(fixtures) 251 | .fork('./prompt.js') 252 | .stdin(/Name:/, 'tz') 253 | .stdin(/Email:/, 'tz@eggjs.com') 254 | .stdin(/Gender:/, [ KEYS.DOWN + KEYS.DOWN ]) 255 | .stdout(/Author: tz /) 256 | .stdout(/Gender: unknown/) 257 | .code(0); 258 | }); 259 | ``` 260 | 261 | > Tips: type ENTER repeatedly if needed 262 | 263 | ```js 264 | it('should works with boilerplate', async () => { 265 | await runner() 266 | .cwd(tmpDir, { init: true }) 267 | .spawn('npm init') 268 | .stdin(/name:/, 'example') 269 | .stdin(/version:/, new Array(9).fill(KEYS.ENTER)) // don't care about others, just enter 270 | .stdout(/"name": "example"/) 271 | .notStderr(/npm ERR/) 272 | .file('package.json', { name: 'example', version: '1.0.0' }) 273 | }); 274 | ``` 275 | 276 | --- 277 | 278 | ## Validator 279 | 280 | ### stdout(expected) 281 | 282 | Validate stdout, support `regexp` and `string.includes`. 283 | 284 | ```js 285 | it('should support stdout()', async () => { 286 | await runner() 287 | .spawn('node -v') 288 | .stdout(/v\d+\.\d+\.\d+/) // regexp match 289 | .stdout(process.version) // string includes; 290 | }); 291 | ``` 292 | 293 | ### notStdout(unexpected) 294 | 295 | The opposite of `stdout()`. 296 | 297 | ### stderr(expected) 298 | 299 | Validate stdout, support `regexp` and `string.includes`. 300 | 301 | ```js 302 | it('should support stderr()', async () => { 303 | await runner() 304 | .cwd(fixtures) 305 | .fork('example.js') 306 | .stderr(/a warning/) 307 | .stderr('this is a warning'); 308 | }); 309 | ``` 310 | 311 | ### notStderr(unexpected) 312 | 313 | The opposite of `stderr()`. 314 | 315 | ### code(n) 316 | 317 | Validate child process exit code. 318 | 319 | No need to explicitly check if the process exits successfully, use `code(n)` only if you want to check other exit codes. 320 | 321 | > Notice: when a process is killed, exit code may be undefined if you don't hook on signal events. 322 | 323 | ```js 324 | it('should support code()', async () => { 325 | await runner() 326 | .spawn('node --unknown-argv') 327 | .code(1); 328 | }); 329 | ``` 330 | 331 | ### file(filePath, expected) 332 | 333 | Validate the file. 334 | 335 | - `file(filePath)`: check whether the file exists 336 | - `file(filePath, 'some string')`: check whether the file content includes the specified string 337 | - `file(filePath, /some regexp/)`: checke whether the file content matches regexp 338 | - `file(filePath, {})`: check whether the file content partially includes the specified JSON 339 | 340 | ```js 341 | it('should support file()', async () => { 342 | await runner() 343 | .cwd(tmpDir, { init: true }) 344 | .spawn('npm init -y') 345 | .file('package.json') 346 | .file('package.json', /"name":/) 347 | .file('package.json', { name: 'example', config: { port: 8080 } }); 348 | }); 349 | ``` 350 | 351 | ### notFile(filePath, unexpected) 352 | 353 | The opposite of `file()`. 354 | 355 | > Notice: `.notFile('not-exist.md', 'abc')` will throw because the file is not existing. 356 | 357 | ### expect(fn) 358 | 359 | Validate with a custom function. 360 | 361 | ```js 362 | it('should support expect()', async () => { 363 | await runner() 364 | .spawn('node -v') 365 | .expect(ctx => { 366 | const { assert, result } = ctx; 367 | assert.match(result.stdout, /v\d+\.\d+\.\d+/); 368 | }); 369 | }); 370 | ``` 371 | 372 | --- 373 | 374 | ## Operation 375 | 376 | ### log(format, ...keys) 377 | 378 | Print log for debugging. `key` supports dot path such as `result.stdout`. 379 | 380 | ```js 381 | it('should support log()', async () => { 382 | await runner() 383 | .spawn('node -v') 384 | .log('result: %j', 'result') 385 | .log('result.stdout') 386 | .stdout(/v\d+\.\d+\.\d+/); 387 | }); 388 | ``` 389 | 390 | ### tap(fn) 391 | 392 | Tap a method to the chain sequence. 393 | 394 | ```js 395 | it('should support tap()', async () => { 396 | await runner() 397 | .spawn('node -v') 398 | .tap(async ({ result, assert}) => { 399 | assert(result.stdout, /v\d+\.\d+\.\d+/); 400 | }); 401 | }); 402 | ``` 403 | 404 | ### sleep(ms) 405 | 406 | ```js 407 | it('should support sleep()', async () => { 408 | await runner() 409 | .fork(cliPath) 410 | .sleep(2000) 411 | .log('result.stdout'); 412 | }); 413 | ``` 414 | 415 | ### shell(cmd, args, opts) 416 | 417 | Run a shell script. For example, run `npm install` after boilerplate init. 418 | 419 | ```js 420 | it('should support shell', async () => { 421 | await runner() 422 | .cwd(tmpDir, { init: true }) 423 | .spawn('npm init -y') 424 | .file('package.json', { name: 'shell', version: '1.0.0' }) 425 | .shell('npm version minor --no-git-tag-version', { reject: false }) 426 | .file('package.json', { version: '1.1.0' }); 427 | }); 428 | ``` 429 | 430 | The output log could validate by `stdout()` and `stderr()` by default, if you don't want this, just pass `{ collectLog: false }`. 431 | 432 | 433 | ### mkdir(path) 434 | 435 | Act like `mkdir -p`. 436 | 437 | ```js 438 | it('should support mkdir', async () => { 439 | await runner() 440 | .cwd(tmpDir, { init: true }) 441 | .mkdir('a/b') 442 | .file('a/b') 443 | .spawn('npm -v'); 444 | }); 445 | ``` 446 | 447 | ### rm(path) 448 | 449 | Move a file or a folder to trash (instead of permanently delete it). It doesn't throw if the file or the folder doesn't exist. 450 | 451 | ```js 452 | it('should support rm', async () => { 453 | await runner() 454 | .cwd(tmpDir, { init: true }) 455 | .mkdir('a/b') 456 | .rm('a/b') 457 | .notFile('a/b') 458 | .spawn('npm -v'); 459 | }); 460 | ``` 461 | 462 | ### writeFile(filePath, content) 463 | 464 | Write content to a file, support JSON and PlainText. 465 | 466 | ```js 467 | it('should support writeFile', async () => { 468 | await runner() 469 | .cwd(tmpDir, { init: true }) 470 | .writeFile('test.json', { name: 'writeFile' }) 471 | .writeFile('test.md', 'this is a test') 472 | .file('test.json', /"name": "writeFile"/) 473 | .file('test.md', /this is a test/) 474 | .spawn('npm -v'); 475 | }); 476 | ``` 477 | 478 | ## Context 479 | 480 | ```js 481 | /** 482 | * @typedef Context 483 | * 484 | * @property {Object} result - child process execute result 485 | * @property {String} result.stdout - child process stdout 486 | * @property {String} result.stderr - child process stderr 487 | * @property {Number} result.code - child process exit code 488 | * 489 | * @property {execa.ExecaChildProcess} proc - child process instance 490 | * @property {TestRunner} instance - runner instance 491 | * @property {String} cwd - child process current workspace directory 492 | * 493 | * @property {Object} assert - assert helper 494 | * @property {Object} utils - utils helper 495 | * @property {Object} logger - built-in logger 496 | */ 497 | ``` 498 | 499 | ### assert 500 | 501 | Extend Node.js built-in `assert` with some powerful assertions. 502 | 503 | ```js 504 | /** 505 | * assert `actual` matches `expected` 506 | * - when `expected` is regexp, assert by `RegExp.test` 507 | * - when `expected` is json, assert by `lodash.isMatch` 508 | * - when `expected` is string, assert by `String.includes` 509 | * 510 | * @param {String|Object} actual - actual string 511 | * @param {String|RegExp|Object} expected - rule to validate 512 | */ 513 | function matchRule(actual, expected) {} 514 | 515 | /** 516 | * assert `actual` does not match `expected` 517 | * - when `expected` is regexp, assert by `RegExp.test` 518 | * - when `expected` is json, assert by `lodash.isMatch` 519 | * - when `expected` is string, assert by `String.includes` 520 | * 521 | * @param {String|Object} actual - actual string 522 | * @param {String|RegExp|Object} expected - rule to validate 523 | */ 524 | function doesNotMatchRule(actual, expected) {} 525 | 526 | /** 527 | * validate file 528 | * 529 | * - `matchFile('/path/to/file')`: check whether the file exists 530 | * - `matchFile('/path/to/file', /\w+/)`: check whether the file content matches regexp 531 | * - `matchFile('/path/to/file', 'usage')`: check whether the file content includes the specified string 532 | * - `matchFile('/path/to/file', { version: '1.0.0' })`: checke whether the file content partially includes the specified JSON 533 | * 534 | * @param {String} filePath - target path to validate, could be relative path 535 | * @param {String|RegExp|Object} [expected] - rule to validate 536 | * @throws {AssertionError} 537 | */ 538 | async function matchFile(filePath, expected) {} 539 | 540 | /** 541 | * validate file with opposite rule 542 | * 543 | * - `doesNotMatchFile('/path/to/file')`: check whether the file exists 544 | * - `doesNotMatchFile('/path/to/file', /\w+/)`: check whether the file content does not match regex 545 | * - `doesNotMatchFile('/path/to/file', 'usage')`: check whether the file content does not include the specified string 546 | * - `doesNotMatchFile('/path/to/file', { version: '1.0.0' })`: checke whether the file content does not partially include the specified JSON 547 | * 548 | * @param {String} filePath - target path to validate, could be relative path 549 | * @param {String|RegExp|Object} [expected] - rule to validate 550 | * @throws {AssertionError} 551 | */ 552 | async function doesNotMatchFile(filePath, expected) {} 553 | ``` 554 | 555 | ### debug(level) 556 | 557 | Set level of logger. 558 | 559 | ```js 560 | import { runner, LogLevel } from 'clet'; 561 | 562 | it('should debug(level)', async () => { 563 | await runner() 564 | .debug(LogLevel.DEBUG) 565 | // .debug('DEBUG') 566 | .spawn('npm -v'); 567 | }); 568 | ``` 569 | 570 | --- 571 | 572 | ## Extendable 573 | 574 | ### use(fn) 575 | 576 | Middleware, always run before child process chains. 577 | 578 | ```js 579 | // middleware.pre -> before -> fork -> running -> after -> end -> middleware.post -> cleanup 580 | 581 | it('should support middleware', async () => { 582 | await runner() 583 | .use(async (ctx, next) => { 584 | // pre 585 | await utils.rm(dir); 586 | await utils.mkdir(dir); 587 | 588 | await next(); 589 | 590 | // post 591 | await utils.rm(dir); 592 | }) 593 | .spawn('npm -v'); 594 | }); 595 | ``` 596 | 597 | ### register(Function|Object) 598 | 599 | Register your custom APIs. 600 | 601 | ```js 602 | it('should register(fn)', async () => { 603 | await runner() 604 | .register(({ ctx }) => { 605 | ctx.cache = {}; 606 | cache = function(key, value) { 607 | this.ctx.cache[key] = value; 608 | return this; 609 | }; 610 | }) 611 | .cache('a', 'b') 612 | .tap(ctx => { 613 | console.log(ctx.cache); 614 | }) 615 | .spawn('node', [ '-v' ]); 616 | }); 617 | ``` 618 | 619 | ## Known Issues 620 | 621 | **Help Wanted** 622 | 623 | - when answer prompt with `inquirer` or `enquirer`, stdout will recieve duplicate output. 624 | - when print child error log with `.error()`, the log order maybe in disorder. 625 | 626 | ## License 627 | 628 | MIT 629 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-leap-day -------------------------------------------------------------------------------- /lib/assert.js: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import isMatch from 'lodash.ismatch'; 3 | import { strict as assert } from 'assert'; 4 | import { types, exists } from './utils.js'; 5 | 6 | assert.matchRule = matchRule; 7 | assert.doesNotMatchRule = doesNotMatchRule; 8 | assert.matchFile = matchFile; 9 | assert.doesNotMatchFile = doesNotMatchFile; 10 | 11 | export { assert }; 12 | 13 | /** 14 | * assert the `actual` is match `expected` 15 | * - when `expected` is regexp, detect by `RegExp.test` 16 | * - when `expected` is json, detect by `lodash.ismatch` 17 | * - when `expected` is string, detect by `String.includes` 18 | * 19 | * @param {String|Object} actual - actual string 20 | * @param {String|RegExp|Object} expected - rule to validate 21 | */ 22 | export function matchRule(actual, expected) { 23 | if (types.isRegExp(expected)) { 24 | assert.match(actual.toString(), expected); 25 | } else if (types.isObject(expected)) { 26 | // if pattern is `json`, then convert actual to json and check whether contains pattern 27 | const content = types.isString(actual) ? JSON.parse(actual) : actual; 28 | const result = isMatch(content, expected); 29 | if (!result) { 30 | // print diff 31 | throw new assert.AssertionError({ 32 | operator: 'should partial includes', 33 | actual: content, 34 | expected, 35 | stackStartFn: matchRule, 36 | }); 37 | } 38 | } else if (actual === undefined || !actual.includes(expected)) { 39 | throw new assert.AssertionError({ 40 | operator: 'should includes', 41 | actual, 42 | expected, 43 | stackStartFn: matchRule, 44 | }); 45 | } 46 | } 47 | 48 | /** 49 | * assert the `actual` is not match `expected` 50 | * - when `expected` is regexp, detect by `RegExp.test` 51 | * - when `expected` is json, detect by `lodash.ismatch` 52 | * - when `expected` is string, detect by `String.includes` 53 | * 54 | * @param {String|Object} actual - actual string 55 | * @param {String|RegExp|Object} expected - rule to validate 56 | */ 57 | export function doesNotMatchRule(actual, expected) { 58 | if (types.isRegExp(expected)) { 59 | assert.doesNotMatch(actual.toString(), expected); 60 | } else if (types.isObject(expected)) { 61 | // if pattern is `json`, then convert actual to json and check whether contains pattern 62 | const content = types.isString(actual) ? JSON.parse(actual) : actual; 63 | const result = isMatch(content, expected); 64 | if (result) { 65 | // print diff 66 | throw new assert.AssertionError({ 67 | operator: 'should not partial includes', 68 | actual: content, 69 | expected, 70 | stackStartFn: doesNotMatchRule, 71 | }); 72 | } 73 | } else if (actual === undefined || actual.includes(expected)) { 74 | throw new assert.AssertionError({ 75 | operator: 'should not includes', 76 | actual, 77 | expected, 78 | stackStartFn: doesNotMatchRule, 79 | }); 80 | } 81 | } 82 | 83 | /** 84 | * validate file 85 | * 86 | * - `matchFile('/path/to/file')`: check whether file exists 87 | * - `matchFile('/path/to/file', /\w+/)`: check whether file match regexp 88 | * - `matchFile('/path/to/file', 'usage')`: check whether file includes specified string 89 | * - `matchFile('/path/to/file', { version: '1.0.0' })`: checke whether file content partial includes specified JSON 90 | * 91 | * @param {String} filePath - target path to validate, could be relative path 92 | * @param {String|RegExp|Object} [expected] - rule to validate 93 | * @throws {AssertionError} 94 | */ 95 | export async function matchFile(filePath, expected) { 96 | // check whether file exists 97 | const isExists = await exists(filePath); 98 | assert(isExists, `Expected ${filePath} to be exists`); 99 | 100 | // compare content, support string/json/regex 101 | if (expected) { 102 | const content = await fs.readFile(filePath, 'utf-8'); 103 | try { 104 | assert.matchRule(content, expected); 105 | } catch (err) { 106 | err.message = `file(${filePath}) with content: ${err.message}`; 107 | throw err; 108 | } 109 | } 110 | } 111 | 112 | /** 113 | * validate file with opposite rule 114 | * 115 | * - `doesNotMatchFile('/path/to/file')`: check whether file don't exists 116 | * - `doesNotMatchFile('/path/to/file', /\w+/)`: check whether file don't match regex 117 | * - `doesNotMatchFile('/path/to/file', 'usage')`: check whether file don't includes specified string 118 | * - `doesNotMatchFile('/path/to/file', { version: '1.0.0' })`: checke whether file content don't partial includes specified JSON 119 | * 120 | * @param {String} filePath - target path to validate, could be relative path 121 | * @param {String|RegExp|Object} [expected] - rule to validate 122 | * @throws {AssertionError} 123 | */ 124 | export async function doesNotMatchFile(filePath, expected) { 125 | // check whether file exists 126 | const isExists = await exists(filePath); 127 | if (!expected) { 128 | assert(!isExists, `Expected ${filePath} to not be exists`); 129 | } else { 130 | assert(isExists, `Expected file(${filePath}) not to match \`${expected}\` but file not exists`); 131 | const content = await fs.readFile(filePath, 'utf-8'); 132 | try { 133 | assert.doesNotMatchRule(content, expected); 134 | } catch (err) { 135 | err.message = `file(${filePath}) with content: ${err.message}`; 136 | throw err; 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /lib/constant.js: -------------------------------------------------------------------------------- 1 | import { EOL } from 'os'; 2 | 3 | export const KEYS = { 4 | UP: '\u001b[A', 5 | DOWN: '\u001b[B', 6 | LEFT: '\u001b[D', 7 | RIGHT: '\u001b[C', 8 | ENTER: EOL, 9 | SPACE: ' ', 10 | }; 11 | -------------------------------------------------------------------------------- /lib/index.d.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import * as execa from 'execa'; 3 | import { strict as assert } from 'assert'; 4 | import { types } from 'util'; 5 | import { mkdir } from 'fs/promises'; 6 | import { isMatch as isMatchFn } from 'lodash.ismatch'; 7 | import { Options as TrashOptions } from 'trash'; 8 | 9 | export type KEYS = { 10 | UP: '\u001b[A', 11 | DOWN: '\u001b[B', 12 | LEFT: '\u001b[D', 13 | RIGHT: '\u001b[C', 14 | ENTER: string, 15 | SPACE: ' ', 16 | } 17 | 18 | export type FunctionPlugin = (runner: TestRunner) => void; 19 | export type ObjectPlugin = Record; 20 | export type Plugin = FunctionPlugin | ObjectPlugin; 21 | 22 | // Logger 23 | export enum LogLevel { 24 | ERROR = 0, 25 | WARN = 1, 26 | LOG = 2, 27 | INFO = 3, 28 | DEBUG = 4, 29 | TRACE = 5, 30 | Silent = -Infinity, 31 | Verbose = Infinity, 32 | } 33 | 34 | interface LoggerOptions { 35 | level?: LogLevel; 36 | indent?: number; 37 | showTag?: boolean; 38 | showTime?: boolean; 39 | tag?: string | string[]; 40 | } 41 | 42 | export class Logger { 43 | constructor(tag: string, options?: LoggerOptions); 44 | constructor(options?: LoggerOptions); 45 | 46 | format(message: string, args?: any[], options?: LoggerOptions): string; 47 | 48 | level: LogLevel; 49 | 50 | child(tag: string, options?: LoggerOptions): Logger; 51 | } 52 | 53 | // Assertion 54 | type AssertType = typeof assert; 55 | type ExpectedAssertion = string | RegExp | object; 56 | 57 | export interface CletAssert extends AssertType { 58 | matchRule(actual: string | object, expected: ExpectedAssertion): void; 59 | 60 | doseNotMatchRule(actual: string | object, expected: ExpectedAssertion): never; 61 | 62 | matchFile(filePath: string, expected: ExpectedAssertion): void; 63 | 64 | doesNotMatchFile(filePath: string, expected: ExpectedAssertion): void; 65 | } 66 | 67 | // Utils 68 | export type WaitAssert = ExpectedAssertion | ExpectedAssertion[] | ((input: string | object) => void); 69 | 70 | export interface CletUtils { 71 | types: typeof types; 72 | isMatch: isMatchFn; 73 | 74 | isString(input: any): boolean; 75 | isObject(input: any): boolean; 76 | isFunction(input: any): boolean; 77 | 78 | validate(input: string | object, expected: WaitAssert): boolean; 79 | 80 | isParent(parent: string, child: string): boolean; 81 | 82 | mkdir: typeof mkdir; 83 | 84 | rm(p: string | string[], opts: { trash?: boolean }): Promise; 85 | 86 | writeFile(filePath: string, content: string | object, opts?: { 87 | encoding?: string; 88 | mode?: number; 89 | flag?: string; 90 | signal?: string; 91 | }): Promise; 92 | 93 | exist(filePath: string): Promise; 94 | 95 | resolve(meta: string | object, ...args: string[]): string; 96 | sleep(ms: number): Promise; 97 | } 98 | 99 | // Runner 100 | export interface TestRunnerContext { 101 | instance: TestRunner; 102 | logger: Logger; 103 | assert: CletAssert; 104 | utils: CletUtils; 105 | cmd?: string; 106 | cmdType?: 'fork' | 'spawn'; 107 | cmdArgs: string[]; 108 | cmdOpts: { 109 | reject: boolean; 110 | cwd: string; 111 | env: Record; 112 | preferLocal: boolean; 113 | }, 114 | cwd: string; 115 | env: Record; 116 | proc: execa.ExecaChildProcess; 117 | result: { 118 | stdout: string; 119 | stderr: string; 120 | code?: number; 121 | stopped?: boolean; 122 | }, 123 | } 124 | 125 | export enum WaitType { 126 | message = 'message', 127 | stdout = 'stdout', 128 | stderr = 'stderr', 129 | close = 'close', 130 | } 131 | 132 | export type TestRunnerMiddleware = (ctx: TestRunnerContext, next: any) => Promise; 133 | 134 | export interface TestRunnerOptions { 135 | autoWait: boolean; 136 | plugins: Plugin | Array; 137 | } 138 | 139 | export class TestRunner extends EventEmitter { 140 | constructor(options: TestRunnerOptions); 141 | 142 | options: TestRunnerOptions; 143 | middlewares: TestRunnerMiddleware[]; 144 | proc?: execa.ExecaChildProcess; 145 | ctx: TestRunnerContext; 146 | 147 | debug(leve: LogLevel): this; 148 | 149 | register(plugins: Plugin | Array): this; 150 | 151 | use(fn: TestRunnerMiddleware): this; 152 | 153 | end(): Promise; 154 | 155 | fork(cmd: string, args?: string[], opts: execa.NodeOptions): this; 156 | 157 | spawn(cmd: string, args?: string[], opts: execa.NodeOptions): this; 158 | 159 | wait(type: WaitType, expect: WaitAssert): this; 160 | 161 | stdin(expected: string | RegExp, respond: string | string[]): this; 162 | 163 | cwd(dir: string, options?: { 164 | clean?: boolean; 165 | init?: boolean; 166 | }): this; 167 | 168 | env(key: string, value: string): this; 169 | 170 | timeout(ms: number): this; 171 | 172 | kill(): this; 173 | 174 | // Operation 175 | tap(fn: (ctx: TestRunnerContext) => void): this; 176 | 177 | log(format: string, ...args?: string[]): this; 178 | 179 | sleep(ms: number): this; 180 | 181 | mkdir(dir: string): this; 182 | 183 | rm(dir: string): this; 184 | 185 | writeFile(filePath: string, content: string | object): this; 186 | 187 | shell(cmd: string, args?: string[], opts: execa.NodeOptions): this; 188 | 189 | // Validator 190 | expect(fn: (ctx: TestRunnerContext) => void): this; 191 | 192 | file(filePath: string, expected: ExpectedAssertion): this; 193 | 194 | noFile(filePath: string, unexpected: ExpectedAssertion): this; 195 | 196 | stdout(expected: ExpectedAssertion): this; 197 | 198 | notStdout(unexpected: ExpectedAssertion): this; 199 | 200 | stderr(expected: ExpectedAssertion): this; 201 | 202 | notStderr(unexpected: ExpectedAssertion): this; 203 | 204 | code(n: number): this; 205 | } 206 | 207 | export function runner(options?: TestRunnerOptions): TestRunner; 208 | -------------------------------------------------------------------------------- /lib/logger.js: -------------------------------------------------------------------------------- 1 | import { strict as assert } from 'assert'; 2 | import util from 'util'; 3 | 4 | export const LogLevel = { 5 | ERROR: 0, 6 | WARN: 1, 7 | LOG: 2, 8 | INFO: 3, 9 | DEBUG: 4, 10 | TRACE: 5, 11 | Silent: -Infinity, 12 | Verbose: Infinity, 13 | }; 14 | 15 | export class Logger { 16 | constructor(tag = '', opts = {}) { 17 | if (typeof tag === 'string') { 18 | opts.tag = opts.tag || tag || ''; 19 | } else { 20 | opts = tag; 21 | } 22 | opts.tag = [].concat(opts.tag || []); 23 | 24 | this.options = { 25 | level: LogLevel.INFO, 26 | indent: 0, 27 | showTag: true, 28 | showTime: false, 29 | ...opts, 30 | }; 31 | 32 | this.childMaps = {}; 33 | 34 | // register methods 35 | for (const [ key, value ] of Object.entries(LogLevel)) { 36 | const fnName = key.toLowerCase(); 37 | const fn = console[fnName] || console.debug; 38 | this[fnName] = (message, ...args) => { 39 | if (value > this.options.level) return; 40 | const msg = this.format(message, args, this.options); 41 | return fn(msg); 42 | }; 43 | } 44 | } 45 | 46 | format(message, args, options) { 47 | const time = options.showTime ? `[${formatTime(new Date())}] ` : ''; 48 | const tag = options.showTag && options.tag.length ? `[${options.tag.join(':')}] ` : ''; 49 | const indent = ' '.repeat(options.indent); 50 | const prefix = time + indent + tag; 51 | const content = util.format(message, ...args).replace(/^/gm, prefix); 52 | return content; 53 | } 54 | 55 | get level() { 56 | return this.options.level; 57 | } 58 | 59 | set level(v) { 60 | this.options.level = normalize(v); 61 | } 62 | 63 | child(tag, opts) { 64 | assert(tag, 'tag is required'); 65 | if (!this.childMaps[tag]) { 66 | this.childMaps[tag] = new Logger({ 67 | ...this.options, 68 | indent: this.options.indent + 2, 69 | ...opts, 70 | tag: [ ...this.options.tag, tag ], 71 | }); 72 | } 73 | return this.childMaps[tag]; 74 | } 75 | } 76 | 77 | function normalize(level) { 78 | if (typeof level === 'number') return level; 79 | const levelNum = LogLevel[level.toUpperCase()]; 80 | assert(levelNum, `unknown loglevel ${level}`); 81 | return levelNum; 82 | } 83 | 84 | function formatTime(date) { 85 | date = new Date(date.getTime() - (date.getTimezoneOffset() * 60000)); 86 | return date.toISOString() 87 | .replace('T', ' ') 88 | .replace(/\..+$/, ''); 89 | } 90 | 91 | -------------------------------------------------------------------------------- /lib/operation.js: -------------------------------------------------------------------------------- 1 | import * as execa from 'execa'; 2 | import * as dotProp from 'dot-prop'; 3 | import path from 'path'; 4 | import stripFinalNewline from 'strip-final-newline'; 5 | import stripAnsi from 'strip-ansi'; 6 | import * as utils from './utils.js'; 7 | import { assert } from './assert.js'; 8 | 9 | /** 10 | * tap a method to chain sequence. 11 | * 12 | * @param {Function} fn - function 13 | * @return {TestRunner} instance for chain 14 | */ 15 | export function tap(fn) { 16 | return this._addChain(fn); 17 | } 18 | 19 | /** 20 | * print log for debugging, support formattor and dot path 21 | * 22 | * @param {String} format - format 23 | * @param {...string} [keys] - contens 24 | * @return {TestRunner} instance for chain 25 | */ 26 | export function log(format, ...keys) { 27 | this._addChain(function log(ctx) { 28 | if (keys.length === 0) { 29 | this.logger.info(dotProp.getProperty(ctx, format) || format); 30 | } else { 31 | this.logger.info(format, ...keys.map(k => dotProp.getProperty(ctx, k))); 32 | } 33 | }); 34 | return this; 35 | } 36 | 37 | /** 38 | * take a sleep 39 | * 40 | * @param {Number} ms - millisecond 41 | * @return {TestRunner} instance for chain 42 | */ 43 | export function sleep(ms) { 44 | assert(ms, '`ms` is required'); 45 | return this.tap(function sleep() { 46 | return utils.sleep(ms); 47 | }); 48 | } 49 | 50 | /** 51 | * mkdir -p 52 | * 53 | * @param {String} dir - dir path, support relative path to `cwd` 54 | * @return {TestRunner} instance for chain 55 | */ 56 | export function mkdir(dir) { 57 | assert(dir, '`dir` is required'); 58 | return this.tap(async function mkdir(ctx) { 59 | await utils.mkdir(path.resolve(ctx.cmdOpts.cwd, dir)); 60 | }); 61 | } 62 | 63 | /** 64 | * move dir to trash 65 | * 66 | * @param {String} dir - dir path, support relative path to `cwd` 67 | * @return {TestRunner} instance for chain 68 | */ 69 | export function rm(dir) { 70 | assert(dir, '`dir is required'); 71 | return this.tap(async function rm(ctx) { 72 | await utils.rm(path.resolve(ctx.cmdOpts.cwd, dir)); 73 | }); 74 | } 75 | 76 | /** 77 | * write file, will auto create parent dir 78 | * 79 | * @param {String} filePath - file path, support relative path to `cwd` 80 | * @param {String|Object} content - content to write, if pass object, will `JSON.stringify` 81 | * @return {TestRunner} instance for chain 82 | */ 83 | export function writeFile(filePath, content) { 84 | assert(filePath, '`filePath` is required'); 85 | return this.tap(async function writeFile(ctx) { 86 | filePath = path.resolve(ctx.cmdOpts.cwd, filePath); 87 | return await utils.writeFile(filePath, content); 88 | }); 89 | } 90 | 91 | /** 92 | * run a shell 93 | * 94 | * @param {String} cmd - cmd string 95 | * @param {Array} [args] - cmd args 96 | * @param {execa.NodeOptions | { collectLog: boolean; }} [opts] - cmd options 97 | * @return {TestRunner} instance for chain 98 | */ 99 | export function shell(cmd, args = [], opts = {}) { 100 | assert(cmd, '`cmd` is required'); 101 | 102 | // exec(cmd, opts) 103 | if (args && !Array.isArray(args)) { 104 | opts = args; 105 | args = []; 106 | } 107 | 108 | return this.tap(async function shell(ctx) { 109 | const command = [ cmd, ...args ].join(' '); 110 | opts.cwd = opts.cwd || ctx.cmdOpts.cwd; 111 | 112 | const proc = execa.execaCommand(command, opts); 113 | const logger = ctx.logger.child('Shell', { showTag: false }); 114 | 115 | proc.stdout.on('data', data => { 116 | const origin = stripFinalNewline(data.toString()); 117 | if (opts.collectLog !== false) { 118 | const content = stripAnsi(origin); 119 | ctx.result.stdout += content; 120 | } 121 | logger.info(origin); 122 | }); 123 | 124 | proc.stderr.on('data', data => { 125 | const origin = stripFinalNewline(data.toString()); 126 | if (opts.collectLog !== false) { 127 | const content = stripAnsi(origin); 128 | ctx.result.stderr += content; 129 | } 130 | logger.info(origin); 131 | }); 132 | 133 | await proc; 134 | }); 135 | } 136 | -------------------------------------------------------------------------------- /lib/runner.js: -------------------------------------------------------------------------------- 1 | import { EOL } from 'os'; 2 | import EventEmitter from 'events'; 3 | import * as execa from 'execa'; 4 | import stripAnsi from 'strip-ansi'; 5 | import stripFinalNewline from 'strip-final-newline'; 6 | import { pEvent } from 'p-event'; 7 | import { compose } from 'throwback'; 8 | 9 | import * as utils from './utils.js'; 10 | import { assert } from './assert.js'; 11 | import { Logger, LogLevel } from './logger.js'; 12 | import * as validatorPlugin from './validator.js'; 13 | import * as operationPlugin from './operation.js'; 14 | 15 | class TestRunner extends EventEmitter { 16 | constructor(opts) { 17 | super(); 18 | this.options = { 19 | autoWait: true, 20 | ...opts, 21 | }; 22 | 23 | this.assert = assert; 24 | this.utils = utils; 25 | this.logger = new Logger({ tag: 'CLET' }); 26 | this.childLogger = this.logger.child('PROC', { indent: 4, showTag: false }); 27 | 28 | // middleware.pre -> before -> fork -> running -> after -> end -> middleware.post -> cleanup 29 | this.middlewares = []; 30 | this._chains = { 31 | before: [], 32 | running: [], 33 | after: [], 34 | end: [], 35 | }; 36 | this._expectedExitCode = undefined; 37 | 38 | this.proc = undefined; 39 | 40 | /** @type {Context} */ 41 | this.ctx = this._initContext(); 42 | 43 | this.register(validatorPlugin); 44 | this.register(operationPlugin); 45 | this.register(this.options.plugins); 46 | } 47 | 48 | /** 49 | * @typedef Context 50 | * 51 | * @property {Object} result - child process execute result 52 | * @property {String} result.stdout - child process stdout 53 | * @property {String} result.stderr - child process stderr 54 | * @property {Number} result.code - child process exit code 55 | * 56 | * @property {execa.ExecaChildProcess} proc - child process instance 57 | * @property {TestRunner} instance - runner instance 58 | * @property {String} cwd - child process current workspace directory 59 | * 60 | * @property {Object} assert - assert helper 61 | * @property {Object} utils - utils helper 62 | * @property {Object} logger - built-in logger 63 | */ 64 | 65 | /** 66 | * init context 67 | * 68 | * @return {Context} context object 69 | * @private 70 | */ 71 | _initContext() { 72 | const ctx = { 73 | instance: this, 74 | logger: this.logger, 75 | assert: this.assert, 76 | utils: this.utils, 77 | 78 | // commander 79 | cmd: undefined, 80 | cmdType: undefined, 81 | cmdArgs: undefined, 82 | cmdOpts: { 83 | reject: false, 84 | cwd: process.cwd(), 85 | env: {}, 86 | preferLocal: true, 87 | // left these to use default 88 | // timeout: 0, 89 | // execArgv: process.execArgv, 90 | // extendEnv: true, 91 | }, 92 | 93 | get cwd() { return ctx.cmdOpts.cwd; }, 94 | get env() { return ctx.cmdOpts.env; }, 95 | get proc() { return ctx.instance.proc; }, 96 | 97 | result: { 98 | stdout: '', 99 | stderr: '', 100 | code: undefined, 101 | stopped: undefined, 102 | }, 103 | }; 104 | return ctx; 105 | } 106 | 107 | debug(level = 'DEBUG') { 108 | this.logger.level = level; 109 | this.childLogger.level = level; 110 | return this; 111 | } 112 | 113 | // TODO: refactor plugin system, add init lifecyle 114 | register(plugins) { 115 | if (!plugins) return this; 116 | if (!Array.isArray(plugins)) plugins = [ plugins ]; 117 | for (const fn of plugins) { 118 | if (typeof fn === 'function') { 119 | fn(this); 120 | } else { 121 | for (const key of Object.keys(fn)) { 122 | this[key] = fn[key]; 123 | } 124 | } 125 | } 126 | return this; 127 | } 128 | 129 | use(fn) { 130 | this.middlewares.push(fn); 131 | return this; 132 | } 133 | 134 | /** @typedef {'before'|'running'|'after'|'end'} ChainType */ 135 | 136 | /** 137 | * add a function to chain 138 | * 139 | * @param {Function} fn - chain function 140 | * @param {ChainType} [type] - which chain to add 141 | * @return {TestRunner} instance for chain 142 | * @protected 143 | */ 144 | _addChain(fn, type) { 145 | if (!type) type = this.ctx.cmd ? 'after' : 'before'; 146 | const chains = this._chains[type]; 147 | assert(chains, `unexpected chain type ${type}`); 148 | chains.push(fn); 149 | return this; 150 | } 151 | 152 | /** 153 | * run a chain 154 | * 155 | * @param {ChainType} type - which chain to run 156 | * @private 157 | */ 158 | async _runChain(type) { 159 | const chains = this._chains[type]; 160 | assert(chains, `unexpected chain type ${type}`); 161 | for (const fn of chains) { 162 | this.logger.debug('run %s chain fn:', type, fn.name); 163 | await fn.call(this, this.ctx); 164 | } 165 | } 166 | 167 | // Perform the test, optional, when you await and `end()` could be omit. 168 | end() { 169 | // clean up 170 | this.middlewares.unshift(async function cleanup(ctx, next) { 171 | try { 172 | await next(); 173 | ctx.logger.info('✔ Test pass.\n'); 174 | // ensure it will return context 175 | return ctx; 176 | } catch (err) { 177 | ctx.logger.error('⚠ Test failed.\n'); 178 | throw err; 179 | } finally { 180 | // kill proc if still alive, when assert fail with long-run server, it will reach here 181 | if (ctx.proc && !ctx.result.stopped) { 182 | ctx.logger.warn(`still alive, kill ${ctx.proc.pid}`); 183 | ctx.proc.kill(); 184 | await ctx.proc; 185 | } 186 | } 187 | }); 188 | 189 | // run chains 190 | const fn = compose(this.middlewares); 191 | return fn(this.ctx, async () => { 192 | // before running 193 | await this._runChain('before'); 194 | 195 | // run command 196 | await this._execCommand(); 197 | 198 | // running 199 | await this._runChain('running'); 200 | 201 | // auto wait proc to exit, then assert, use for not-long-run command 202 | // othersize, need to call `.wait()` manually 203 | if (this.options.autoWait) { 204 | await this.proc; 205 | } 206 | 207 | // after running 208 | await this._runChain('after'); 209 | 210 | // ensure proc is exit if user forgot to call `wait('end')` after wait other event 211 | await this.proc; 212 | 213 | // after exit 214 | await this._runChain('end'); 215 | 216 | // if developer don't call `.code()`, will rethrow proc error in order to avoid omissions 217 | if (this._expectedExitCode === undefined) { 218 | // `killed` is true only if call `kill()/cancel()` manually 219 | const { failed, isCanceled, killed } = this.ctx.res; 220 | if (failed && !isCanceled && !killed) throw this.ctx.res; 221 | } 222 | 223 | return this.ctx; 224 | }); 225 | } 226 | 227 | // suger method for `await runner().end()` -> `await runner()` 228 | then(resolve, reject) { 229 | return this.end().then(resolve, reject); 230 | } 231 | 232 | catch(fn) { 233 | return this.then(undefined, fn); 234 | } 235 | 236 | /** 237 | * execute a Node.js script as a child process. 238 | * 239 | * @param {String} cmd - cmd string 240 | * @param {Array} [args] - cmd args 241 | * @param {execa.NodeOptions} [opts] - cmd options 242 | * @see https://github.com/sindresorhus/execa#options 243 | * @return {TestRunner} instance for chain 244 | */ 245 | fork(cmd, args, opts) { 246 | return this._registerCommand('fork', cmd, args, opts); 247 | } 248 | 249 | /** 250 | * execute a shell script as a child process. 251 | * 252 | * @param {String} cmd - cmd string 253 | * @param {Array} [args] - cmd args 254 | * @param {execa.NodeOptions} [opts] - cmd options 255 | * @return {TestRunner} runner instance 256 | */ 257 | spawn(cmd, args, opts) { 258 | assert(cmd, 'cmd is required'); 259 | return this._registerCommand('spawn', cmd, args, opts); 260 | } 261 | 262 | _registerCommand(cmdType, cmd, cmdArgs, cmdOpts = {}) { 263 | assert(cmd, 'cmd is required'); 264 | assert(!this.ctx.cmd, 'cmd had registered'); 265 | 266 | // fork(cmd, cmdOpts) 267 | if (cmdArgs && !Array.isArray(cmdArgs)) { 268 | cmdOpts = cmdArgs; 269 | cmdArgs = undefined; 270 | } 271 | 272 | // alias 273 | if (cmdOpts.execArgv) { 274 | cmdOpts.nodeOptions = cmdOpts.nodeOptions || cmdOpts.execArgv; 275 | delete cmdOpts.execArgv; 276 | } 277 | 278 | // merge opts and env 279 | cmdOpts.env = { ...this.ctx.cmdOpts.env, ...cmdOpts.env }; 280 | Object.assign(this.ctx.cmdOpts, cmdOpts); 281 | Object.assign(this.ctx, { cmd, cmdType, cmdArgs }); 282 | 283 | return this; 284 | } 285 | 286 | async _execCommand() { 287 | const { cmd, cmdType, cmdArgs = [], cmdOpts } = this.ctx; 288 | assert(cmd, 'cmd is required'); 289 | assert(await utils.exists(cmdOpts.cwd), `cwd ${cmdOpts.cwd} is not exists`); 290 | 291 | if (cmdType === 'fork') { 292 | // TODO: check cmd is exists 293 | this.logger.info('Fork `%s` %j', cmd, cmdOpts); 294 | this.proc = execa.execaNode(cmd, cmdArgs, cmdOpts); 295 | } else { 296 | const cmdString = [ cmd, ...cmdArgs ].join(' '); 297 | this.logger.info('Spawn `%s` %j', cmdString, cmdOpts); 298 | this.proc = execa.execaCommand(cmdString, cmdOpts); 299 | } 300 | 301 | // Notice: don't await it 302 | this.proc.then(res => { 303 | // { command, escapedCommand, exitCode, stdout, stderr, all, failed, timedOut, isCanceled, killed } 304 | this.ctx.res = res; 305 | const { result } = this.ctx; 306 | result.code = res.exitCode; 307 | result.stopped = true; 308 | if (res.failed && !res.isCanceled && !res.killed) { 309 | const msg = res.originalMessage ? `Command failed with: ${res.originalMessage}` : res.shortMessage; 310 | this.logger.warn(msg); 311 | } 312 | }); 313 | 314 | this.proc.stdin.setEncoding('utf8'); 315 | 316 | this.proc.stdout.on('data', data => { 317 | const origin = stripFinalNewline(data.toString()); 318 | const content = stripAnsi(origin); 319 | this.ctx.result.stdout += content; 320 | this.emit('stdout', content); 321 | 322 | this.childLogger.info(origin); 323 | }); 324 | 325 | this.proc.stderr.on('data', data => { 326 | const origin = stripFinalNewline(data.toString()); 327 | const content = stripAnsi(origin); 328 | this.ctx.result.stderr += content; 329 | this.emit('stderr', content); 330 | 331 | // FIXME: when using `.error()`, error log will print before info, why?? 332 | this.childLogger.info(origin); 333 | }); 334 | 335 | this.proc.on('spawn', () => { 336 | this.emit('spawn', this); 337 | }); 338 | 339 | this.proc.on('message', data => { 340 | this.emit('message', data); 341 | this.logger.debug('message event:', data); 342 | }); 343 | 344 | this.proc.once('close', code => { 345 | this.emit('close', code); 346 | this.logger.debug('close event:', code); 347 | }); 348 | 349 | // Notice: don't return proc otherwise it will be wait and resolve 350 | await utils.sleep(50); 351 | } 352 | 353 | /** 354 | * wait for event then resume the chains 355 | * 356 | * @param {String} type - message/stdout/stderr/close 357 | * @param {String|RegExp|Object|Function} expected - rule to validate 358 | * - {String}: check whether includes specified string 359 | * - {RegExp}: check whether match regexp 360 | * - {Object}: check whether partial includes specified JSON 361 | * - {Function}: check whether with specified function 362 | * @return {TestRunner} instance for chain 363 | */ 364 | wait(type, expected) { 365 | this.options.autoWait = false; 366 | 367 | // watch immediately but await later in chains 368 | let promise; 369 | switch (type) { 370 | case 'message': { 371 | promise = pEvent(this, 'message', { 372 | rejectionEvents: [ 'close' ], 373 | filter: input => utils.validate(input, expected), 374 | }); 375 | break; 376 | } 377 | 378 | case 'stdout': 379 | case 'stderr': { 380 | promise = pEvent(this, type, { 381 | rejectionEvents: [ 'close' ], 382 | filter: () => { 383 | return utils.validate(this.ctx.result[type], expected); 384 | }, 385 | }); 386 | break; 387 | } 388 | 389 | case 'close': 390 | default: { 391 | promise = pEvent(this, 'close'); 392 | break; 393 | } 394 | } 395 | 396 | // await later in chains 397 | return this._addChain(async function wait() { 398 | try { 399 | await promise; 400 | } catch (err) { 401 | // don't treat close event as error as rejectionEvents 402 | } 403 | }); 404 | } 405 | 406 | /** 407 | * Detect a prompt, then respond to it. 408 | * 409 | * could use `KEYS.UP` / `KEYS.DOWN` to respond to choices prompt. 410 | * 411 | * @param {String|RegExp} expected - test `stdout` with regexp match or string includes 412 | * @param {String|Array} respond - respond content, if set to array then write each with a delay. 413 | * @return {TestRunner} instance for chain 414 | */ 415 | stdin(expected, respond) { 416 | assert(expected, '`expected is required'); 417 | assert(respond, '`respond is required'); 418 | 419 | this._addChain(async function stdin(ctx) { 420 | // check stdout 421 | const isPrompt = utils.validate(ctx.result.stdout.substring(ctx._lastPromptIndex), expected); 422 | if (!isPrompt) { 423 | try { 424 | await pEvent(ctx.instance, 'stdout', { 425 | rejectionEvents: [ 'close' ], 426 | filter: () => { 427 | return utils.validate(ctx.result.stdout.substring(ctx._lastPromptIndex), expected); 428 | }, 429 | }); 430 | } catch (err) { 431 | const msg = 'wait for prompt, but proccess is terminate with error'; 432 | this.logger.error(msg); 433 | const error = new Error(msg); 434 | error.cause = err; 435 | throw error; 436 | } 437 | } 438 | 439 | // remember last index 440 | ctx._lastPromptIndex = ctx.result.stdout.length; 441 | 442 | // respond to stdin 443 | if (!Array.isArray(respond)) respond = [ respond ]; 444 | for (const str of respond) { 445 | // FIXME: when stdin.write, stdout will recieve duplicate output 446 | // auto add \n 447 | ctx.proc.stdin.write(str.replace(/\r?\n$/, '') + EOL); 448 | await utils.sleep(100); // wait a second 449 | } 450 | }, 'running'); 451 | return this; 452 | } 453 | 454 | /** 455 | * set working directory 456 | * 457 | * @param {String} dir - working directory 458 | * @param {Object} [opts] - options 459 | * @param {Boolean} [opts.init] - whether rm and mkdir dir before test 460 | * @param {Boolean} [opts.clean] - whether rm dir after test 461 | * @return {TestRunner} instance for chain 462 | */ 463 | cwd(dir, opts = {}) { 464 | this.ctx.cmdOpts.cwd = dir; 465 | 466 | // auto init 467 | const { init, clean } = opts; 468 | if (init) { 469 | this.use(async function initCwd(ctx, next) { 470 | // if dir is parent of cmdPath and process.cwd(), should throw 471 | const { cmd, cmdType } = ctx; 472 | assert(cmd, 'cmd is required'); 473 | assert(!utils.isParent(dir, process.cwd()), `rm ${dir} is too dangerous`); 474 | assert(cmdType === 'spawn' || (cmdType === 'fork' && !utils.isParent(dir, cmd)), `rm ${dir} is too dangerous`); 475 | 476 | await utils.rm(dir); 477 | await utils.mkdir(dir); 478 | try { 479 | await next(); 480 | } finally { 481 | if (clean) await utils.rm(dir); 482 | } 483 | }); 484 | } 485 | return this; 486 | } 487 | 488 | /** 489 | * set environment variables. 490 | * 491 | * @param {String} key - env key 492 | * @param {String} value - env value 493 | * @return {TestRunner} instance for chain 494 | */ 495 | env(key, value) { 496 | this.ctx.cmdOpts.env[key] = value; 497 | return this; 498 | } 499 | 500 | /** 501 | * set a timeout, will kill SIGTERM then SIGKILL. 502 | * 503 | * @param {Number} ms - milliseconds 504 | * @return {TestRunner} instance for chain 505 | */ 506 | timeout(ms) { 507 | this.ctx.cmdOpts.timeout = ms; 508 | return this; 509 | } 510 | 511 | /** 512 | * cancel the proc. 513 | * 514 | * useful for manually end long-run server after validate. 515 | * 516 | * when kill, exit code maybe undefined if user don't hook signal event. 517 | * 518 | * @see https://github.com/sindresorhus/execa#killsignal-options 519 | * @return {TestRunner} instance for chain 520 | */ 521 | kill() { 522 | return this._addChain(async function kill(ctx) { 523 | ctx.proc.cancel(); 524 | await ctx.proc; 525 | }); 526 | } 527 | } 528 | 529 | export * from './constant.js'; 530 | export { TestRunner, LogLevel }; 531 | 532 | /** 533 | * create a runner 534 | * @param {Object} opts - options 535 | * @return {TestRunner} runner instance 536 | */ 537 | export function runner(opts) { return new TestRunner(opts); } 538 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import { types } from 'util'; 3 | import path from 'path'; 4 | 5 | import { dirname } from 'dirname-filename-esm'; 6 | import isMatch from 'lodash.ismatch'; 7 | import trash from 'trash'; 8 | 9 | types.isString = function(v) { return typeof v === 'string'; }; 10 | types.isObject = function(v) { return v !== null && typeof v === 'object'; }; 11 | types.isFunction = function(v) { return typeof v === 'function'; }; 12 | 13 | export { types, isMatch }; 14 | 15 | /** 16 | * validate input with expected rules 17 | * 18 | * @param {String|Object} input - target 19 | * @param {String|RegExp|Object|Function|Array} expected - rules 20 | * @return {Boolean} pass or not 21 | */ 22 | export function validate(input, expected) { 23 | if (Array.isArray(expected)) { 24 | return expected.some(rule => validate(input, rule)); 25 | } else if (types.isRegExp(expected)) { 26 | return expected.test(input); 27 | } else if (types.isString(expected)) { 28 | return input && input.includes(expected); 29 | } else if (types.isObject(expected)) { 30 | return isMatch(input, expected); 31 | } 32 | return expected(input); 33 | } 34 | 35 | /** 36 | * Check whether is parent 37 | * 38 | * @param {String} parent - parent file path 39 | * @param {String} child - child file path 40 | * @return {Boolean} true if parent >= child 41 | */ 42 | export function isParent(parent, child) { 43 | const p = path.relative(parent, child); 44 | return !(p === '' || p.startsWith('..')); 45 | } 46 | 47 | /** 48 | * mkdirp -p 49 | * 50 | * @param {String} dir - dir path 51 | * @param {Object} [opts] - see fsPromises.mkdirp 52 | */ 53 | export async function mkdir(dir, opts) { 54 | return await fs.mkdir(dir, { recursive: true, ...opts }); 55 | } 56 | 57 | /** 58 | * removes files and directories. 59 | * 60 | * by default it will only moves them to the trash, which is much safer and reversible. 61 | * 62 | * @param {String|Array} p - accepts paths and [glob patterns](https://github.com/sindresorhus/globby#globbing-patterns) 63 | * @param {Object} [opts] - options of [trash](https://github.com/sindresorhus/trash) or [fsPromises.rm](https://nodejs.org/api/fs.html#fs_fspromises_rm_path_options) 64 | * @param {Boolean} [opts.trash=true] - whether to move to [trash](https://github.com/sindresorhus/trash) or permanently delete 65 | */ 66 | export async function rm(p, opts = {}) { 67 | /* istanbul ignore if */ 68 | if (opts.trash === false || process.env.CI) { 69 | return await fs.rm(p, { force: true, recursive: true, ...opts }); 70 | } 71 | /* istanbul ignore next */ 72 | return await trash(p, opts); 73 | } 74 | 75 | 76 | /** 77 | * write file, will auto create parent dir 78 | * 79 | * @param {String} filePath - file path 80 | * @param {String|Object} content - content to write, if pass object, will `JSON.stringify` 81 | * @param {Object} [opts] - see fsPromises.writeFile 82 | */ 83 | export async function writeFile(filePath, content, opts) { 84 | await mkdir(path.dirname(filePath)); 85 | if (types.isObject(content)) { 86 | content = JSON.stringify(content, null, 2); 87 | } 88 | return await fs.writeFile(filePath, content, opts); 89 | } 90 | 91 | /** 92 | * check exists due to `fs.exists` is deprecated 93 | * 94 | * @param {String} filePath - file or directory 95 | * @return {Boolean} exists or not 96 | */ 97 | export async function exists(filePath) { 98 | try { 99 | await fs.access(filePath); 100 | return true; 101 | } catch (_) { 102 | return false; 103 | } 104 | } 105 | 106 | /** 107 | * resolve file path by import.meta, kind of __dirname for esm 108 | * 109 | * @param {Object} meta - import.meta 110 | * @param {...String} args - other paths 111 | * @return {String} file path 112 | */ 113 | export function resolve(meta, ...args) { 114 | const p = types.isObject(meta) ? dirname(meta) : meta; 115 | return path.resolve(p, ...args); 116 | } 117 | 118 | /** 119 | * take a sleep 120 | * 121 | * @param {Number} ms - millisecond 122 | */ 123 | export function sleep(ms) { 124 | return new Promise(resolve => { 125 | setTimeout(resolve, ms); 126 | }); 127 | } 128 | -------------------------------------------------------------------------------- /lib/validator.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { filename } from 'dirname-filename-esm'; 3 | import { assert } from './assert.js'; 4 | import * as utils from './utils.js'; 5 | 6 | /** 7 | * add a validate fn to chains 8 | * 9 | * @param {Function} fn - async ctx => ctx.assert(ctx.result.stdout.includes('hi')); 10 | * @throws {AssertionError} 11 | */ 12 | export function expect(fn) { 13 | const buildError = new Error('only for stack'); 14 | return this._addChain(async ctx => { 15 | try { 16 | await fn.call(this, ctx); 17 | } catch (err) { 18 | throw mergeError(buildError, err); 19 | } 20 | }); 21 | } 22 | 23 | const extractPathRegex = /\s+at.*[(\s](.*):\d+:\d+\)?/; 24 | const __filename = filename(import.meta); 25 | 26 | function mergeError(buildError, runError) { 27 | buildError.message = runError.message; 28 | buildError.actual = runError.actual; 29 | buildError.expected = runError.expected; 30 | buildError.operator = runError.operator; 31 | buildError.stackStartFn = runError.stackStartFn; 32 | buildError.cause = runError; 33 | 34 | buildError.stack = buildError.stack 35 | .split('\n') 36 | .filter(line => { 37 | /* istanbul ignore if */ 38 | if (line.trim() === '') return false; 39 | const pathMatches = line.match(extractPathRegex); 40 | if (pathMatches === null || !pathMatches[1]) return true; 41 | if (pathMatches[1] === __filename) return false; 42 | return true; 43 | }) 44 | .join('\n'); 45 | 46 | return buildError; 47 | } 48 | 49 | /** 50 | * validate file 51 | * 52 | * - `file('/path/to/file')`: check whether file exists 53 | * - `file('/path/to/file', /\w+/)`: check whether file match regexp 54 | * - `file('/path/to/file', 'usage')`: check whether file includes specified string 55 | * - `file('/path/to/file', { version: '1.0.0' })`: checke whether file content partial includes specified JSON 56 | * 57 | * @param {String} filePath - target path to validate, could be relative path 58 | * @param {String|RegExp|Object} [expected] - rule to validate 59 | * @throws {AssertionError} 60 | */ 61 | export function file(filePath, expected) { 62 | assert(filePath, '`filePath` is required'); 63 | return this.expect(async function file({ cwd, assert }) { 64 | const fullPath = path.resolve(cwd, filePath); 65 | await assert.matchFile(fullPath, expected); 66 | }); 67 | } 68 | 69 | /** 70 | * validate file with opposite rule 71 | * 72 | * - `notFile('/path/to/file')`: check whether file don't exists 73 | * - `notFile('/path/to/file', /\w+/)`: check whether file don't match regex 74 | * - `notFile('/path/to/file', 'usage')`: check whether file don't includes specified string 75 | * - `notFile('/path/to/file', { version: '1.0.0' })`: checke whether file content don't partial includes specified JSON 76 | * 77 | * @param {String} filePath - target path to validate, could be relative path 78 | * @param {String|RegExp|Object} [expected] - rule to validate 79 | * @throws {AssertionError} 80 | */ 81 | export function notFile(filePath, expected) { 82 | assert(filePath, '`filePath` is required'); 83 | return this.expect(async function notFile({ cwd, assert }) { 84 | const fullPath = path.resolve(cwd, filePath); 85 | await assert.doesNotMatchFile(fullPath, expected); 86 | }); 87 | } 88 | 89 | /** 90 | * validate stdout 91 | * 92 | * - `stdout(/\w+/)`: check whether stdout match regex 93 | * - `stdout('server started')`: check whether stdout includes some string 94 | * 95 | * @param {String|RegExp} expected - rule to validate 96 | * @throws {AssertionError} 97 | */ 98 | export function stdout(expected) { 99 | assert(expected, '`expected` is required'); 100 | return this.expect(async function stdout({ result, assert }) { 101 | assert.matchRule(result.stdout, expected); 102 | }); 103 | } 104 | 105 | /** 106 | * validate stdout with opposite rule 107 | * 108 | * - `notStdout(/\w+/)`: check whether stdout don't match regex 109 | * - `notStdout('server started')`: check whether stdout don't includes some string 110 | * 111 | * @param {String|RegExp} unexpected - rule to validate 112 | * @throws {AssertionError} 113 | */ 114 | export function notStdout(unexpected) { 115 | assert(unexpected, '`unexpected` is required'); 116 | return this.expect(async function notStdout({ result, assert }) { 117 | assert.doesNotMatchRule(result.stdout, unexpected); 118 | }); 119 | } 120 | 121 | /** 122 | * validate stderr 123 | * 124 | * - `stderr(/\w+/)`: check whether stderr match regex 125 | * - `stderr('server started')`: check whether stderr includes some string 126 | * 127 | * @param {String|RegExp} expected - rule to validate 128 | * @throws {AssertionError} 129 | */ 130 | export function stderr(expected) { 131 | assert(expected, '`expected` is required'); 132 | return this.expect(async function stderr({ result, assert }) { 133 | assert.matchRule(result.stderr, expected); 134 | }); 135 | } 136 | 137 | /** 138 | * validate stderr with opposite rule 139 | * 140 | * - `notStderr(/\w+/)`: check whether stderr don't match regex 141 | * - `notStderr('server started')`: check whether stderr don't includes some string 142 | * 143 | * @param {String|RegExp} unexpected - rule to validate 144 | * @throws {AssertionError} 145 | */ 146 | export function notStderr(unexpected) { 147 | assert(unexpected, '`unexpected` is required'); 148 | return this.expect(async function notStderr({ result, assert }) { 149 | assert.doesNotMatchRule(result.stderr, unexpected); 150 | }); 151 | } 152 | 153 | /** 154 | * validate process exitCode 155 | * 156 | * @param {Number|Function} n - value to compare 157 | * @throws {AssertionError} 158 | */ 159 | export function code(n) { 160 | this._expectedExitCode = n; 161 | 162 | const fn = utils.types.isFunction(n) ? n : code => assert.equal(code, n, `Expected exitCode to be ${n} but got ${code}`); 163 | 164 | this.expect(function code({ result }) { 165 | // when using `.wait()`, it could maybe not exit at this time, so skip and will double check it later 166 | if (result.code !== undefined) { 167 | fn(result.code); 168 | } 169 | }); 170 | 171 | // double check 172 | this._addChain(function code({ result }) { 173 | fn(result.code); 174 | }, 'end'); 175 | 176 | return this; 177 | } 178 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "clet", 3 | "version": "1.0.1", 4 | "description": "Command Line E2E Testing", 5 | "type": "module", 6 | "main": "./lib/runner.js", 7 | "exports": "./lib/runner.js", 8 | "types": "./lib/index.d.ts", 9 | "author": "TZ (https://github.com/atian25)", 10 | "homepage": "https://github.com/node-modules/clet", 11 | "repository": "git@github.com:node-modules/clet.git", 12 | "dependencies": { 13 | "dirname-filename-esm": "^1.1.1", 14 | "dot-prop": "^7.2.0", 15 | "execa": "^6.1.0", 16 | "lodash.ismatch": "^4.4.0", 17 | "p-event": "^5.0.1", 18 | "strip-ansi": "^7.0.1", 19 | "strip-final-newline": "^3.0.0", 20 | "throwback": "^4.1.0", 21 | "trash": "^8.1.0" 22 | }, 23 | "devDependencies": { 24 | "@vitest/coverage-c8": "^0.22.1", 25 | "@vitest/ui": "^0.22.1", 26 | "cross-env": "^7.0.3", 27 | "egg-ci": "^1.19.0", 28 | "enquirer": "^2.3.6", 29 | "eslint": "^7", 30 | "eslint-config-egg": "^9", 31 | "supertest": "^6.2.3", 32 | "vitest": "^0.22.1" 33 | }, 34 | "files": [ 35 | "bin", 36 | "lib", 37 | "index.js" 38 | ], 39 | "scripts": { 40 | "lint": "eslint .", 41 | "test": "vitest", 42 | "cov": "vitest run --coverage", 43 | "ci": "npm run lint && npm run cov" 44 | }, 45 | "ci": { 46 | "version": "14, 16, 18", 47 | "type": "github", 48 | "npminstall": false 49 | }, 50 | "eslintConfig": { 51 | "extends": "eslint-config-egg", 52 | "root": true, 53 | "env": { 54 | "node": true, 55 | "browser": false, 56 | "jest": true 57 | }, 58 | "rules": { 59 | "node/file-extension-in-import": [ 60 | "error", 61 | "always" 62 | ] 63 | }, 64 | "parserOptions": { 65 | "sourceType": "module" 66 | }, 67 | "ignorePatterns": [ 68 | "dist", 69 | "coverage", 70 | "node_modules" 71 | ] 72 | }, 73 | "license": "MIT" 74 | } 75 | -------------------------------------------------------------------------------- /test/assert.test.js: -------------------------------------------------------------------------------- 1 | 2 | import path from 'path'; 3 | import { it, describe } from 'vitest'; 4 | import { assert, matchRule, doesNotMatchRule } from '../lib/assert.js'; 5 | 6 | describe('test/assert.test.js', () => { 7 | const pkgInfo = { 8 | name: 'clet', 9 | version: '1.0.0', 10 | config: { 11 | port: 8080, 12 | }, 13 | }; 14 | 15 | it('should export', () => { 16 | assert.equal(assert.matchRule, matchRule); 17 | assert.equal(assert.doesNotMatchRule, doesNotMatchRule); 18 | }); 19 | 20 | describe('matchRule', () => { 21 | it('should support regexp', () => { 22 | matchRule(123456, /\d+/); 23 | matchRule('abc', /\w+/); 24 | 25 | assert.throws(() => { 26 | matchRule(123456, /abc/); 27 | }, { 28 | name: 'AssertionError', 29 | message: /The input did not match the regular expression/, 30 | actual: '123456', 31 | expected: /abc/, 32 | }); 33 | }); 34 | 35 | it('should support string', () => { 36 | matchRule('abc', 'b'); 37 | 38 | assert.throws(() => { 39 | matchRule('abc', 'cd'); 40 | }, { 41 | name: 'AssertionError', 42 | message: /'abc' should includes 'cd'/, 43 | actual: 'abc', 44 | expected: 'cd', 45 | }); 46 | }); 47 | 48 | it('should support JSON', () => { 49 | matchRule(pkgInfo, { name: 'clet', config: { port: 8080 } }); 50 | matchRule(JSON.stringify(pkgInfo), { name: 'clet', config: { port: 8080 } }); 51 | 52 | const unexpected = { name: 'clet', config: { a: '1' } }; 53 | assert.throws(() => { 54 | matchRule(pkgInfo, unexpected); 55 | }, { 56 | name: 'AssertionError', 57 | message: /should partial includes/, 58 | actual: pkgInfo, 59 | expected: unexpected, 60 | }); 61 | 62 | assert.throws(() => { 63 | matchRule(JSON.stringify(pkgInfo), unexpected); 64 | }, { 65 | name: 'AssertionError', 66 | message: /should partial includes/, 67 | actual: pkgInfo, 68 | expected: unexpected, 69 | }); 70 | }); 71 | }); 72 | 73 | describe('doesNotMatchRule', () => { 74 | it('should support regexp', () => { 75 | doesNotMatchRule(123456, /abc/); 76 | doesNotMatchRule('abc', /\d+/); 77 | 78 | assert.throws(() => { 79 | doesNotMatchRule(123456, /\d+/); 80 | }, { 81 | name: 'AssertionError', 82 | message: /The input was expected to not match the regular expression/, 83 | actual: '123456', 84 | expected: /\d+/, 85 | }); 86 | }); 87 | 88 | it('should support string', () => { 89 | doesNotMatchRule('abc', '123'); 90 | assert.throws(() => { 91 | doesNotMatchRule('abcd', 'cd'); 92 | }, { 93 | name: 'AssertionError', 94 | message: /'abcd' should not includes 'cd'/, 95 | actual: 'abcd', 96 | expected: 'cd', 97 | }); 98 | }); 99 | 100 | it('should support json', () => { 101 | doesNotMatchRule(pkgInfo, { name: 'clet', config: { a: '1' } }); 102 | doesNotMatchRule(JSON.stringify(pkgInfo), { name: 'clet', config: { a: '1' } }); 103 | 104 | const unexpected = { name: 'clet', config: { port: 8080 } }; 105 | assert.throws(() => { 106 | doesNotMatchRule(pkgInfo, unexpected); 107 | }, { 108 | name: 'AssertionError', 109 | message: /should not partial includes/, 110 | actual: pkgInfo, 111 | expected: unexpected, 112 | }); 113 | 114 | assert.throws(() => { 115 | doesNotMatchRule(JSON.stringify(pkgInfo), unexpected); 116 | }, { 117 | name: 'AssertionError', 118 | message: /should not partial includes/, 119 | actual: pkgInfo, 120 | expected: unexpected, 121 | }); 122 | }); 123 | }); 124 | 125 | describe('matchFile', () => { 126 | const fixtures = path.resolve('test/fixtures/file'); 127 | it('should check exists', async () => { 128 | await assert.matchFile(`${fixtures}/test.md`); 129 | await assert.rejects(async () => { 130 | await assert.matchFile(`${fixtures}/not-exist.md`); 131 | }, /not-exist.md to be exists/); 132 | }); 133 | 134 | it('should check content', async () => { 135 | await assert.matchFile(`${fixtures}/test.md`, 'this is a README'); 136 | await assert.matchFile(`${fixtures}/test.md`, /this is a README/); 137 | await assert.matchFile(`${fixtures}/test.json`, { name: 'test', config: { port: 8080 } }); 138 | 139 | await assert.rejects(async () => { 140 | await assert.matchFile(`${fixtures}/test.md`, 'abc'); 141 | }, /file.*test\.md.*this is.*should includes 'abc'/); 142 | }); 143 | }); 144 | 145 | describe('doesNotMatchFile', () => { 146 | const fixtures = path.resolve('test/fixtures/file'); 147 | it('should check not exists', async () => { 148 | await assert.doesNotMatchFile(`${fixtures}/a/b/c/d.md`); 149 | 150 | await assert.rejects(async () => { 151 | await assert.doesNotMatchFile(`${fixtures}/not-exist.md`, 'abc'); 152 | }, /Expected file\(.*not-exist.md\) not to match.*but file not exists/); 153 | }); 154 | 155 | it('should check not content', async () => { 156 | await assert.doesNotMatchFile(`${fixtures}/test.md`, 'abc'); 157 | await assert.doesNotMatchFile(`${fixtures}/test.md`, /abcccc/); 158 | await assert.doesNotMatchFile(`${fixtures}/test.json`, { name: 'test', config: { a: 1 } }); 159 | 160 | await assert.rejects(async () => { 161 | await assert.doesNotMatchFile(`${fixtures}/test.md`, 'this is a README'); 162 | }, /file.*test\.md.*this is.*should not includes 'this is a README'/); 163 | }); 164 | }); 165 | 166 | it.todo('error stack'); 167 | }); 168 | -------------------------------------------------------------------------------- /test/command.test.js: -------------------------------------------------------------------------------- 1 | import { it, describe } from 'vitest'; 2 | import { runner } from '../lib/runner.js'; 3 | import path from 'path'; 4 | 5 | describe('test/command.test.js', () => { 6 | const fixtures = path.resolve('test/fixtures/command'); 7 | 8 | describe('fork', () => { 9 | it('should fork', async () => { 10 | await runner() 11 | .cwd(fixtures) 12 | .fork('./bin/cli.js') 13 | .stdout(/version=v\d+\.\d+\.\d+/) 14 | .stdout(`cwd=${fixtures}`); 15 | }); 16 | 17 | it('should fork with args', async () => { 18 | await runner() 19 | .cwd(fixtures) 20 | .fork('./bin/cli.js', [ '--name=tz' ]) 21 | .stdout(/argv=.*--name=tz/); 22 | }); 23 | 24 | it('should fork with opts', async () => { 25 | await runner() 26 | .cwd(fixtures) 27 | .fork('./bin/cli.js', { nodeOptions: [ '--no-deprecation' ] }) 28 | .stdout(/argv=\[]/) 29 | .stdout(/execArgv=\["--no-deprecation"]/); 30 | }); 31 | 32 | it('should fork with args + env', async () => { 33 | await runner() 34 | .cwd(fixtures) 35 | .fork('./bin/cli.js', [ '--name=tz' ], { execArgv: [ '--no-deprecation' ] }) 36 | .stdout(/argv=.*--name=tz/) 37 | .stdout(/execArgv=\["--no-deprecation"]/); 38 | }); 39 | 40 | it('should fork with env merge', async () => { 41 | await runner() 42 | .cwd(fixtures) 43 | .env('a', 1) 44 | .fork('./bin/cli.js', { env: { logEnv: 'PATH,a,b', b: 2 } }) 45 | .stdout(/env.a=1/) 46 | .stdout(/env.b=2/) 47 | .stdout(/env.PATH=/); 48 | }); 49 | }); 50 | 51 | describe('spawn', () => { 52 | it('should spawn shell', async () => { 53 | await runner() 54 | .cwd(fixtures) 55 | .spawn('node -v') 56 | .stdout(/^v\d+\.\d+\.\d+/); 57 | }); 58 | it('should spawn with args', async () => { 59 | await runner() 60 | .cwd(fixtures) 61 | .spawn('node', [ './bin/cli.js' ]) 62 | .stdout(/version=v\d+\.\d+\.\d+/) 63 | .stdout(`cwd=${fixtures}`); 64 | }); 65 | 66 | it('should spawn without separated args', async () => { 67 | await runner() 68 | .cwd(fixtures) 69 | .env('a', 1) 70 | .spawn('node ./bin/cli.js', { env: { logEnv: 'PATH,a,b', b: 2 } }) 71 | .stdout(/version=v\d+\.\d+\.\d+/) 72 | .stdout(/env.a=1/) 73 | .stdout(/env.b=2/) 74 | .stdout(/env.PATH=/); 75 | }); 76 | 77 | it('should spawn with opts', async () => { 78 | await runner() 79 | .cwd(fixtures) 80 | .env('a', 1) 81 | .spawn('node', [ './bin/cli.js' ], { env: { logEnv: 'PATH,a,b', b: 2 } }) 82 | .stdout(/version=v\d+\.\d+\.\d+/) 83 | .stdout(/env.a=1/) 84 | .stdout(/env.b=2/) 85 | .stdout(/env.PATH=/); 86 | }); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /test/commonjs.test.cjs: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const path = require('path'); 3 | import { it, describe, beforeAll } from 'vitest'; 4 | 5 | describe('test/commonjs.test.cjs', () => { 6 | const fixtures = path.resolve('test/fixtures'); 7 | let runner; 8 | 9 | beforeAll(async () => { 10 | runner = (await import('../lib/runner.js')).runner; 11 | }); 12 | 13 | it('should support import', async () => { 14 | assert(runner); 15 | console.log('this is commonjs'); 16 | }); 17 | 18 | it('should support fork', async () => { 19 | await runner() 20 | .fork(`${fixtures}/version.js`) 21 | .log('result.stdout') 22 | .stdout(/\d+\.\d+\.\d+/) 23 | .end(); 24 | }); 25 | 26 | it('should support spawn', async () => { 27 | await runner() 28 | .spawn('npm -v') 29 | .log('result.stdout') 30 | .stdout(/\d+\.\d+\.\d+/) 31 | .end(); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /test/example.test.js: -------------------------------------------------------------------------------- 1 | 2 | import path from 'path'; 3 | import { it, describe } from 'vitest'; 4 | import request from 'supertest'; 5 | import { runner, KEYS } from '../lib/runner.js'; 6 | import * as utils from './test-utils.js'; 7 | 8 | describe('test/example.test.js', () => { 9 | it('should works with boilerplate', async () => { 10 | const tmpDir = utils.getTempDir(); 11 | await runner() 12 | .cwd(tmpDir, { init: true }) 13 | .spawn('npm init') 14 | .stdin(/name:/, 'example') // wait for stdout, then respond 15 | .stdin(/version:/, new Array(9).fill(KEYS.ENTER)) // don't care about others, just enter 16 | .stdout(/"name": "example"/) // validate stdout 17 | .file('package.json', { name: 'example', version: '1.0.0' }); // validate file content, relative to cwd 18 | }); 19 | 20 | it('should works with command-line apps', async () => { 21 | const baseDir = path.resolve('test/fixtures/example'); 22 | await runner() 23 | .cwd(baseDir) 24 | .fork('bin/cli.js', [ '--name=test' ], { execArgv: [ '--no-deprecation' ] }) 25 | .stdout('this is example bin') 26 | .stdout(`cwd=${baseDir}`) 27 | .stdout(/argv=\["--name=\w+"\]/) 28 | .stdout(/execArgv=\["--no-deprecation"\]/) 29 | .stderr(/this is a warning/); 30 | }); 31 | 32 | it('should works with long-run apps', async () => { 33 | await runner() 34 | .cwd('test/fixtures/server') 35 | .fork('bin/cli.js') 36 | .wait('stdout', /server started/) 37 | .expect(async () => { 38 | return request('http://localhost:3000') 39 | .get('/') 40 | .query({ name: 'tz' }) 41 | .expect(200) 42 | .expect('hi, tz'); 43 | }) 44 | .kill(); // long-run server will not auto exit, so kill it manually after test 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /test/file.test.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { it, describe } from 'vitest'; 3 | import { runner } from '../lib/runner.js'; 4 | import * as utils from './test-utils.js'; 5 | import { strict as assert } from 'assert'; 6 | 7 | describe('test/file.test.js', () => { 8 | const fixtures = path.resolve('test/fixtures'); 9 | const tmpDir = utils.getTempDir(); 10 | const cliPath = path.resolve(fixtures, 'file.js'); 11 | 12 | describe('file()', () => { 13 | it('should check exists', async () => { 14 | await runner() 15 | .cwd(tmpDir, { init: true }) 16 | .fork(cliPath) 17 | 18 | // check exists 19 | .file('./test.json') // support relative path 20 | .file(`${tmpDir}/test.md`); 21 | }); 22 | 23 | it('should check exists fail', async () => { 24 | await assert.rejects(async () => { 25 | await runner() 26 | .cwd(tmpDir, { init: true }) 27 | .fork(cliPath) 28 | .file(`${tmpDir}/not-exist.md`); 29 | }, /not-exist.md to be exists/); 30 | }); 31 | 32 | it('should check content', async () => { 33 | await runner() 34 | .cwd(tmpDir, { init: true }) 35 | .fork(cliPath) 36 | .file(`${tmpDir}/test.md`, 'this is a README') 37 | .file(`${tmpDir}/test.md`, /this is a README/) 38 | .file(`${tmpDir}/test.json`, { name: 'test', config: { port: 8080 } }); 39 | }); 40 | 41 | it('should check content fail', async () => { 42 | await assert.rejects(async () => { 43 | await runner() 44 | .cwd(tmpDir, { init: true }) 45 | .fork(cliPath) 46 | .file(`${tmpDir}/test.md`, 'abc'); 47 | }, /file.*test\.md.*this is.*should includes 'abc'/); 48 | }); 49 | }); 50 | 51 | describe('notFile()', () => { 52 | it('should check not exists', async () => { 53 | await runner() 54 | .cwd(tmpDir, { init: true }) 55 | .fork(cliPath) 56 | .notFile('./abc') 57 | .notFile(`${tmpDir}/a/b/c/d.md`); 58 | 59 | }); 60 | 61 | it('should check not exists fail', async () => { 62 | await assert.rejects(async () => { 63 | await runner() 64 | .cwd(tmpDir, { init: true }) 65 | .fork(cliPath) 66 | .notFile(`${tmpDir}/not-exist.md`, 'abc'); 67 | }, /Expected file\(.*not-exist.md\) not to match.*but file not exists/); 68 | }); 69 | 70 | it('should check not content', async () => { 71 | await runner() 72 | .cwd(tmpDir, { init: true }) 73 | .fork(cliPath) 74 | .notFile(`${tmpDir}/test.md`, 'abc') 75 | .notFile(`${tmpDir}/test.md`, /abcccc/) 76 | .notFile(`${tmpDir}/test.json`, { name: 'test', config: { a: 1 } }); 77 | }); 78 | 79 | it('should check not content fail', async () => { 80 | await assert.rejects(async () => { 81 | await runner() 82 | .cwd(tmpDir, { init: true }) 83 | .fork(cliPath) 84 | .notFile(`${tmpDir}/test.md`, 'this is a README'); 85 | }, /file.*test\.md.*this is.*should not includes 'this is a README'/); 86 | }); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /test/fixtures/command/bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | console.log('cwd=%s', process.cwd()); 4 | console.log('version=%s', process.version); 5 | console.log('argv=%j', process.argv.slice(2)); 6 | console.log('execArgv=%j', process.execArgv); 7 | 8 | if (process.env.logEnv) { 9 | const keys = process.env.logEnv.split(','); 10 | for (const key of keys) { 11 | console.log(`env.${key}=${process.env[key]}`); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/fixtures/command/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "command", 3 | "version": "1.0.0", 4 | "bin": "./bin/cli.js", 5 | "type": "module" 6 | } 7 | -------------------------------------------------------------------------------- /test/fixtures/example/bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | console.log('this is example bin~'); 4 | console.log('cwd=%s', process.cwd()); 5 | console.log('argv=%j', process.argv.slice(2)); 6 | console.log('execArgv=%j', process.execArgv); 7 | console.warn('this is a warning'); 8 | -------------------------------------------------------------------------------- /test/fixtures/example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "bin": "./bin/cli.js", 5 | "type": "module" 6 | } 7 | -------------------------------------------------------------------------------- /test/fixtures/file.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import fs from 'fs'; 4 | 5 | fs.writeFileSync('./test.md', '# test\nthis is a README'); 6 | 7 | fs.writeFileSync('./test.json', JSON.stringify({ 8 | name: 'test', 9 | version: '1.0.0', 10 | config: { 11 | port: 8080, 12 | }, 13 | }, null, 2)); 14 | 15 | console.log(fs.readdirSync('./')); 16 | -------------------------------------------------------------------------------- /test/fixtures/file/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test", 3 | "version": "1.0.0", 4 | "config": { 5 | "port": 8080 6 | } 7 | } -------------------------------------------------------------------------------- /test/fixtures/file/test.md: -------------------------------------------------------------------------------- 1 | # test 2 | this is a README -------------------------------------------------------------------------------- /test/fixtures/logger.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | console.log('this is a log message'); 4 | console.log('version=%s', process.version); 5 | console.error(new Error('some error message')); 6 | console.log('process exit'); 7 | -------------------------------------------------------------------------------- /test/fixtures/long-run.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | console.log('long run...'); 4 | 5 | process.on('SIGTERM', () => { 6 | console.log('recieve SIGTERM'); 7 | process.exit(0); 8 | }); 9 | 10 | setTimeout(() => { 11 | console.log('exit long-run'); 12 | }, 5000); 13 | 14 | -------------------------------------------------------------------------------- /test/fixtures/middleware.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import fs from 'fs'; 4 | 5 | const targetPath = process.env.targetPath; 6 | 7 | fs.appendFileSync(targetPath, '3'); 8 | -------------------------------------------------------------------------------- /test/fixtures/process.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | console.log('version: %s', process.version); 4 | console.log('argv: %j', process.argv.slice(2)); 5 | 6 | const type = process.argv[2] && process.argv[2].substring(2); 7 | 8 | switch (type) { 9 | case 'error': 10 | console.error('this is an error'); 11 | break; 12 | 13 | case 'fail': 14 | throw new Error('this is an error'); 15 | 16 | case 'delay': 17 | console.log('delay for a while'); 18 | setTimeout(() => console.log('delay done'), 200); 19 | break; 20 | 21 | default: 22 | break; 23 | } 24 | -------------------------------------------------------------------------------- /test/fixtures/prompt.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import enquirer from 'enquirer'; 4 | 5 | async function run() { 6 | if (process.env.throw) { 7 | setTimeout(() => { 8 | console.log('manually exit due to test prompt timeout'); 9 | process.exit(1); 10 | }, 1000); 11 | } 12 | const answers = await enquirer.prompt([{ 13 | type: 'input', 14 | name: 'name', 15 | message: 'Name:', 16 | }, { 17 | type: 'input', 18 | name: 'email', 19 | message: 'Email:', 20 | }, { 21 | type: 'select', 22 | name: 'gender', 23 | message: 'Gender:', 24 | choices: [ 'boy', 'girl', 'unknown' ], 25 | }]); 26 | console.log(`Author: ${answers.name} <${answers.email}>`); 27 | console.log(`Gender: ${answers.gender}`); 28 | } 29 | 30 | run().catch(console.error); 31 | -------------------------------------------------------------------------------- /test/fixtures/readline.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import readline from 'readline'; 4 | 5 | const rl = readline.createInterface({ 6 | input: process.stdin, 7 | output: process.stdout, 8 | }); 9 | 10 | function ask(q) { 11 | return new Promise(resolve => rl.question(q, resolve)); 12 | } 13 | 14 | async function run() { 15 | const name = await ask('Name: '); 16 | const email = await ask('Email: '); 17 | rl.close(); 18 | console.log(`Author: ${name} <${email}>`); 19 | } 20 | 21 | run().catch(console.error); 22 | -------------------------------------------------------------------------------- /test/fixtures/server/bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import startServer from '../index.js'; 4 | 5 | startServer(); 6 | -------------------------------------------------------------------------------- /test/fixtures/server/index.js: -------------------------------------------------------------------------------- 1 | import http from 'http'; 2 | import { URL } from 'url'; 3 | 4 | export default function startServer() { 5 | const server = http.createServer((req, res) => { 6 | const urlObj = new URL(req.url, `http://${req.headers.host}`); 7 | console.log(`recieve request: ${urlObj.href}`); 8 | res.end(`hi, ${urlObj.searchParams.get('name') || 'anonymous'}`); 9 | }); 10 | server.listen(3000, () => { 11 | console.log('[%s] server started at localhost:3000', process.pid); 12 | }); 13 | 14 | // setTimeout(() => { 15 | // console.log('auto exit'); 16 | // process.exit(0); 17 | // }, 5000); 18 | 19 | // process.on('SIGTERM', () => { 20 | // console.log('recieve SIGTERM, gracefull exit'); 21 | // server.close(); 22 | // process.exit(); 23 | // }); 24 | } 25 | -------------------------------------------------------------------------------- /test/fixtures/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "bin": "./bin/cli.js", 5 | "exports": "./index.js", 6 | "type": "module" 7 | } 8 | -------------------------------------------------------------------------------- /test/fixtures/version.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | console.log(process.version); 4 | -------------------------------------------------------------------------------- /test/fixtures/wait.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | function sleep(ms) { 4 | return new Promise(resolve => { 5 | setTimeout(resolve, ms); 6 | }); 7 | } 8 | 9 | async function run() { 10 | console.log('starting...'); 11 | await sleep(500); 12 | console.log('started'); 13 | console.error('be careful'); 14 | process.send && process.send({ action: 'egg-ready' }); 15 | process.send && process.send('egg-ready'); 16 | await sleep(500); 17 | process.exit(0); 18 | } 19 | 20 | run().catch(console.error); 21 | -------------------------------------------------------------------------------- /test/logger.test.js: -------------------------------------------------------------------------------- 1 | 2 | import { it, describe, beforeEach, afterEach, expect, vi } from 'vitest'; 3 | import { Logger, LogLevel } from '../lib/logger.js'; 4 | 5 | describe('test/logger.test.js', () => { 6 | beforeEach(() => { 7 | for (const name of [ 'error', 'warn', 'info', 'log', 'debug' ]) { 8 | vi.spyOn(global.console, name); 9 | } 10 | }); 11 | 12 | afterEach(() => { 13 | vi.resetAllMocks(); 14 | }); 15 | 16 | it('should support level', () => { 17 | const logger = new Logger(); 18 | expect(logger.level === LogLevel.INFO); 19 | 20 | logger.level = LogLevel.DEBUG; 21 | logger.debug('debug log'); 22 | expect(console.debug).toHaveBeenCalledWith('debug log'); 23 | 24 | logger.level = 'WARN'; 25 | logger.info('info log'); 26 | expect(console.info).not.toHaveBeenCalled(); 27 | }); 28 | 29 | it('should logger verbose', () => { 30 | const logger = new Logger({ level: LogLevel.Verbose }); 31 | logger.error('error log'); 32 | logger.warn('warn log'); 33 | logger.info('info log'); 34 | logger.debug('debug log'); 35 | logger.verbose('verbose log'); 36 | 37 | expect(console.error).toHaveBeenCalledWith('error log'); 38 | expect(console.warn).toHaveBeenCalledWith('warn log'); 39 | expect(console.info).toHaveBeenCalledWith('info log'); 40 | expect(console.debug).toHaveBeenCalledWith('debug log'); 41 | expect(console.debug).toHaveBeenCalledWith('verbose log'); 42 | }); 43 | 44 | it('should logger as default', () => { 45 | const logger = new Logger(); 46 | logger.error('error log'); 47 | logger.warn('warn log'); 48 | logger.info('info log'); 49 | logger.debug('debug log'); 50 | logger.verbose('verbose log'); 51 | 52 | expect(console.error).toHaveBeenCalledWith('error log'); 53 | expect(console.warn).toHaveBeenCalledWith('warn log'); 54 | expect(console.info).toHaveBeenCalledWith('info log'); 55 | expect(console.debug).not.toHaveBeenCalled(); 56 | }); 57 | 58 | it('should support tag/time', () => { 59 | const logger = new Logger({ tag: 'A', showTag: true, showTime: true }); 60 | 61 | logger.info('info log'); 62 | expect(console.info).toHaveBeenCalledWith(expect.stringMatching(/\[\d+-\d+-\d+ \d+:\d+:\d+\] \[A\] info log/)); 63 | }); 64 | 65 | it('should child()', () => { 66 | const logger = new Logger('A'); 67 | const childLogger = logger.child('B', { showTag: true, showTime: true }); 68 | expect(childLogger === logger.child('B')); 69 | childLogger.warn('info log'); 70 | expect(console.warn).toHaveBeenCalledWith(expect.stringMatching(/\[\d+-\d+-\d+ \d+:\d+:\d+\]\s{3}\[A:B\] info log/)); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /test/middleware.test.js: -------------------------------------------------------------------------------- 1 | import { it, describe } from 'vitest'; 2 | import { runner } from '../lib/runner.js'; 3 | import { strict as assert } from 'assert'; 4 | 5 | describe('test/middleware.test.js', () => { 6 | it('should support middleware', async () => { 7 | const tmp = []; 8 | await runner() 9 | .use(async (ctx, next) => { 10 | tmp.push('1'); 11 | await next(); 12 | tmp.push('5'); 13 | }) 14 | .use(async (ctx, next) => { 15 | tmp.push('2'); 16 | await next(); 17 | tmp.push('4'); 18 | }) 19 | .spawn('node -p "3"') 20 | .tap(ctx => { 21 | tmp.push(ctx.result.stdout.replace(/\r?\n/, '')); 22 | }); 23 | 24 | // check 25 | assert.equal(tmp.join(''), '12345'); 26 | }); 27 | 28 | it('should always fork after middleware', async () => { 29 | const tmp = []; 30 | await runner() 31 | .use(async (ctx, next) => { 32 | tmp.push('1'); 33 | await next(); 34 | tmp.push('5'); 35 | }) 36 | 37 | .spawn('node -p "3"') 38 | .tap(ctx => { 39 | tmp.push(ctx.result.stdout.replace(/\r?\n/, '')); 40 | }) 41 | 42 | .use(async (ctx, next) => { 43 | tmp.push('2'); 44 | await next(); 45 | tmp.push('4'); 46 | }); 47 | 48 | // check 49 | assert.equal(tmp.join(''), '12345'); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /test/operation.test.js: -------------------------------------------------------------------------------- 1 | import { it, describe, beforeEach, afterEach, expect, vi } from 'vitest'; 2 | import { runner, KEYS } from '../lib/runner.js'; 3 | import * as utils from './test-utils.js'; 4 | 5 | describe('test/operation.test.js', () => { 6 | beforeEach(() => { 7 | for (const name of [ 'error', 'warn', 'info', 'log', 'debug' ]) { 8 | vi.spyOn(global.console, name); 9 | } 10 | }); 11 | 12 | afterEach(() => { 13 | vi.resetAllMocks(); 14 | }); 15 | 16 | const tmpDir = utils.getTempDir(); 17 | 18 | it('should support mkdir()/rm()', async () => { 19 | await runner() 20 | .cwd(tmpDir, { init: true }) 21 | .mkdir('a/b') 22 | .file('a/b') 23 | .rm('a/b') 24 | .file('a') 25 | .notFile('a/b') 26 | .spawn('npm -v'); 27 | }); 28 | 29 | it('should support writeFile()', async () => { 30 | await runner() 31 | .cwd(tmpDir, { init: true }) 32 | .writeFile('test.json', { name: 'writeFile' }) 33 | .writeFile('test.md', 'this is a test') 34 | .file('test.json', /"name": "writeFile"/) 35 | .file('test.md', /this is a test/) 36 | .spawn('npm -v'); 37 | }); 38 | 39 | it('should support shell()', async () => { 40 | await runner() 41 | .cwd(tmpDir, { init: true }) 42 | .spawn('npm init') 43 | .stdin(/name:/, 'example') 44 | .stdin(/version:/, new Array(9).fill(KEYS.ENTER)) 45 | .file('package.json', { name: 'example', version: '1.0.0' }) 46 | .shell('npm test', { reject: false }) 47 | .shell('node --no-exists', { reject: false }) 48 | .shell('echo "dont collect this log"', { reject: false, collectLog: false }) 49 | .shell('node --no-collect', { reject: false, collectLog: false }) 50 | .sleep(100) 51 | // should also collect shell output 52 | .stdout('no test specified') 53 | .stderr('bad option: --no-exists') 54 | .notStdout('dont collect this log') 55 | .notStderr('bad option: --no-collect'); 56 | }, 10000); 57 | 58 | it('should support log()', async () => { 59 | await runner() 60 | .spawn('npm -v') 61 | .log('stdout: %s, code: %d', 'result.stdout', 'result.code') 62 | .log('result'); 63 | 64 | expect(console.info).toHaveBeenCalledWith(expect.stringMatching(/\[CLET\] stdout: \d+\.\d+\.\d+, code: 0/)); 65 | expect(console.info).toHaveBeenCalledWith(expect.stringMatching(/\[CLET\] \{ stdout:.*/)); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /test/plugin.test.js: -------------------------------------------------------------------------------- 1 | import { it, describe } from 'vitest'; 2 | import { strict as assert } from 'assert'; 3 | import { runner } from '../lib/runner.js'; 4 | 5 | describe('test/plugin.test.js', () => { 6 | it('should support options.plugins', async () => { 7 | const opts = { 8 | plugins: [ 9 | function(target) { 10 | target.ctx.cache = {}; 11 | target.cache = function(key, value) { 12 | this.ctx.cache[key] = value; 13 | return this; 14 | }; 15 | }, 16 | ], 17 | }; 18 | 19 | const ctx = await runner(opts) 20 | .spawn('node', [ '-v' ]) 21 | .cache('a', 'b'); 22 | 23 | assert.equal(ctx.cache.a, 'b'); 24 | }); 25 | 26 | it('should register(fn)', async () => { 27 | const ctx = await runner() 28 | .register(target => { 29 | target.ctx.cache = {}; 30 | target.cache = function(key, value) { 31 | this.ctx.cache[key] = value; 32 | return this; 33 | }; 34 | }) 35 | .spawn('node', [ '-v' ]) 36 | .cache('a', 'b'); 37 | 38 | assert.equal(ctx.cache.a, 'b'); 39 | }); 40 | 41 | it('should register(obj)', async () => { 42 | const ctx = await runner() 43 | .register({ 44 | a(...args) { 45 | this.ctx.a = args.join(','); 46 | return this; 47 | }, 48 | b(...args) { 49 | this.ctx.b = args.join(','); 50 | return this; 51 | }, 52 | }) 53 | .spawn('node', [ '-v' ]) 54 | .a(1, 2) 55 | .b(1); 56 | 57 | assert.equal(ctx.a, '1,2'); 58 | assert(ctx.b, '1'); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /test/process.test.js: -------------------------------------------------------------------------------- 1 | import { it, describe, beforeEach } from 'vitest'; 2 | import path from 'path'; 3 | import { strict as assert } from 'assert'; 4 | import fs from 'fs'; 5 | import { runner } from '../lib/runner.js'; 6 | import * as utils from './test-utils.js'; 7 | 8 | describe('test/process.test.js', () => { 9 | const fixtures = path.resolve('test/fixtures'); 10 | const tmpDir = utils.getTempDir(); 11 | 12 | beforeEach(() => utils.initDir(tmpDir)); 13 | 14 | it.todo('fork relative path'); 15 | it.todo('fork bin path'); 16 | 17 | it('should support expect()', async () => { 18 | await runner() 19 | .spawn('node -v') 20 | .expect(ctx => { 21 | const { assert, result } = ctx; 22 | assert.match(result.stdout, /v\d+\.\d+\.\d+/); 23 | }); 24 | }); 25 | 26 | it('should support spawn', async () => { 27 | await runner() 28 | .spawn('node -v') 29 | .stdout(/v\d+\.\d+\.\d+/) 30 | .stdout(process.version) 31 | .notStdout('xxxx') 32 | .notStdout(/^abc/) 33 | .notStderr('xxxx') 34 | .notStderr(/^abc/) 35 | .code(0); 36 | }); 37 | 38 | it('should support stdout()', async () => { 39 | await runner() 40 | .cwd(fixtures) 41 | .fork('process.js') 42 | .stdout(/version: v\d+\.\d+\.\d+/) 43 | .stdout('argv:') 44 | .notStdout('xxxx') 45 | .notStdout(/^abc/); 46 | }); 47 | 48 | it('should support stderr()', async () => { 49 | await runner() 50 | .cwd(fixtures) 51 | .fork('process.js', [ '--error' ]) 52 | .stderr(/an error/) 53 | .stderr('this is an error') 54 | .notStderr('xxxx') 55 | .notStderr(/^abc/); 56 | }); 57 | 58 | it('should support code(0)', async () => { 59 | await runner() 60 | .cwd(fixtures) 61 | .fork('process.js') 62 | .code(0); 63 | }); 64 | 65 | it('should support code(1)', async () => { 66 | await runner() 67 | .cwd(fixtures) 68 | .fork('process.js', [ '--fail' ]) 69 | .code(1); 70 | }); 71 | 72 | it('should double check code()', async () => { 73 | await runner() 74 | .cwd(fixtures) 75 | .fork('process.js', [ '--delay' ]) 76 | .wait('stdout', /delay for a while/) 77 | .code(0); 78 | }); 79 | 80 | it('should support code(fn)', async () => { 81 | await runner() 82 | .cwd(fixtures) 83 | .spawn('node --no-exists-argv') 84 | .code(n => n < 0); 85 | }); 86 | 87 | it('should throw if not calling code() when proc fail', async () => { 88 | await assert.rejects(async () => { 89 | await runner() 90 | .cwd(fixtures) 91 | .fork('process.js', [ '--fail' ]); 92 | }, /Command failed with exit code 1/); 93 | }); 94 | 95 | it('should timeout', async () => { 96 | await assert.rejects(async () => { 97 | await runner() 98 | .cwd(fixtures) 99 | .timeout(1000) 100 | .fork('long-run.js') 101 | .wait('stdout', /long run/) 102 | .sleep(2000); 103 | }, /timed out after 1000/); 104 | }); 105 | 106 | it('should auto create cwd', async () => { 107 | const cliPath = path.resolve(fixtures, 'file.js'); 108 | const targetDir = utils.getTempDir('../cwd-test'); 109 | await fs.promises.mkdir(targetDir, { recursive: true }); 110 | await fs.promises.writeFile(path.join(targetDir, 'should-delete.md'), 'foo'); 111 | 112 | // clean: false 113 | await runner() 114 | .cwd(targetDir, { init: true }) 115 | .fork(cliPath) 116 | .notFile('should-delete.md') 117 | .file('test.md', /# test/); 118 | 119 | assert.equal(fs.existsSync(targetDir), true); 120 | 121 | // clean: true 122 | await runner() 123 | .cwd(targetDir, { init: true, clean: true }) 124 | .fork(cliPath) 125 | .file('test.md', /# test/); 126 | 127 | assert.equal(fs.existsSync(targetDir), false); 128 | }); 129 | 130 | it('should throw if auto create cwd will damage', async () => { 131 | const cliPath = path.resolve(fixtures, 'file.js'); 132 | const targetDir = utils.getTempDir('../cwd-test-damage'); 133 | 134 | await assert.rejects(async () => { 135 | await runner() 136 | .cwd(fixtures, { init: true, clean: true }) 137 | .fork(cliPath); 138 | }, /rm.*too dangerous/); 139 | 140 | await assert.rejects(async () => { 141 | await runner() 142 | .cwd(path.dirname(cliPath), { init: true, clean: true }) 143 | .fork(cliPath); 144 | }, /rm.*too dangerous/); 145 | 146 | // rm event fail 147 | await assert.rejects(async () => { 148 | await runner() 149 | .cwd(targetDir, { init: true, clean: true }) 150 | .fork(cliPath) 151 | .file('test.md', /# test/) 152 | .tap(() => { throw new Error('trigger fail'); }); 153 | }, /trigger fail/); 154 | 155 | assert.equal(fs.existsSync(targetDir), false); 156 | }); 157 | 158 | it('should kill', async () => { 159 | await runner() 160 | .cwd(fixtures) 161 | .fork('long-run.js') 162 | .wait('stdout', /long run/) 163 | .kill() 164 | // .stdout(/recieve SIGTERM/) 165 | .notStdout(/exit long-run/); 166 | }); 167 | 168 | it('should ensure proc is kill if assert fail', async () => { 169 | await assert.rejects(async () => { 170 | await runner() 171 | .cwd(fixtures) 172 | .fork('long-run.js') 173 | .wait('stdout', /long run/) 174 | .tap(() => { 175 | throw new Error('fork trigger break'); 176 | }); 177 | }, /fork trigger break/); 178 | 179 | await assert.rejects(async () => { 180 | await runner() 181 | .cwd(fixtures) 182 | .spawn('node', [ 'long-run.js' ]) 183 | .wait('stdout', /long run/) 184 | .tap(() => { 185 | throw new Error('spawn trigger break'); 186 | }); 187 | }, /spawn trigger break/); 188 | }, 10000); 189 | }); 190 | -------------------------------------------------------------------------------- /test/prompt.test.js: -------------------------------------------------------------------------------- 1 | 2 | import { it, describe } from 'vitest'; 3 | import path from 'path'; 4 | import { strict as assert } from 'assert'; 5 | import { runner, KEYS } from '../lib/runner.js'; 6 | 7 | describe('test/prompt.test.js', () => { 8 | const fixtures = path.resolve('test/fixtures'); 9 | 10 | it('should work with readline', async () => { 11 | await runner() 12 | .cwd(fixtures) 13 | .fork('./readline.js') 14 | .stdin(/Name:/, 'tz') 15 | .stdin(/Email:/, 'tz@eggjs.com') 16 | .stdout(/Name:/) 17 | .stdout(/Email:/) 18 | .stdout(/Author: tz /); 19 | }); 20 | 21 | it('should work with prompt', async () => { 22 | await runner() 23 | .cwd(fixtures) 24 | .fork('./prompt.js') 25 | .stdin(/Name:/, 'tz') 26 | .stdin(/Email:/, 'tz@eggjs.com') 27 | .stdin(/Gender:/, [ KEYS.DOWN + KEYS.DOWN ]) 28 | .stdout(/Author: tz /) 29 | .stdout(/Gender: unknown/); 30 | }); 31 | 32 | it('should work with EOL', async () => { 33 | await runner() 34 | .cwd(fixtures) 35 | .fork('./prompt.js') 36 | .stdin(/Name:/, 'tz\n') 37 | .stdin(/Email:/, 'tz@eggjs.com\n') 38 | .stdin(/Gender:/, [ KEYS.DOWN + KEYS.DOWN + KEYS.ENTER ]) 39 | .stdout(/Author: tz /) 40 | .stdout(/Gender: unknown/); 41 | }); 42 | 43 | it('should support multi respond', async () => { 44 | await runner() 45 | .cwd(fixtures) 46 | .fork('./prompt.js') 47 | .stdin(/Name:/, [ 'tz', 'tz@eggjs.com', '' ]) 48 | .stdout(/Author: tz /) 49 | .stdout(/Gender: boy/); 50 | }); 51 | 52 | 53 | it('should throw when process exit before the prompt is resolve', async () => { 54 | await assert.rejects(async () => { 55 | await runner() 56 | .cwd(fixtures) 57 | .fork('./prompt.js') 58 | .env('throw', true) 59 | .stdin(/Name:/, 'tz\n') 60 | .stdin(/Email:/, 'tz@eggjs.com\n') 61 | .stdin(/Gender:/, '\n') 62 | .stdin(/Unknown:/, 'still wait\n') 63 | .stdout(/Author: tz /); 64 | }, /wait for prompt, but proccess is terminate/); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /test/runner.test.js: -------------------------------------------------------------------------------- 1 | import { it, describe, beforeEach } from 'vitest'; 2 | import path from 'path'; 3 | import { strict as assert } from 'assert'; 4 | import { runner } from '../lib/runner.js'; 5 | import * as utils from './test-utils.js'; 6 | 7 | describe('test/runner.test.js', () => { 8 | const fixtures = path.resolve('test/fixtures'); 9 | const tmpDir = utils.getTempDir(); 10 | 11 | beforeEach(() => utils.initDir(tmpDir)); 12 | 13 | it('should work with end()', async () => { 14 | const ctx = await runner() 15 | .cwd(fixtures) 16 | .spawn('node -v') 17 | .code(0) 18 | .end(); 19 | 20 | // ensure chain return instance context 21 | assert.equal(ctx.instance.constructor.name, 'TestRunner'); 22 | }); 23 | 24 | it('should work without end()', async () => { 25 | const ctx = await runner() 26 | .cwd(fixtures) 27 | .spawn('node -v'); 28 | 29 | // ensure chain return instance context 30 | assert.equal(ctx.instance.constructor.name, 'TestRunner'); 31 | }); 32 | 33 | it('should logger', async () => { 34 | await runner() 35 | .cwd(fixtures) 36 | .log('logger test start') 37 | .fork('logger.js') 38 | .stdout(/v\d+\.\d+\.\d+/) 39 | .log('logger test end'); 40 | }); 41 | 42 | it('should logger only error', async () => { 43 | // TODO: validate 44 | await runner() 45 | .cwd(fixtures) 46 | .debug() 47 | .debug('WARN') 48 | .log('logger test start') 49 | .fork('logger.js') 50 | .stdout(/v\d+\.\d+\.\d+/) 51 | .log('logger test end'); 52 | }); 53 | 54 | it('should support catch()', async () => { 55 | await runner() 56 | .spawn('node --no-exist-args') 57 | .catch(err => { 58 | assert.match(err.message, /bad option: --no-exist-args/); 59 | }); 60 | }); 61 | 62 | it('should export context', async () => { 63 | await runner() 64 | .cwd(fixtures) 65 | .env('a', 'b') 66 | .spawn('node -v') 67 | .tap(ctx => { 68 | ctx.assert(ctx.cwd === fixtures); 69 | ctx.assert(ctx.env.a === 'b'); 70 | ctx.assert(ctx.proc); 71 | ctx.assert(ctx.result); 72 | }); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import path from 'path'; 3 | 4 | export async function setup() { 5 | const p = path.join(process.cwd(), 'test/.tmp'); 6 | await fs.rm(p, { force: true, recursive: true }); 7 | } 8 | 9 | export async function teardown() { 10 | const p = path.join(process.cwd(), 'test/.tmp'); 11 | await fs.rm(p, { force: true, recursive: true }); 12 | } 13 | -------------------------------------------------------------------------------- /test/stack.test.js: -------------------------------------------------------------------------------- 1 | import { it, describe } from 'vitest'; 2 | 3 | describe('test/stack.test.js', () => { 4 | it.todo('error stack'); 5 | }); 6 | -------------------------------------------------------------------------------- /test/test-utils.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import crypto from 'crypto'; 3 | import { promises as fs } from 'fs'; 4 | export { strict as assert } from 'assert'; 5 | 6 | // export * from '../lib/utils.js'; 7 | 8 | // calc tmp dir by jest test file name 9 | export function getTempDir(...p) { 10 | return path.join(process.cwd(), 'test/.tmp', crypto.randomUUID(), ...p); 11 | } 12 | 13 | export async function initDir(p) { 14 | await fs.rm(p, { force: true, recursive: true }); 15 | await fs.mkdir(p, { recursive: true }); 16 | } 17 | -------------------------------------------------------------------------------- /test/utils.test.js: -------------------------------------------------------------------------------- 1 | import { it, describe, beforeEach } from 'vitest'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | import { strict as assert } from 'assert'; 5 | import * as utils from '../lib/utils.js'; 6 | import * as testUtils from './test-utils.js'; 7 | 8 | describe('test/utils.test.js', () => { 9 | const tmpDir = testUtils.getTempDir(); 10 | beforeEach(() => testUtils.initDir(tmpDir)); 11 | 12 | it('types', () => { 13 | assert(utils.types.isString('foo')); 14 | assert(utils.types.isObject({})); 15 | assert(utils.types.isFunction(() => {})); 16 | }); 17 | 18 | it('validate', () => { 19 | assert(utils.validate('foo', /fo+/)); 20 | assert(utils.validate('foo', 'o')); 21 | assert(utils.validate({ name: 'test', config: { port: 8080 } }, { config: { port: 8080 } })); 22 | assert(utils.validate('foo', x => x.startsWith('f'))); 23 | assert(utils.validate('foo', [ /fo+/, 'o' ])); 24 | }); 25 | 26 | it('isParent', () => { 27 | const cwd = process.cwd(); 28 | const file = path.join(cwd, 'index.js'); 29 | assert(utils.isParent(cwd, file)); 30 | assert(!utils.isParent(file, cwd)); 31 | assert(!utils.isParent(cwd, cwd)); 32 | }); 33 | 34 | it('mkdirp and rm', async () => { 35 | const targetDir = path.resolve(tmpDir, './a'); 36 | 37 | assert(!fs.existsSync(targetDir)); 38 | await utils.mkdir(targetDir); 39 | assert(fs.existsSync(targetDir)); 40 | 41 | await utils.rm(targetDir); 42 | assert(!fs.existsSync(targetDir)); 43 | }); 44 | 45 | it('writeFile', async () => { 46 | await utils.writeFile(`${tmpDir}/test.md`, 'this is a test'); 47 | assert(fs.readFileSync(`${tmpDir}/test.md`, 'utf-8') === 'this is a test'); 48 | 49 | await utils.writeFile(`${tmpDir}/test.json`, { name: 'test' }); 50 | assert(fs.readFileSync(`${tmpDir}/test.json`, 'utf-8').match(/"name": "test"/)); 51 | }); 52 | 53 | it('exists', async () => { 54 | assert(await utils.exists('package.json')); 55 | assert(!await utils.exists('not-exists-file')); 56 | }); 57 | 58 | it('resolve meta', async () => { 59 | const p = utils.resolve(import.meta, '../test', './fixtures'); 60 | const isExist = await utils.exists(p); 61 | assert(isExist); 62 | }); 63 | 64 | it('resolve', async () => { 65 | const p = utils.resolve('test', './fixtures'); 66 | assert(fs.existsSync(p)); 67 | }); 68 | 69 | it('sleep', async () => { 70 | const start = Date.now(); 71 | await utils.sleep(1000); 72 | assert(Date.now() - start >= 1000); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /test/wait.test.js: -------------------------------------------------------------------------------- 1 | import { it, describe } from 'vitest'; 2 | import path from 'path'; 3 | import { strict as assert } from 'assert'; 4 | import { runner } from '../lib/runner.js'; 5 | 6 | describe('test/wait.test.js', () => { 7 | const fixtures = path.resolve('test/fixtures'); 8 | const cliPath = path.join(fixtures, 'wait.js'); 9 | const timePlugin = { 10 | time(label = 'default') { 11 | return this.tap(() => { 12 | this.ctx.timeMapping = this.ctx.timeMapping || {}; 13 | this.ctx.timeMapping[label] = Date.now(); 14 | }); 15 | }, 16 | timeEnd(label, fn) { 17 | if (typeof label === 'function') { 18 | fn = label; 19 | label = 'default'; 20 | } 21 | return this.tap(() => { 22 | const start = this.ctx.timeMapping[label]; 23 | const now = Date.now(); 24 | const cost = now - start; 25 | fn(cost, start, now); 26 | }); 27 | }, 28 | }; 29 | 30 | it('should wait stdout', async () => { 31 | await runner() 32 | .register(timePlugin) 33 | .cwd(fixtures) 34 | .time() 35 | .fork(cliPath) 36 | .timeEnd(cost => assert(cost < 500, `Expected ${cost} < 500`)) 37 | .wait('stdout', /started/) 38 | .timeEnd(cost => assert(cost > 500, `Expected ${cost} > 500`)) 39 | .kill(); 40 | }); 41 | 42 | it('should wait stderr', async () => { 43 | await runner() 44 | .register(timePlugin) 45 | .cwd(fixtures) 46 | .time() 47 | .fork(cliPath) 48 | .timeEnd(cost => assert(cost < 500, `Expected ${cost} < 500`)) 49 | .wait('stderr', /be careful/) 50 | .timeEnd(cost => assert(cost > 500, `Expected ${cost} > 500`)) 51 | .kill(); 52 | }); 53 | 54 | it('should wait message with object', async () => { 55 | await runner() 56 | .register(timePlugin) 57 | .cwd(fixtures) 58 | .time() 59 | .fork(cliPath) 60 | .timeEnd(cost => assert(cost < 500)) 61 | .wait('message', { action: 'egg-ready' }) 62 | .timeEnd(cost => assert(cost > 500)) 63 | .kill(); 64 | }); 65 | 66 | it('should wait message with regex', async () => { 67 | await runner() 68 | .register(timePlugin) 69 | .cwd(fixtures) 70 | .time() 71 | .fork(cliPath) 72 | .timeEnd(cost => assert(cost < 500)) 73 | .wait('message', /egg-ready/) 74 | .timeEnd(cost => assert(cost > 500)) 75 | .kill(); 76 | }); 77 | 78 | it('should wait message with fn', async () => { 79 | await runner() 80 | .register(timePlugin) 81 | .cwd(fixtures) 82 | .time() 83 | .fork(cliPath) 84 | .timeEnd(cost => assert(cost < 500)) 85 | .wait('message', data => data && data.action === 'egg-ready') 86 | .timeEnd(cost => assert(cost > 500)) 87 | .kill(); 88 | }); 89 | 90 | it('should wait close', async () => { 91 | await runner() 92 | .cwd(fixtures) 93 | .fork(cliPath) 94 | .wait('close') 95 | .code(0); 96 | }); 97 | 98 | it('should wait close as default', async () => { 99 | await runner() 100 | .cwd(fixtures) 101 | .fork(cliPath) 102 | .wait() 103 | .code(0); 104 | }); 105 | 106 | it('should wait end if message is not emit', async () => { 107 | await runner() 108 | .cwd(fixtures) 109 | .fork(cliPath) 110 | .wait('message', /not-exist-event/) 111 | .code(0); 112 | }); 113 | 114 | it('should auto wait end without calling .wait()', async () => { 115 | await runner() 116 | .cwd(fixtures) 117 | .fork(cliPath) 118 | .code(0); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | // plugins: [ { 5 | // name: 'vitest-setup-plugin', 6 | // config: () => ({ 7 | // test: { 8 | // setupFiles: [ 9 | // './setupFiles/add-something-to-global.ts', 10 | // 'setupFiles/without-relative-path-prefix.ts', 11 | // ], 12 | // }, 13 | // }), 14 | // } ], 15 | test: { 16 | // globals: true, 17 | globalSetup: [ 18 | './test/setup.js', 19 | ], 20 | }, 21 | }); 22 | --------------------------------------------------------------------------------