├── .eslintrc.json ├── .github └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── CONTRIBUTING.md ├── README.md ├── clix.d.ts ├── examples └── js │ ├── bin.js │ ├── package-lock.json │ ├── package.json │ └── test.js ├── logo.png ├── package-lock.json ├── package.json ├── scripts └── examples.js ├── src ├── clix.js ├── constant.js ├── debug.js ├── errors.js ├── index.js ├── player.js ├── scenario-builder.js └── scenario-executor.js └── tests ├── functional ├── fixtures │ ├── exit-code-without-error-message.sh │ ├── return-error-with-code.sh │ ├── return-error.sh │ ├── simple-with-input.sh │ ├── simple.sh │ ├── timeout-error.sh │ └── timeout.sh ├── run.test.js └── timeout.test.js └── unit ├── acts ├── expect.spec.js ├── expectError.spec.js ├── input.spec.js └── withCode.spec.js ├── compare.spec.js ├── result.spec.js ├── validation.spec.js └── write.spec.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": ["eslint:recommended", "prettier"], 7 | "parserOptions": { 8 | "ecmaVersion": "latest", 9 | "sourceType": "module" 10 | }, 11 | "rules": { 12 | "indent": ["error", 2, {"SwitchCase": 1}], 13 | "linebreak-style": ["error", "unix"], 14 | "quotes": ["error", "single"], 15 | "semi": ["error", "always"] 16 | }, 17 | "globals": { 18 | "process": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | npm-publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v1 12 | - uses: actions/setup-node@v1 13 | with: 14 | node-version: 16 15 | registry-url: https://registry.npmjs.org/ 16 | - run: npm i 17 | - run: npm publish --access public 18 | env: 19 | NODE_AUTH_TOKEN: ${{secrets.NPM_AUTH_TOKEN}} -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | types: [assigned, opened, synchronize, reopened] 8 | 9 | jobs: 10 | test-linux: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: [16.x, 18.x] 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - run: npm i 22 | env: 23 | CI: true 24 | - run: npm run test:unit 25 | name: Run unit tests 26 | env: 27 | CI: true 28 | - run: npm run test:functional 29 | name: Run functional tests 30 | env: 31 | CI: true 32 | - run: npm run test:examples 33 | name: Run example tests 34 | env: 35 | CI: true 36 | 37 | test-windows: 38 | runs-on: windows-latest 39 | strategy: 40 | matrix: 41 | node-version: [16.x, 18.x] 42 | steps: 43 | - uses: actions/checkout@v2 44 | - name: Use Node.js ${{ matrix.node-version }} 45 | uses: actions/setup-node@v1 46 | with: 47 | node-version: ${{ matrix.node-version }} 48 | - run: npm i 49 | env: 50 | CI: true 51 | - run: npm run test:unit 52 | name: Run unit tests 53 | env: 54 | CI: true 55 | - run: npm run test:functional 56 | name: Run functional tests 57 | env: 58 | CI: true 59 | - run: npm run test:examples 60 | name: Run example tests 61 | env: 62 | CI: true 63 | 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | temp 3 | .nyc_output -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "semi": true, 4 | "singleQuote": true 5 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[javascript]": { 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "editor.formatOnSave": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution 2 | 3 | Hi 👋🏼 4 | 5 | Really happy to see you here ^^. 6 | 7 | This document aims to onboard you onto `clix` contributing. 8 | 9 | ## How submit a pull request? 10 | * First, you should check that your branch is based on a feature branch (`vX.X.X`), if there are no new feature branch available you should create them your self or ask me (gorez.tony@gmail.com). 11 | * Each feature should be covered with 12 | * unit tests 13 | * functional tests 14 | * The pull request should have a proper title and description explaining 15 | * Context/motivation 16 | * What you added? 17 | * What is the gain? 18 | * Before asking for reviews, please make sure your tests are passing in the CI 19 | * `README.md` or any documentation should have been updated if necessary 20 | 21 | ## Install the dependencies: 22 | 23 | ``` 24 | npm i 25 | ``` 26 | 27 | > ⚠️ Required Node.JS version: 16.X.X or greater. 28 | 29 | ## Tests 30 | 31 | ### Unit Tests 32 | 33 | What do you need to know about the unit tests ? 34 | 35 | * Technology: [tap](https://node-tap.org/) 36 | * Command: `npm test:unit` 37 | * Watch Command: `npm test:unit:watch` *(Recommended during development session.)* 38 | 39 | ### Functional Tests 40 | 41 | * Technology: [tap](https://node-tap.org/) 42 | * Command: `npm run test:functional` 43 | 44 | ### Run tests in CI 45 | 46 | Sometimes in your hacking journey you'll have to handle backward compatibility for Node.JS 16.x, 17.x and 18.x. 47 | 48 | So If you want to mimic the CI and run tests (unit or functional) in a previous version of node, you could use this template command: 49 | 50 | ```bash 51 | $ docker run --rm -it -v $PWD:$PWD --workdir $PWD 52 | ``` 53 | 54 | Where: 55 | - node-version: a docker image (like `node:12`, `node:14`...) 56 | - command: a npm command from the package.json (like `npm run test`) 57 | 58 | For example, if you want to run functional tests in Node.JS 16.x: 59 | 60 | ```bash 61 | $ docker run --rm -it -v $PWD:$PWD --workdir $PWD node:16 npm run test:functional 62 | ``` 63 | 64 | ## Debugging 65 | 66 | To see all the internal logs used in `clix`, set the DEBUG environment variable to restqa when launching your command. 67 | 68 | ``` 69 | DEBUG=1 70 | ``` 71 | 72 | ## Release 73 | 74 | We have a workflow in the CI responsible for packages releases (`.gihtub/workflows/publish.yml`). 75 | 76 | ### How to trigger a release ? 77 | 78 | You have to: 79 | * Bump the version in the `package.json` 80 | * Add a tag to the last commit. 81 | ```bash 82 | $ git tag 83 | ``` 84 | * Push the tag 85 | ```bash 86 | $ git push --tags 87 | ``` 88 | * Go to github repository interface and visit the release [page](https://github.com/tony-go/clix/releases) 89 | * Click on "Draft a new release button", then: 90 | * Choose the tag you just pushed 91 | * Write a title (the version) 92 | * Write a description in the proper way, please look at old release if you need a model. 93 | * Click on "Publish Release" button 94 | * You should find a new "publish" workflow in the [actions section](https://github.com/tony-go/clix/actions/workflows/publish.yml) 95 | 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > ⚠️: This package doesn't exist at the moment. It's only a spike made with a Readme driven development approach. 2 | 3 | > 👉: Share your feedback [in this issue](https://github.com/tony-go/clix/issues/1). 4 | 5 |

clix logo

6 |

clix

7 |

Write acceptance tests easily for your CLI program.

8 | 9 | ## ⭐️ Features 10 | 11 | - 🏎 CLI Runner out of the box 12 | - 🌈 Simple API 13 | - 🔄 Async/Await based 14 | - 🌝 Test runner agnostic 15 | 16 | ## 📦 Install 17 | 18 | ``` 19 | npm install -D @tonygo/clix 20 | ``` 21 | 22 | ## 🧰 Use clix 23 | 24 | Let's write your first acceptance tests 25 | 26 | ### Basic scenario 27 | ```js 28 | import assert from 'assert'; 29 | import clix from '@tonygo/clix'; 30 | 31 | const scenario_1 = clix('my command') 32 | .expect('Hey user, what is your name?') 33 | .input('tony') 34 | .expect('Super!', { timeout: 3000 }); 35 | 36 | const result_1 = await scenario_1.run(); 37 | assert.ok(result_1.ok); 38 | ``` 39 | 40 | ### Catch failure 41 | ```js 42 | const scenario_2 = clix('my command') 43 | .expect('Hey user, what is your name?') 44 | .input(223) 45 | .expectError('Sorry, dude!') 46 | .withCode(2); 47 | 48 | const result_2 = await scenario_2.run(); 49 | assert.ok(result_2.ok); 50 | ``` 51 | 52 | ### Handle list 53 | ```js 54 | const scenario_3 = clix('my command') 55 | .expect([ // Handle multiple lines with arrays 56 | 'What is your choice?', 57 | /a/, 58 | /b/ 59 | ]) 60 | .select([1]) // using an array for multiple choices 61 | .expect('Ok, dude!'); 62 | 63 | const result_2 = await scenario_3.run(); 64 | assert.ok(result_2.ok); 65 | ``` 66 | 67 | ## 🗺 Road map 68 | 69 | | Item | Status | Notes' | 70 | |-----------|-------------------|-----------------| 71 | | `.select` API | ABORTED | Findings here: https://github.com/tony-go/clix/issues/16 | 72 | | `.skip(numberOfLines)` PI | TODO | | 73 | 74 | ## 📖 API 75 | 76 | ### **scenario = clix(command: string, options: ClixOptions): Clix** 77 | 78 | Start a clix scenario with `clix('my command')`; 79 | 80 | Options: 81 | ```ts 82 | interface ClixOptions { 83 | timeout: number; 84 | } 85 | ``` 86 | 87 | ### **scenario.expect(line: string | Regexp | (string | Regexp)[], options?: ExpectOptions): Clix** 88 | 89 | Assert that the output line (stdout) is strictly equal and returns the Clix instance. 90 | 91 | ```ts 92 | interface ExpectOptions { 93 | timeout?: number; 94 | } 95 | ``` 96 | 97 | ### **scenario.expectError(errorMessage: string | Regexp | (string | Regexp)[], options?: ExpectErrorOptions): Clix** 98 | 99 | Assert that the output line (stderr) is strictly equal and returns the Clix instance. 100 | 101 | ```ts 102 | interface ExpectErrorOptions { 103 | code?: number; 104 | timeout?: number; 105 | } 106 | ``` 107 | 108 | ### **scenario.input(input: string): Clix** 109 | 110 | Emulate an interaction with the CLI and returns the Clix instance. 111 | 112 | ### **async scenario.run(): Promise** 113 | 114 | Will run the program and will assert all assertions registered before. 115 | 116 | The `ClixResult` object stand for: 117 | 118 | ```ts 119 | interface ClixResult { 120 | ok: boolean 121 | acts: { 122 | all: () => []Act 123 | failed: () => Act | null 124 | } 125 | } 126 | 127 | type ActType = 'expect' | 'expect-error' | 'exit-code' | 'input'; 128 | interface Act { 129 | type: ActType; 130 | val: Value; 131 | ok: boolean; 132 | actual?: Value; 133 | } 134 | ``` 135 | 136 | ## 💪🏼 Contributing 137 | 138 | Are interested in contributing? 😍 Please read the [contribution guide](./CONTRIBUTING.md) first. 139 | 140 | 141 | -------------------------------------------------------------------------------- /clix.d.ts: -------------------------------------------------------------------------------- 1 | type ActType = 'expect' | 'expect-error' | 'exit-code' | 'input'; 2 | 3 | interface Act { 4 | type: ActType; 5 | value: Value; 6 | ok: boolean; 7 | actual?: Value; 8 | options?: { timeout: number }; 9 | } 10 | 11 | interface ClixResult { 12 | ok: boolean; 13 | acts: { 14 | all: () => Array; 15 | failed: () => Act | null; 16 | }; 17 | } 18 | 19 | export interface Clix { 20 | expect: (value: string | Array) => Clix; 21 | expectError: (value: string | Array) => Clix; 22 | exitCode: (code: number) => Clix; 23 | input: (value: string | Array) => Clix; 24 | run: () => Promise; 25 | } 26 | 27 | declare function clix(command: string): Clix; 28 | 29 | export default clix; 30 | -------------------------------------------------------------------------------- /examples/js/bin.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import * as readline from 'readline'; 4 | 5 | const rl = readline.createInterface({ 6 | input: process.stdin, 7 | output: process.stdout, 8 | }); 9 | 10 | rl.question('What is your name?\n', (answer) => { 11 | console.log(`Hello, ${answer}!`); 12 | 13 | rl.close(); 14 | process.exit(0); 15 | }); 16 | -------------------------------------------------------------------------------- /examples/js/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "basic", 3 | "version": "1.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "basic", 9 | "version": "1.0.0", 10 | "license": "ISC", 11 | "bin": { 12 | "basic": "bin.js" 13 | }, 14 | "devDependencies": { 15 | "@tonygo/clix": "^0.0.1" 16 | } 17 | }, 18 | "node_modules/@tonygo/clix": { 19 | "version": "0.0.1", 20 | "resolved": "https://registry.npmjs.org/@tonygo/clix/-/clix-0.0.1.tgz", 21 | "integrity": "sha512-GicMKhEF2WHj03dg/xw+DTDNWOlQZjPqN3VEwCUZxVx+hndj5pHr5OyHqFjZe00jTkNujKgvpat4JAbawpTH8Q==", 22 | "dev": true, 23 | "dependencies": { 24 | "cross-spawn": "^7.0.3", 25 | "split2": "^4.1.0" 26 | } 27 | }, 28 | "node_modules/cross-spawn": { 29 | "version": "7.0.3", 30 | "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", 31 | "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", 32 | "dev": true, 33 | "dependencies": { 34 | "path-key": "^3.1.0", 35 | "shebang-command": "^2.0.0", 36 | "which": "^2.0.1" 37 | }, 38 | "engines": { 39 | "node": ">= 8" 40 | } 41 | }, 42 | "node_modules/isexe": { 43 | "version": "2.0.0", 44 | "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", 45 | "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", 46 | "dev": true 47 | }, 48 | "node_modules/path-key": { 49 | "version": "3.1.1", 50 | "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", 51 | "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", 52 | "dev": true, 53 | "engines": { 54 | "node": ">=8" 55 | } 56 | }, 57 | "node_modules/shebang-command": { 58 | "version": "2.0.0", 59 | "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", 60 | "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", 61 | "dev": true, 62 | "dependencies": { 63 | "shebang-regex": "^3.0.0" 64 | }, 65 | "engines": { 66 | "node": ">=8" 67 | } 68 | }, 69 | "node_modules/shebang-regex": { 70 | "version": "3.0.0", 71 | "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", 72 | "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", 73 | "dev": true, 74 | "engines": { 75 | "node": ">=8" 76 | } 77 | }, 78 | "node_modules/split2": { 79 | "version": "4.1.0", 80 | "resolved": "https://registry.npmjs.org/split2/-/split2-4.1.0.tgz", 81 | "integrity": "sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ==", 82 | "dev": true, 83 | "engines": { 84 | "node": ">= 10.x" 85 | } 86 | }, 87 | "node_modules/which": { 88 | "version": "2.0.2", 89 | "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", 90 | "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", 91 | "dev": true, 92 | "dependencies": { 93 | "isexe": "^2.0.0" 94 | }, 95 | "bin": { 96 | "node-which": "bin/node-which" 97 | }, 98 | "engines": { 99 | "node": ">= 8" 100 | } 101 | } 102 | }, 103 | "dependencies": { 104 | "@tonygo/clix": { 105 | "version": "0.0.1", 106 | "resolved": "https://registry.npmjs.org/@tonygo/clix/-/clix-0.0.1.tgz", 107 | "integrity": "sha512-GicMKhEF2WHj03dg/xw+DTDNWOlQZjPqN3VEwCUZxVx+hndj5pHr5OyHqFjZe00jTkNujKgvpat4JAbawpTH8Q==", 108 | "dev": true, 109 | "requires": { 110 | "cross-spawn": "^7.0.3", 111 | "split2": "^4.1.0" 112 | } 113 | }, 114 | "cross-spawn": { 115 | "version": "7.0.3", 116 | "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", 117 | "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", 118 | "dev": true, 119 | "requires": { 120 | "path-key": "^3.1.0", 121 | "shebang-command": "^2.0.0", 122 | "which": "^2.0.1" 123 | } 124 | }, 125 | "isexe": { 126 | "version": "2.0.0", 127 | "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", 128 | "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", 129 | "dev": true 130 | }, 131 | "path-key": { 132 | "version": "3.1.1", 133 | "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", 134 | "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", 135 | "dev": true 136 | }, 137 | "shebang-command": { 138 | "version": "2.0.0", 139 | "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", 140 | "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", 141 | "dev": true, 142 | "requires": { 143 | "shebang-regex": "^3.0.0" 144 | } 145 | }, 146 | "shebang-regex": { 147 | "version": "3.0.0", 148 | "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", 149 | "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", 150 | "dev": true 151 | }, 152 | "split2": { 153 | "version": "4.1.0", 154 | "resolved": "https://registry.npmjs.org/split2/-/split2-4.1.0.tgz", 155 | "integrity": "sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ==", 156 | "dev": true 157 | }, 158 | "which": { 159 | "version": "2.0.2", 160 | "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", 161 | "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", 162 | "dev": true, 163 | "requires": { 164 | "isexe": "^2.0.0" 165 | } 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /examples/js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "basic", 3 | "version": "1.0.0", 4 | "description": "", 5 | "type": "module", 6 | "bin": { 7 | "prog": ".bin" 8 | }, 9 | "scripts": { 10 | "test": "node test.js" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "ISC", 15 | "devDependencies": { 16 | "@tonygo/clix": "^0.0.1" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/js/test.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import path from 'path'; 3 | 4 | import clix from '../../src/index.js'; 5 | 6 | async function test() { 7 | const cwd = process.cwd(); 8 | const fullPath = path.join(cwd, 'examples', 'js', 'bin.js'); 9 | const scenario = clix('node' + ' ' + fullPath) 10 | .expect('What is your name?') 11 | .input('Joe') 12 | .expect('Hello, Joe!'); 13 | 14 | const { ok } = await scenario.run(); 15 | 16 | assert.ok(ok, 'Scenario should be ok'); 17 | } 18 | 19 | test() 20 | .then(() => console.log('Success')) 21 | .catch(console.error); 22 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tony-go/clix/493ef076c241cc795629421ae48cafd48c18909a/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tonygo/clix", 3 | "version": "0.0.4", 4 | "description": "Write acceptance tests easily for your CLI program.", 5 | "main": "src/index.js", 6 | "type": "module", 7 | "types": "clix.d.ts", 8 | "scripts": { 9 | "test:unit": "cross-env tap --no-coverage 'tests/unit/**/*.spec.js'", 10 | "test:unit:watch": "cross-env tap --watch --no-coverage-report 'tests/unit/**/*.spec.js'", 11 | "test:functional": "cross-env tap --no-coverage 'tests/functional/**/*.test.js'", 12 | "test:examples": "node scripts/examples.js", 13 | "test:all": "cross-env tap --no-coverage 'tests/**/*.js'", 14 | "lint": "eslint ./src" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/tony-go/clix.git" 19 | }, 20 | "keywords": [ 21 | "cli", 22 | "tests", 23 | "acceptance", 24 | "tests", 25 | "cli" 26 | ], 27 | "author": "Tony Gorez ", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/tony-go/clix/issues" 31 | }, 32 | "homepage": "https://github.com/tony-go/clix#readme", 33 | "devDependencies": { 34 | "cross-env": "^7.0.3", 35 | "eslint": "^8.9.0", 36 | "eslint-config-prettier": "^8.3.0", 37 | "prettier": "^2.5.1", 38 | "tap": "^16.0.1", 39 | "tinyspy": "^0.3.0" 40 | }, 41 | "dependencies": { 42 | "cross-spawn": "^7.0.3", 43 | "split2": "^4.1.0" 44 | }, 45 | "optionalDependencies": { 46 | "ntsuspend": "^1.0.2" 47 | }, 48 | "tap": { 49 | "check-coverage": false 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /scripts/examples.js: -------------------------------------------------------------------------------- 1 | function executeJsExamples() { 2 | import('../examples/js/test.js'); 3 | } 4 | 5 | executeJsExamples(); 6 | -------------------------------------------------------------------------------- /src/clix.js: -------------------------------------------------------------------------------- 1 | import { ScenarioBuilder } from './scenario-builder.js'; 2 | 3 | export function clix(command) { 4 | if (typeof command !== 'string') { 5 | throw new Error(`Command should be a string but got ${command}`); 6 | } 7 | 8 | if (command.length < 1) { 9 | throw new Error('Command should not be an empty string'); 10 | } 11 | 12 | return new ScenarioBuilder().withCommand(command); 13 | } 14 | -------------------------------------------------------------------------------- /src/constant.js: -------------------------------------------------------------------------------- 1 | export const kActType = { 2 | input: 'input', 3 | expect: 'expect', 4 | expectError: 'expect-error', 5 | exitCode: 'exit-code', 6 | }; 7 | -------------------------------------------------------------------------------- /src/debug.js: -------------------------------------------------------------------------------- 1 | // todo(tony): make something cleaner (pino) 2 | class Debug { 3 | constructor(command) { 4 | this.command = command; 5 | } 6 | 7 | debug(...message) { 8 | if (process.env.DEBUG) { 9 | console.log(this.command, message); 10 | } 11 | } 12 | } 13 | 14 | export { Debug }; 15 | -------------------------------------------------------------------------------- /src/errors.js: -------------------------------------------------------------------------------- 1 | export class TimeoutError extends Error { 2 | constructor(message) { 3 | super(message); 4 | 5 | this.name = this.constructor.name; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { clix } from './clix.js'; 2 | 3 | export { ScenarioExecutor } from './scenario-executor.js'; 4 | 5 | export default clix; 6 | -------------------------------------------------------------------------------- /src/player.js: -------------------------------------------------------------------------------- 1 | import spawn from 'cross-spawn'; 2 | import splitByLine from 'split2'; 3 | import { createRequire } from 'module'; 4 | 5 | export class Player { 6 | /** 7 | * @type {ChildProcess} 8 | */ 9 | #proc = null; 10 | 11 | /** 12 | * @type {object} 13 | * @type {object.resolve} function to resolve the promise 14 | * @type {object.reject} function to reject the promise 15 | * @type {object.handler} function to handle data 16 | */ 17 | #context = null; 18 | 19 | /** 20 | * **Windows ONLY** 21 | * 22 | * @description Suspends a process given its ID. 23 | * @param pid - ID of the process to suspend. 24 | * @returns `true` if it succeeds or `false` if it fails. 25 | */ 26 | #pauseProcess = null; 27 | 28 | /** 29 | * **Windows ONLY** 30 | * 31 | * @description Resume a process given its ID. 32 | * @param pid - ID of the process to resume. 33 | * @returns `true` if it succeeds or `false` if it fails. 34 | */ 35 | #continueProcess = null; 36 | 37 | /** 38 | * @param {void} 39 | * @return {void} 40 | * @description Stop the player (kill the process) 41 | */ 42 | stop() { 43 | this.#proc.kill('SIGINT'); 44 | } 45 | 46 | /** 47 | * @param {object} context 48 | * @description Set the context of the player 49 | */ 50 | setContext(context) { 51 | this.#context = context; 52 | } 53 | 54 | #setWindowsControls() { 55 | if (/^win/.test(process.platform)) { 56 | const { suspend, resume } = createRequire(import.meta.url)('ntsuspend'); 57 | this.#pauseProcess = suspend; 58 | this.#continueProcess = resume; 59 | } 60 | } 61 | 62 | /** 63 | * @param {string} command - The command to execute 64 | * @returns {void} 65 | * @description Start the player (spawn the process) 66 | */ 67 | start(command) { 68 | this.#setWindowsControls(); 69 | 70 | // TODO(tony): check this.#context is not null 71 | const proc = spawn(command, { shell: true }); 72 | const { handler, exitHandler, ...context } = this.#context; 73 | 74 | proc.on('spawn', () => { 75 | this.#proc = proc; 76 | }); 77 | 78 | proc.on('exit', (code) => { 79 | exitHandler(code, { ...context, isError: code !== 0 }); 80 | }); 81 | 82 | proc.on('error', (line) => { 83 | this.#pause(); 84 | handler(line, { ...context, isError: true }); 85 | }); 86 | 87 | proc.stdout.pipe(splitByLine()).on('data', (line) => { 88 | this.#pause(); 89 | handler(line, { ...context, isError: false }); 90 | }); 91 | 92 | proc.stderr.pipe(splitByLine()).on('data', (line) => { 93 | this.#pause(); 94 | handler(line, { ...context, isError: true }); 95 | }); 96 | } 97 | 98 | /** 99 | * @param {string} input - The input to send to the player 100 | * @returns {void} 101 | * @description Simulate the player input (write in stdin) 102 | */ 103 | write(input) { 104 | if (!this.#proc) { 105 | throw 'Process was not spawned'; 106 | } 107 | 108 | this.#proc.stdin.setEncoding('utf-8'); 109 | this.#proc.stdin.write(input); 110 | this.#proc.stdin.end(); 111 | } 112 | 113 | /** 114 | * @description Pause the player (child process) 115 | * @returns {void} 116 | */ 117 | #pause() { 118 | if (this.#pauseProcess) { 119 | this.#pauseProcess(this.#proc.pid); 120 | } else { 121 | this.#proc.kill('SIGSTOP'); 122 | } 123 | } 124 | 125 | /** 126 | * @description Resume the player (child process) 127 | * @returns {void} 128 | */ 129 | continue() { 130 | if (this.#continueProcess) { 131 | this.#continueProcess(this.#proc.pid); 132 | } else { 133 | this.#proc.kill('SIGCONT'); 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/scenario-builder.js: -------------------------------------------------------------------------------- 1 | import { kActType } from './constant.js'; 2 | import { Player } from './player.js'; 3 | import { ScenarioExecutor } from './scenario-executor.js'; 4 | 5 | export class ScenarioBuilder { 6 | #acts = []; 7 | #player = null; 8 | #command = null; 9 | 10 | async run() { 11 | return this.build().run(); 12 | } 13 | 14 | withCommand(command) { 15 | this.#command = command; 16 | return this; 17 | } 18 | 19 | withPlayer(player) { 20 | this.#player = player; 21 | return this; 22 | } 23 | 24 | /** 25 | * Allow user to declare an expected output 26 | * @param {string} value 27 | * @param {Array} value 28 | * @returns {ScenarioBuilder} 29 | */ 30 | expect(values, options = {}) { 31 | if (typeof values === 'string') { 32 | this.#acts.push({ value: values, type: kActType.expect, options }); 33 | return this; 34 | } 35 | 36 | if (!Array.isArray(values)) { 37 | throw Error('Expect value should be a string or an array'); 38 | } 39 | 40 | values.forEach((value) => 41 | this.#acts.push({ value, type: kActType.expect }) 42 | ); 43 | 44 | return this; 45 | } 46 | 47 | /** 48 | * Allows to simulate a user input 49 | * @param {String} value 50 | * @param {Array} value 51 | * @returns {ScenarioBuilder} 52 | */ 53 | input(values) { 54 | if (typeof values === 'string') { 55 | this.#acts.push({ value: values, type: kActType.input }); 56 | return this; 57 | } 58 | 59 | if (!Array.isArray(values)) { 60 | throw Error('Input value should be a string or an array'); 61 | } 62 | 63 | values.forEach((value) => this.#acts.push({ value, type: kActType.input })); 64 | 65 | return this; 66 | } 67 | 68 | /** 69 | * Allow user to declare an expected error 70 | * @param {String} values 71 | * @param {Array} values 72 | * @returns {ScenarioBuilder} 73 | */ 74 | expectError(values, options = {}) { 75 | if (typeof values === 'string') { 76 | this.#acts.push({ value: values, type: kActType.expectError, options }); 77 | return this; 78 | } 79 | 80 | if (!Array.isArray(values)) { 81 | throw Error('ExpectError value should be a string or an array'); 82 | } 83 | 84 | values.forEach((value) => 85 | this.#acts.push({ value, type: kActType.expectError }) 86 | ); 87 | 88 | return this; 89 | } 90 | 91 | /** 92 | * Allow user to declare an expected exit code 93 | * @param {Number} code 94 | * @returns {ScenarioBuilder} 95 | */ 96 | withCode(code) { 97 | this.#acts.push({ value: code, type: kActType.exitCode }); 98 | return this; 99 | } 100 | 101 | build() { 102 | return new ScenarioExecutor( 103 | this.#command, 104 | this.#acts ?? [], 105 | this.#player ?? new Player() 106 | ); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/scenario-executor.js: -------------------------------------------------------------------------------- 1 | // internal dependencies 2 | import { Debug } from './debug.js'; 3 | import { kActType } from './constant.js'; 4 | import { Player } from './player.js'; 5 | import { TimeoutError } from './errors.js'; 6 | 7 | // constants 8 | const kDefaultTimeout = 500; 9 | 10 | export class ScenarioExecutor extends Debug { 11 | /** 12 | * @type {string} 13 | * @description the command to run 14 | */ 15 | #command; 16 | 17 | /** 18 | * @type {Player} 19 | * @description current player running the command 20 | */ 21 | #player; 22 | 23 | /** 24 | * @type {number} 25 | * @description default timeout 26 | */ 27 | #defaultTimeout = kDefaultTimeout; 28 | 29 | /** 30 | * @type {number} 31 | * @description index of the current act 32 | */ 33 | #actPointer = 0; 34 | 35 | /** 36 | * @type {Timeout} 37 | * @description global timer to handle timeout 38 | */ 39 | #timer = null; 40 | 41 | constructor(command, acts = [], player = new Player()) { 42 | super(command); 43 | this.#command = command; 44 | this.acts = acts; 45 | this.#player = player; 46 | } 47 | 48 | /** 49 | * //////////////////////// 50 | * // PUBLIC API METHODS // 51 | * //////////////////////// 52 | */ 53 | 54 | /** 55 | * Allows user to execute the scenario 56 | * @returns {Promise} 57 | */ 58 | async run() { 59 | await this.#play(); 60 | return this._buildResult(); 61 | } 62 | 63 | /** 64 | * //////////////////////// 65 | * // RESTRICTED METHODS // 66 | * //////////////////////// 67 | * (generally available for testing purpose) 68 | */ 69 | 70 | /** 71 | * @typedef ClixResult 72 | * @type {object} 73 | * @property {boolean} ok - true if all acts are ok 74 | * @property {object} acts 75 | * @property {Function} acts.all - will return all acts 76 | * @property {Function} acts.failed - will return the last failed acts 77 | * 78 | * @returns {ClixResult} 79 | */ 80 | _buildResult() { 81 | return { 82 | ok: this.acts.every((act) => act.ok), 83 | acts: { 84 | all: () => this.acts, 85 | failed: () => this.#findFailedAct() || null, 86 | }, 87 | }; 88 | } 89 | 90 | /** 91 | * Will compare the current buffer with the current act 92 | * and enrich current act with the result 93 | * 94 | * @param {object} currentAct - current act 95 | * @param {string} expectedValue - output from console 96 | */ 97 | _compare(currentAct, expectedValue) { 98 | const areValuesEqual = currentAct.value === expectedValue; 99 | currentAct.ok = areValuesEqual ? true : false; 100 | currentAct.actual = expectedValue; 101 | this.debug('equal', expectedValue, currentAct.value); 102 | } 103 | 104 | /** 105 | * Write user input in the process 106 | * @param {*} rawInput - input to write in the process 107 | */ 108 | _writeInProc(rawInput) { 109 | this.debug(this.#command, `write input: ${rawInput}`); 110 | this.#player.write(this._formatInput(rawInput)); 111 | } 112 | 113 | /** 114 | * Format the input to be written in the process 115 | * @param {string} input - input to write in the process 116 | */ 117 | _formatInput(input) { 118 | return input.includes('\n') ? input : input + '\n'; 119 | } 120 | 121 | /** 122 | * ///////////////////// 123 | * // PRIVATE METHODS // 124 | * ///////////////////// 125 | */ 126 | 127 | /** 128 | * Find the first failed act from this.acts 129 | * @returns {Act} first failed act 130 | */ 131 | #findFailedAct() { 132 | return this.acts.find((act) => act.ok === false); 133 | } 134 | 135 | #next() { 136 | this.#actPointer++; 137 | } 138 | 139 | #currentAct() { 140 | return this.acts.at(this.#actPointer); 141 | } 142 | 143 | #resetTimer() { 144 | clearTimeout(this.#timer); 145 | } 146 | 147 | async #play() { 148 | return new Promise((resolve, reject) => { 149 | const context = { 150 | resolve, 151 | reject, 152 | handler: this.#handleData.bind(this), 153 | exitHandler: this.#handleExit.bind(this), 154 | }; 155 | 156 | this.#player.setContext(context); 157 | 158 | this.#startTimer(resolve, reject); 159 | 160 | this.#player.start(this.#command); 161 | }); 162 | } 163 | 164 | #fillNextInputActs() { 165 | const currentAct = this.#currentAct(); 166 | if (!currentAct || currentAct.type !== kActType.input) { 167 | return; 168 | } 169 | 170 | this._writeInProc(currentAct.value); 171 | currentAct.ok = true; 172 | 173 | this.#next(); 174 | this.#fillNextInputActs(); 175 | } 176 | 177 | #startTimer(resolve, reject) { 178 | const currentAct = this.#currentAct(); 179 | if (!currentAct) { 180 | resolve(); 181 | return; 182 | } 183 | 184 | const timeout = currentAct.options?.timeout; 185 | 186 | if (timeout) { 187 | this.debug(this.#command, `act timeout set to ${timeout}`); 188 | this.#timer = setTimeout(() => { 189 | reject( 190 | new TimeoutError( 191 | 'The act did not take place within the allotted time' 192 | ) 193 | ); 194 | }, timeout); 195 | } else { 196 | this.#timer = setTimeout(resolve, this.#defaultTimeout); 197 | } 198 | } 199 | 200 | #handleData(data, { resolve, reject, isError }) { 201 | this.#resetTimer(); 202 | this.debug(this.#command, `${isError ? 'error' : 'data'} : ${data}`); 203 | 204 | const currentAct = this.#currentAct(); 205 | if (!currentAct) { 206 | this.#player.stop(); 207 | isError ? reject(new Error(data)) : resolve(); 208 | return; 209 | } 210 | 211 | if (isError && currentAct.type === kActType.expect) { 212 | this.#player.stop(); 213 | reject(new Error(data)); 214 | return; 215 | } 216 | 217 | this._compare(currentAct, data); 218 | this.#next(); 219 | this.#player.continue(); 220 | 221 | this.#fillNextInputActs(); 222 | this.#startTimer(resolve, reject); 223 | } 224 | 225 | #handleExit(code, { resolve, reject, isError }) { 226 | this.#resetTimer(); 227 | this.debug(this.#command, `exit with code ${code}`); 228 | 229 | const act = this.acts.at(-1); 230 | 231 | if (act.type !== kActType.exitCode) { 232 | if (isError) { 233 | reject(new Error(`Process terminated with exit code ${code}`)); 234 | } 235 | 236 | return; 237 | } 238 | 239 | this._compare(act, code); 240 | resolve(); 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /tests/functional/fixtures/exit-code-without-error-message.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo Hello, who am I talking to? 3 | read name 4 | echo $name 5 | exit 2 -------------------------------------------------------------------------------- /tests/functional/fixtures/return-error-with-code.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo Hello, who am I talking to? 3 | read name 4 | echo error >&2 5 | exit 2 -------------------------------------------------------------------------------- /tests/functional/fixtures/return-error.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo Hello, who am I talking to? 3 | read name 4 | echo error >&2 -------------------------------------------------------------------------------- /tests/functional/fixtures/simple-with-input.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo Hello, who am I talking to? 3 | read name 4 | echo Hey $name! 5 | -------------------------------------------------------------------------------- /tests/functional/fixtures/simple.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Ask the user for their name 3 | echo Hello, who am I talking to? 4 | -------------------------------------------------------------------------------- /tests/functional/fixtures/timeout-error.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo Hello, who am I talking to? 4 | read name 5 | sleep 2 6 | echo Hey $name! >&2 7 | -------------------------------------------------------------------------------- /tests/functional/fixtures/timeout.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo Hello, who am I talking to? 4 | read name 5 | sleep 2 6 | echo Hey $name! 7 | -------------------------------------------------------------------------------- /tests/functional/run.test.js: -------------------------------------------------------------------------------- 1 | // node.js dependencies 2 | import cp from 'child_process'; 3 | 4 | // third-party dependencies 5 | import { test } from 'tap'; 6 | import { spyOn } from 'tinyspy'; 7 | 8 | // internal dependencies 9 | import clix from '../../src/index.js'; 10 | 11 | // constants 12 | const kSimpleOutputCommand = 'bash ./tests/functional/fixtures/simple.sh'; 13 | const kSimpleCommandWithOutput = 14 | 'bash ./tests/functional/fixtures/simple-with-input.sh'; 15 | const kReturnError = 'bash ./tests/functional/fixtures/return-error.sh'; 16 | const kReturnErrorWithCode = 17 | 'bash ./tests/functional/fixtures/return-error-with-code.sh'; 18 | 19 | test('it should expose a run method', (t) => { 20 | const scenario = clix(kSimpleOutputCommand); 21 | 22 | t.ok(scenario.run); 23 | t.end(); 24 | }); 25 | 26 | test('it should spawn a process when run is called', async (t) => { 27 | const spawnSpy = spyOn(cp, 'spawn'); 28 | const scenario = clix(kSimpleOutputCommand).expect( 29 | 'Hello, who am I talking to?' 30 | ); 31 | 32 | await scenario.run(); 33 | 34 | t.ok(spawnSpy.called); 35 | t.end(); 36 | }); 37 | 38 | test('it should assert the expect value passed', async (t) => { 39 | const expectedValue = 'Hello, who am I talking to?'; 40 | const scenario = clix(kSimpleOutputCommand).expect(expectedValue); 41 | 42 | const { ok, acts } = await scenario.run(); 43 | 44 | t.ok(ok); 45 | t.same(acts.all(), [ 46 | { 47 | value: expectedValue, 48 | type: 'expect', 49 | ok: true, 50 | actual: expectedValue, 51 | options: {}, 52 | }, 53 | ]); 54 | t.end(); 55 | }); 56 | 57 | test('it should write input value passed', async (t) => { 58 | const name = 'tony'; 59 | const scenario = clix(kSimpleCommandWithOutput) 60 | .expect('Hello, who am I talking to?') 61 | .input(name) 62 | .expect(`Hey ${name}!`); 63 | 64 | const { ok, acts } = await scenario.run(); 65 | 66 | t.ok(ok); 67 | t.ok(acts.all().every((act) => act.ok)); 68 | t.end(); 69 | }); 70 | 71 | test('it should assert error message', async (t) => { 72 | const scenario = clix(kReturnError) 73 | .expect('Hello, who am I talking to?') 74 | .input('tony') 75 | .expectError('error'); 76 | 77 | const { ok, acts } = await scenario.run(); 78 | 79 | t.ok(ok); 80 | t.ok(acts.all().every((act) => act.ok)); 81 | t.end(); 82 | }); 83 | 84 | test('it should assert error message and the error message', async (t) => { 85 | const scenario = clix(kReturnErrorWithCode) 86 | .expect('Hello, who am I talking to?') 87 | .input('tony') 88 | .expectError('error') 89 | .withCode(2); 90 | 91 | const { ok, acts } = await scenario.run(); 92 | 93 | t.ok(ok); 94 | t.ok(acts.all().every((act) => act.ok)); 95 | t.end(); 96 | }); 97 | 98 | test('it should assert exit code without error message', async (t) => { 99 | const kExitCodeWithoutErrorMessage = 100 | 'bash ./tests/functional/fixtures/exit-code-without-error-message.sh'; 101 | const fakeInput = 'tony'; 102 | const scenario = clix(kExitCodeWithoutErrorMessage) 103 | .expect('Hello, who am I talking to?') 104 | .input(fakeInput) 105 | .expect(fakeInput) 106 | .withCode(2); 107 | 108 | const { ok, acts } = await scenario.run(); 109 | 110 | t.ok(ok); 111 | t.ok(acts.all().every((act) => act.ok)); 112 | t.end(); 113 | }); 114 | 115 | test('.run should append actual value in each act object', async (t) => { 116 | const scenario = clix(kReturnErrorWithCode) 117 | .expect('Hello, who am I talking to?') 118 | .input('tony') 119 | .expectError('error') 120 | .withCode(2); 121 | 122 | const { acts } = await scenario.run(); 123 | const allActs = acts.all(); 124 | 125 | const allActsHaveActualProperty = allActs 126 | .filter(isNotInputAct) 127 | .every(shouldHaveAnActualProperty); 128 | t.ok(allActsHaveActualProperty); 129 | t.end(); 130 | }); 131 | 132 | test('.run should throw error when a act fail but was not expected to fail', async (t) => { 133 | const scenario = clix('unknown command').expect('should throw'); 134 | 135 | try { 136 | await scenario.run(); 137 | t.ok(false); 138 | } catch (e) { 139 | t.ok(e.message); 140 | } 141 | 142 | t.end(); 143 | }); 144 | 145 | /** 146 | * HELPERS 147 | */ 148 | 149 | const isNotInputAct = (act) => act.type !== 'input'; 150 | const shouldHaveAnActualProperty = (act) => act.actual !== undefined; 151 | -------------------------------------------------------------------------------- /tests/functional/timeout.test.js: -------------------------------------------------------------------------------- 1 | import { test } from 'tap'; 2 | 3 | import clix from '../../src/index.js'; 4 | 5 | test('.run should succeed if expect timeout are not elapsed', async (t) => { 6 | const kCustomTimeoutCommand = 'bash ./tests/functional/fixtures/timeout.sh'; 7 | const name = 'hali'; 8 | const scenario = clix(kCustomTimeoutCommand) 9 | .expect('Hello, who am I talking to?') 10 | .input(name) 11 | .expect(`Hey ${name}!`, { timeout: 3_000 }); 12 | 13 | const res = await scenario.run(); 14 | 15 | t.ok(res.ok); 16 | t.end(); 17 | }); 18 | 19 | test('.run rejects failed if expect timeout are elapsed', async (t) => { 20 | const kCustomTimeoutCommand = 'bash ./tests/functional/fixtures/timeout.sh'; 21 | const name = 'hali'; 22 | const scenario = clix(kCustomTimeoutCommand) 23 | .expect('Hello, who am I talking to?') 24 | .input(name) 25 | .expect(`Hey ${name}!`, { timeout: 1_000 }); 26 | 27 | await t.rejects(scenario.run()); 28 | t.end(); 29 | }); 30 | 31 | test('.run should succeed if expectError timeout are not elapsed', async (t) => { 32 | const kCustomTimeoutCommand = 33 | 'bash ./tests/functional/fixtures/timeout-error.sh'; 34 | const name = 'hali'; 35 | const scenario = clix(kCustomTimeoutCommand) 36 | .expect('Hello, who am I talking to?') 37 | .input(name) 38 | .expectError('Hey ' + name + '!', { timeout: 3_000 }); 39 | 40 | const res = await scenario.run(); 41 | 42 | t.ok(res.ok); 43 | t.end(); 44 | }); 45 | 46 | test('.run should rejects if expectError timeout are elapsed', async (t) => { 47 | const kCustomTimeoutCommand = 48 | 'bash ./tests/functional/fixtures/timeout-error.sh'; 49 | const name = 'hali'; 50 | const scenario = clix(kCustomTimeoutCommand) 51 | .expect('Hello, who am I talking to?') 52 | .input(name) 53 | .expectError('Hey ' + name + '!', { timeout: 1_000 }); 54 | 55 | await t.rejects(scenario.run()); 56 | t.end(); 57 | }); 58 | -------------------------------------------------------------------------------- /tests/unit/acts/expect.spec.js: -------------------------------------------------------------------------------- 1 | import { test } from 'tap'; 2 | 3 | import clix from '../../../src/index.js'; 4 | import { kActType } from '../../../src/constant.js'; 5 | import { Player } from '../../../src/player.js'; 6 | import { ScenarioBuilder } from '../../../src/scenario-builder.js'; 7 | 8 | test('it should expose an expect function to add an expected value', (t) => { 9 | const scenarioBuilder = clix('foo bar'); 10 | const expectedValue = 'hey'; 11 | 12 | scenarioBuilder.expect(expectedValue); 13 | const scenario = scenarioBuilder.build(); 14 | 15 | t.same(scenario.acts, [ 16 | { value: expectedValue, type: kActType.expect, options: {} }, 17 | ]); 18 | t.end(); 19 | }); 20 | 21 | test('it should expose an expect function to add an several expected Values', (t) => { 22 | const scenarioBuilder = clix('foo bar'); 23 | const valueA = 'hey'; 24 | const valueB = 'yo'; 25 | 26 | scenarioBuilder.expect([valueA, valueB]); 27 | const scenario = scenarioBuilder.build(); 28 | 29 | t.same(scenario.acts, [ 30 | { value: valueA, type: kActType.expect }, 31 | { value: valueB, type: kActType.expect }, 32 | ]); 33 | t.end(); 34 | }); 35 | 36 | test('it should allow chaining with expect method', (t) => { 37 | const scenarioBuilder = clix('foo bar').expect('yo').expect('foo'); 38 | 39 | t.ok(scenarioBuilder instanceof ScenarioBuilder); 40 | t.end(); 41 | }); 42 | 43 | test('it should handle a timeout option', (t) => { 44 | const scenario = clix('foo').expect('bar', { timeout: 3000 }).build(); 45 | 46 | t.same(scenario.acts, [ 47 | { value: 'bar', type: kActType.expect, options: { timeout: 3000 } }, 48 | ]); 49 | 50 | t.end(); 51 | }); 52 | 53 | test('it should throw a timeout error when time is out', async (t) => { 54 | class PlayerStub extends Player { 55 | start() {} 56 | continue() {} 57 | } 58 | 59 | const player = new PlayerStub(); 60 | const scenarioBuilder = new ScenarioBuilder() 61 | .withCommand('foo') 62 | .withPlayer(player) 63 | .expect('bar', { timeout: 5 }); 64 | 65 | try { 66 | await scenarioBuilder.run(); 67 | t.ok(false); 68 | } catch (e) { 69 | t.ok(e.message); 70 | } 71 | 72 | t.end(); 73 | }); 74 | 75 | test('it should not throw the timeout error', async (t) => { 76 | class PlayerStub extends Player { 77 | #context = null; 78 | 79 | setContext(context) { 80 | this.#context = context; 81 | } 82 | 83 | start() { 84 | this.#context.handler('bar', { ...this.#context, isError: false }); 85 | } 86 | 87 | continue() {} 88 | } 89 | 90 | const player = new PlayerStub(); 91 | const scenarioBuilder = new ScenarioBuilder() 92 | .withCommand('foo') 93 | .withPlayer(player) 94 | .expect('bar', { timeout: 5 }); 95 | 96 | await scenarioBuilder.run(); 97 | 98 | t.end(); 99 | }); 100 | -------------------------------------------------------------------------------- /tests/unit/acts/expectError.spec.js: -------------------------------------------------------------------------------- 1 | import { test } from 'tap'; 2 | 3 | import clix from '../../../src/index.js'; 4 | import { kActType } from '../../../src/constant.js'; 5 | import { Player } from '../../../src/player.js'; 6 | import { ScenarioBuilder } from '../../../src/scenario-builder.js'; 7 | 8 | test('it expectError function could add expected errors', (t) => { 9 | const scenarioBuilder = clix('foo bar'); 10 | 11 | const error = 'error'; 12 | scenarioBuilder.expectError(error); 13 | const scenario = scenarioBuilder.build(); 14 | 15 | t.same(scenario.acts, [ 16 | { 17 | value: error, 18 | type: kActType.expectError, 19 | options: {}, 20 | }, 21 | ]); 22 | t.end(); 23 | }); 24 | 25 | test('it expectError could take an array of string', (t) => { 26 | const scenarioBuilder = clix('foo bar'); 27 | const errorA = 'boom'; 28 | const errorB = 'paf'; 29 | 30 | scenarioBuilder.expectError([errorA, errorB]); 31 | const scenario = scenarioBuilder.build(); 32 | 33 | t.same(scenario.acts, [ 34 | { value: errorA, type: kActType.expectError }, 35 | { value: errorB, type: kActType.expectError }, 36 | ]); 37 | t.end(); 38 | }); 39 | 40 | test('it should allow chaining with input method', (t) => { 41 | const scenarioBuilder = clix('foo bar').expectError('yo').expectError('foo'); 42 | 43 | t.ok(scenarioBuilder instanceof ScenarioBuilder); 44 | t.end(); 45 | }); 46 | 47 | test('it should handle a timeout option', (t) => { 48 | const scenarioBuilder = clix('foo').expectError('bar', { timeout: 3000 }); 49 | const scenario = scenarioBuilder.build(); 50 | 51 | t.same(scenario.acts, [ 52 | { value: 'bar', type: kActType.expectError, options: { timeout: 3000 } }, 53 | ]); 54 | 55 | t.end(); 56 | }); 57 | 58 | test('it should throw a timeout error when time is out', async (t) => { 59 | class PlayerStub extends Player { 60 | start() {} 61 | continue() {} 62 | } 63 | 64 | const player = new PlayerStub(); 65 | const scenarioBuilder = new ScenarioBuilder() 66 | .withCommand('foo') 67 | .withPlayer(player) 68 | .expectError('bar', { timeout: 5 }); 69 | 70 | try { 71 | await scenarioBuilder.run(); 72 | t.ok(false); 73 | } catch (e) { 74 | t.ok(e.message); 75 | } 76 | 77 | t.end(); 78 | }); 79 | 80 | test('it should not throw the timeout error', async (t) => { 81 | class PlayerStub extends Player { 82 | #context = null; 83 | 84 | setContext(context) { 85 | this.#context = context; 86 | } 87 | 88 | start() { 89 | this.#context.handler('bar', { ...this.#context, isError: false }); 90 | } 91 | 92 | continue() {} 93 | } 94 | 95 | const player = new PlayerStub(); 96 | const scenarioBuilder = new ScenarioBuilder() 97 | .withCommand('foo') 98 | .withPlayer(player) 99 | .expectError('bar', { timeout: 5 }); 100 | 101 | await scenarioBuilder.run(); 102 | 103 | t.end(); 104 | }); 105 | -------------------------------------------------------------------------------- /tests/unit/acts/input.spec.js: -------------------------------------------------------------------------------- 1 | import { test } from 'tap'; 2 | 3 | import clix from '../../../src/index.js'; 4 | import { kActType } from '../../../src/constant.js'; 5 | import { ScenarioBuilder } from '../../../src/scenario-builder.js'; 6 | 7 | test('it should expose an input function to add an expected value', (t) => { 8 | const scenarioBuilder = clix('foo bar'); 9 | const inputValue = 'hey'; 10 | 11 | scenarioBuilder.input(inputValue); 12 | const scenario = scenarioBuilder.build(); 13 | 14 | t.same(scenario.acts, [{ value: inputValue, type: kActType.input }]); 15 | t.end(); 16 | }); 17 | 18 | test('it should expose an input function to add an several expected Values', (t) => { 19 | const scenarioBuilder = clix('foo bar'); 20 | const inputA = 'hey'; 21 | const inputB = 'yo'; 22 | 23 | scenarioBuilder.input([inputA, inputB]); 24 | const scenario = scenarioBuilder.build(); 25 | 26 | t.same(scenario.acts, [ 27 | { value: inputA, type: kActType.input }, 28 | { value: inputB, type: kActType.input }, 29 | ]); 30 | t.end(); 31 | }); 32 | 33 | test('it should allow chaining with input method', (t) => { 34 | const scenarioBuilder = clix('foo bar').input('yo').input('foo'); 35 | 36 | t.ok(scenarioBuilder instanceof ScenarioBuilder); 37 | t.end(); 38 | }); 39 | -------------------------------------------------------------------------------- /tests/unit/acts/withCode.spec.js: -------------------------------------------------------------------------------- 1 | import { test } from 'tap'; 2 | 3 | import clix from '../../../src/index.js'; 4 | import { kActType } from '../../../src/constant.js'; 5 | import { ScenarioBuilder } from '../../../src/scenario-builder.js'; 6 | 7 | test('it withError function could add an expected error code', (t) => { 8 | const scenarioBuilder = clix('foo bar'); 9 | 10 | const code = 1; 11 | const errorText = 'yo'; 12 | scenarioBuilder.expectError(errorText).withCode(code); 13 | const scenario = scenarioBuilder.build(); 14 | 15 | const lastAct = scenario.acts.at(-1); 16 | t.same(lastAct, { 17 | value: code, 18 | type: kActType.exitCode, 19 | }); 20 | t.end(); 21 | }); 22 | 23 | test('it should allow chaining with input method', (t) => { 24 | const scenarioBuilder = clix('foo bar').expectError('yo').withCode(2); 25 | 26 | t.ok(scenarioBuilder instanceof ScenarioBuilder); 27 | t.end(); 28 | }); 29 | 30 | test('it should be possible to call withCode after an expect', (t) => { 31 | const code = 2; 32 | 33 | const scenarioBuilder = clix('foo bar').expect('yo').withCode(code); 34 | const scenario = scenarioBuilder.build(); 35 | 36 | const lastAct = scenario.acts.at(-1); 37 | t.same(lastAct, { 38 | value: code, 39 | type: kActType.exitCode, 40 | }); 41 | t.end(); 42 | }); 43 | -------------------------------------------------------------------------------- /tests/unit/compare.spec.js: -------------------------------------------------------------------------------- 1 | // third party dependencies 2 | import { test, afterEach } from 'tap'; 3 | import { spyOn } from 'tinyspy'; 4 | 5 | // internal dependencies 6 | import clix from '../../src/index.js'; 7 | 8 | afterEach(() => { 9 | process.env['DEBUG'] = ''; 10 | }); 11 | 12 | test('it should expose a compare function', (t) => { 13 | const scenario = clix('test').build(); 14 | 15 | t.equal(typeof scenario._compare, 'function'); 16 | t.end(); 17 | }); 18 | 19 | test('it should set currentAct.ok to true if values are equal', (t) => { 20 | const act = { 21 | type: 'equal', 22 | value: 'foo', 23 | }; 24 | const equalBufferValue = 'foo'; 25 | 26 | clix('test').build()._compare(act, equalBufferValue); 27 | 28 | t.ok(act.ok); 29 | t.end(); 30 | }); 31 | 32 | test('it should set currentAct.ok to false if values are not equal', (t) => { 33 | const act = { 34 | type: 'equal', 35 | value: 'foo', 36 | }; 37 | const notEqualBufferValue = 'zoo'; 38 | 39 | clix('test').build()._compare(act, notEqualBufferValue); 40 | 41 | t.notOk(act.ok); 42 | t.end(); 43 | }); 44 | 45 | test('it should add an actual property to the current act within buffer value', (t) => { 46 | const act = { 47 | type: 'equal', 48 | value: 'foo', 49 | }; 50 | const equalBufferValue = 'foo'; 51 | 52 | clix('test').build()._compare(act, equalBufferValue); 53 | 54 | t.equal(act.actual, equalBufferValue); 55 | t.end(); 56 | }); 57 | 58 | test('it should call debug method with expected and actual value', (t) => { 59 | process.env['DEBUG'] = '1'; 60 | const debugSpy = spyOn(console, 'log'); 61 | const act = { 62 | type: 'equal', 63 | value: 'foo', 64 | }; 65 | const equalBufferValue = 'foo'; 66 | 67 | clix('test').build()._compare(act, equalBufferValue); 68 | 69 | t.equal(debugSpy.calls.length, 1); 70 | const consoleArguments = debugSpy.calls.at(0).at(1); 71 | t.equal(consoleArguments[('equal', equalBufferValue, act.value)]); 72 | t.end(); 73 | }); 74 | -------------------------------------------------------------------------------- /tests/unit/result.spec.js: -------------------------------------------------------------------------------- 1 | import { test } from 'tap'; 2 | 3 | import clix from '../../src/index.js'; 4 | 5 | test('it should expose a _buildResult method as public', (t) => { 6 | const scenario = clix('my-command').build(); 7 | 8 | t.ok(scenario._buildResult); 9 | t.end(); 10 | }); 11 | 12 | test('_buildResult should return an .ok property', (t) => { 13 | const scenario = clix('my-command').build(); 14 | 15 | const { ok } = scenario._buildResult(); 16 | 17 | t.equal(typeof ok, 'boolean'); 18 | t.end(); 19 | }); 20 | 21 | test('_buildResult should return a .acts property within an .all property', (t) => { 22 | const scenario = clix('my-command'); 23 | 24 | const { acts } = scenario.build()._buildResult(); 25 | 26 | t.equal(typeof acts.all, 'function'); 27 | t.end(); 28 | }); 29 | 30 | test('acts.all should return all acts', (t) => { 31 | // given 32 | const scenario = clix('my-command') 33 | .expect('foo') 34 | .input('bar') 35 | .expectError('baz') 36 | .build(); 37 | 38 | // when 39 | const { acts } = scenario._buildResult(); 40 | const allActs = acts.all(); 41 | 42 | // then 43 | t.equal(allActs.length, scenario.acts.length); 44 | t.end(); 45 | }); 46 | 47 | test('acts.last should return the last act that failed', (t) => { 48 | // given 49 | const scenario = clix('my-command') 50 | .expect('foo') 51 | .input('bar') 52 | .expectError('baz') 53 | .build(); 54 | 55 | scenario.acts[0].ok = false; // simulate .run() call 56 | 57 | // when 58 | const { acts } = scenario._buildResult(); 59 | const lastFailedActs = acts.failed(); 60 | 61 | // then 62 | t.equal(lastFailedActs.value, 'foo'); 63 | t.end(); 64 | }); 65 | 66 | test('acts.last should return the last act if nothing failed', (t) => { 67 | // given 68 | const scenario = clix('my-command') 69 | .expect('foo') 70 | .input('bar') 71 | .expectError('baz') 72 | .build(); 73 | 74 | const res = scenario._buildResult(); 75 | 76 | // when 77 | const act = res.acts.failed(); 78 | 79 | // then 80 | t.equal(act, null); 81 | t.end(); 82 | }); 83 | -------------------------------------------------------------------------------- /tests/unit/validation.spec.js: -------------------------------------------------------------------------------- 1 | import { test } from 'tap'; 2 | 3 | import clix from '../../src/index.js'; 4 | import { ScenarioBuilder } from '../../src/scenario-builder.js'; 5 | 6 | test('it should expose a function', (t) => { 7 | t.ok(clix instanceof Function); 8 | t.end(); 9 | }); 10 | 11 | test('it should throw an error if command is nullish', (t) => { 12 | const nullCommand = null; 13 | t.throws( 14 | () => clix(nullCommand), 15 | new Error(`Command should be a string but got ${nullCommand}`) 16 | ); 17 | t.end(); 18 | }); 19 | 20 | test('it should throw an error if command is undefined', (t) => { 21 | const undefinedCommand = null; 22 | t.throws( 23 | () => clix(undefinedCommand), 24 | new Error(`Command should be a string but got ${undefinedCommand}`) 25 | ); 26 | t.end(); 27 | }); 28 | 29 | test('it should throw an error if command is an empty string', (t) => { 30 | const emptyCommand = ''; 31 | t.throws( 32 | () => clix(emptyCommand), 33 | new Error('Command should not be an empty string') 34 | ); 35 | t.end(); 36 | }); 37 | 38 | test('it should return a ScenarioBuilder instance if command type is valid', (t) => { 39 | const validCommand = 'bash ./fixtures/basic'; 40 | 41 | const res = clix(validCommand); 42 | 43 | t.ok(res instanceof ScenarioBuilder); 44 | t.end(); 45 | }); 46 | -------------------------------------------------------------------------------- /tests/unit/write.spec.js: -------------------------------------------------------------------------------- 1 | import { test } from 'tap'; 2 | import { ScenarioBuilder } from '../../src/scenario-builder.js'; 3 | 4 | class PlayerStub { 5 | lastInput = null; 6 | 7 | write(input) { 8 | this.lastInput = input; 9 | } 10 | } 11 | 12 | test('_writeInProc add a \n if the input does not contain it', (t) => { 13 | const player = new PlayerStub(); 14 | const scenario = new ScenarioBuilder() 15 | .withCommand('random command') 16 | .withPlayer(player) 17 | .build(); 18 | 19 | const inputWithoutBackslachN = 'What is your name?'; 20 | scenario._writeInProc(inputWithoutBackslachN); 21 | 22 | const inputWithBackSlachN ='What is your name?\n' 23 | t.equal(player.lastInput, inputWithBackSlachN); 24 | t.end(); 25 | }); 26 | 27 | test('_writeInProc should not add if it is already in the input', (t) => { 28 | const player = new PlayerStub(); 29 | const scenario = new ScenarioBuilder() 30 | .withCommand('random command') 31 | .withPlayer(player) 32 | .build(); 33 | 34 | const inputWithBackSlachN ='What is your name?\n' 35 | scenario._writeInProc(inputWithBackSlachN); 36 | 37 | t.equal(player.lastInput, inputWithBackSlachN); 38 | t.end(); 39 | }); 40 | --------------------------------------------------------------------------------