├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .husky └── pre-commit ├── .npmignore ├── .prettierignore ├── .prettierrc ├── README.md ├── cli-examples ├── __tests__ │ ├── graphic-print.test.js │ ├── multiple-prompts.test.js │ ├── prompts.test.js │ ├── select.test.js │ └── simple-print.test.js ├── graphic-print.js ├── multiple-prompts.js ├── prompts.js ├── select.js ├── simple-print.js └── test-utils │ └── icons.js ├── lib ├── __tests__ │ └── cli-ansi-parser.test.js ├── cli-ansi-parser.js ├── cli-testing-tool.js └── index.js ├── package-lock.json └── package.json /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | old-code/ 3 | dev/ 4 | node_modules -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es6: true, 4 | node: true, 5 | jest: true 6 | }, 7 | extends: ['google', 'prettier'], 8 | plugins: ['prettier'], 9 | globals: { 10 | Atomics: 'readonly', 11 | SharedArrayBuffer: 'readonly' 12 | }, 13 | parserOptions: { 14 | ecmaVersion: 2018 15 | }, 16 | rules: { 17 | 'prettier/prettier': ['error'], 18 | 'comma-dangle': 0, 19 | 'require-jsdoc': 0, 20 | 'arrow-parens': 0, 21 | 'guard-for-in': 0, 22 | 'valid-jsdoc': 0, 23 | 'no-undef': 'error', 24 | 'operator-linebreak': [ 25 | 'error', 26 | 'after', 27 | { 28 | overrides: { '?': 'ignore', ':': 'ignore', '+': 'ignore' } 29 | } 30 | ], 31 | indent: [ 32 | 'error', 33 | 2, 34 | { 35 | CallExpression: { arguments: 'first' }, 36 | ignoredNodes: [ 37 | 'CallExpression > CallExpression', 38 | 'CallExpression > MemberExpression' 39 | ], 40 | SwitchCase: 1 41 | } 42 | ], 43 | 'max-len': ['error', { code: 80, ignoreComments: true }] 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ${{ matrix.os }} 8 | 9 | strategy: 10 | matrix: 11 | node-version: [12.x] 12 | os: ['macos-latest'] 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm ci 21 | - run: npm run eslint 22 | 23 | test: 24 | runs-on: ${{ matrix.os }} 25 | 26 | strategy: 27 | matrix: 28 | node-version: [12.x, 15.x] 29 | os: ['windows-latest', 'ubuntu-latest'] 30 | 31 | steps: 32 | - uses: actions/checkout@v2 33 | - name: Use Node.js ${{ matrix.node-version }} 34 | uses: actions/setup-node@v1 35 | with: 36 | node-version: ${{ matrix.node-version }} 37 | - run: npm ci 38 | - run: npm test 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | playground.js -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run prettier 5 | npm run eslint:fix 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | cli-examples 2 | node_modules 3 | tests 4 | .prettierrc 5 | .prettierignore 6 | .husky 7 | .github 8 | .eslintrc.js 9 | .eslintignore 10 | .vscode 11 | playground.js 12 | lib/__tests__/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | old-code/ 3 | dev/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "printWidth": 80, 5 | "trailingComma": "none" 6 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CLI Testing Tool 2 | 3 | A testing library that allows you to test input and outputs of your CLI command. 4 | 5 | *Note: This is WIP but it should be ready enough for most common CLI use-cases I can think of* 6 | 7 | ## Installation 8 | 9 | With NPM: 10 | ```sh 11 | npm install --save-dev cli-testing-tool 12 | ``` 13 | 14 | With Yarn: 15 | ```sh 16 | yarn add --dev cli-testing-tool 17 | ``` 18 | 19 | ## Examples 20 | 21 | Check out [Interactive Examples on Stackblitz](https://stackblitz.com/edit/node-kfod5b?file=examples%2Fprompts%2Fprompts.test.js) 22 | 23 | ### Testing Colored Terminal Text 24 | 25 | Check out [this example of StackBlitz](https://stackblitz.com/edit/node-kfod5b?file=examples%2Fgraphic-hello-world%2Fgraphic-hello-world.test.js) 26 | 27 | ```js 28 | // colored-greeting.test.js 29 | const { createCommandInterface } = require('cli-testing-tool'); 30 | 31 | test('should print colored greetings', async () => { 32 | const commandInterface = createCommandInterface('node ./graphic-print.js', { 33 | cwd: __dirname, // considering, the test file is in the same directory as the cli file 34 | }); 35 | await commandInterface.type('Saurabh\n'); 36 | const terminal = await commandInterface.getOutput(); 37 | 38 | // ANSI Escape codes are tokenized into readable text token in tokenizedOutput 39 | // Helpful when libraries like inquirer or prompts add ansi-escape codes. 40 | expect(terminal.tokenizedOutput).toBe( 41 | "What's your name?Hi, [BOLD_START][RED_START]Saurabh[COLOR_END][BOLD_END]!" 42 | ); 43 | 44 | // ANSI Escape codes are not tokenized. 45 | expect(terminal.rawOutput).toBe( 46 | `What's your name?Hi, \x1B[1m\x1B[31mSaurabh\x1B[39m\x1B[22m!` 47 | ); 48 | 49 | // ANSI Escape codes are removed 50 | expect(terminal.stringOutput).toBe(`What's your name?Hi, Saurabh!`); 51 | }); 52 | 53 | ``` 54 | 55 |
56 | Code of the CLI that we're testing in above snippet 57 | 58 | ```js 59 | // colored-greeting.js 60 | const readline = require('readline').createInterface({ 61 | input: process.stdin, 62 | output: process.stdout 63 | }); 64 | 65 | const bold = (str) => `\x1b[1m${str}\x1b[22m`; 66 | const red = (str) => `\x1b[31m${str}\x1b[39m`; 67 | 68 | readline.question(`What's your name?`, (name) => { 69 | console.log(`Hi, ${bold(red('Saurabh'))}!`); 70 | readline.close(); 71 | }); 72 | 73 | ``` 74 | 75 |
76 | 77 | 78 | 79 | ## Options 80 | 81 | You can pass options as 2nd param to `createCommandInterface`. 82 | 83 | The default options are: 84 | ```js 85 | const defaultOptions = { 86 | typeDelay: 100, // number. delay between each `.type()` call 87 | logData: false, // boolean. if true, logs the command data on terminal 88 | logError: true, // boolean. if false, won't add command errors on terminal 89 | cwd: process.cwd(), // string. working directory from where your simulated command is executed 90 | env: undefined // object | undefined. environment variables object if there are any 91 | }; 92 | ``` 93 | 94 | 95 | ## Terminal Text Parsing Support Checklist 96 | Refer to [Full List of Ansi Escape Codes](https://gist.github.com/fnky/458719343aabd01cfb17a3a4f7296797) that need to be handled. 97 | - [x] Normal text without ansi escape codes 98 | - [x] Colored text 99 | - [x] Cursor movement (Basic Support. Not tested) 100 | - [x] Erase Line/Screen Clear (Basic Support. Not tested) 101 | - [ ] Screen Modes (No Support) 102 | - [ ] Private Modes (No Support) 103 | - [ ] Multiple Arguments (No Support. Difficult to support this) 104 | 105 | 106 | 107 | ---- 108 | 109 | Big Shoutout to 110 | - [@fnky](https://github.com/fnky) for the [list of all ansi escape codes](https://gist.github.com/fnky/458719343aabd01cfb17a3a4f7296797). 111 | 112 | - [@netzkolchose](https://github.com/netzkolchose) for [node-ansiterminal](https://github.com/netzkolchose/node-ansiterminal) library. 113 | -------------------------------------------------------------------------------- /cli-examples/__tests__/graphic-print.test.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { createCommandInterface } = require('../../lib/index'); 3 | 4 | test('should print greetings', async () => { 5 | const commandInterface = createCommandInterface('node ./graphic-print.js', { 6 | cwd: path.join(__dirname, '..') 7 | }); 8 | await commandInterface.type('Saurabh\n'); 9 | const terminal = await commandInterface.getOutput(); 10 | 11 | // ANSI Escape codes are tokenized into readable text token in tokenizedOutput 12 | // Helpful when libraries like inquirer or prompts add ansi-escape codes. 13 | expect(terminal.tokenizedOutput).toBe( 14 | "What's your name?Hi, [BOLD_START][RED_START]Saurabh[COLOR_END][BOLD_END]!" 15 | ); 16 | 17 | // ANSI Escape codes are not tokenized. 18 | expect(terminal.rawOutput).toBe( 19 | `What's your name?Hi, \x1B[1m\x1B[31mSaurabh\x1B[39m\x1B[22m!` 20 | ); 21 | 22 | // ANSI Escape codes are removed 23 | expect(terminal.stringOutput).toBe(`What's your name?Hi, Saurabh!`); 24 | }); 25 | -------------------------------------------------------------------------------- /cli-examples/__tests__/multiple-prompts.test.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { createCommandInterface } = require('../../lib'); 3 | const { CHECK_MARK, THREE_DOTS } = require('../test-utils/icons'); 4 | 5 | test('should pass', async () => { 6 | const commandInterface = createCommandInterface( 7 | 'node ./multiple-prompts.js', 8 | { 9 | cwd: path.join(__dirname, '..') 10 | } 11 | ); 12 | await commandInterface.type('19\n'); 13 | await commandInterface.type('saurabh\n'); 14 | const terminal = await commandInterface.getOutput(); 15 | expect(terminal.stringOutput).toBe( 16 | [ 17 | `${CHECK_MARK} How old are you? ${THREE_DOTS} 19`, 18 | '{ value: 19 }', 19 | `${CHECK_MARK} What is your name? ${THREE_DOTS} saurabh`, 20 | "{ value: 'saurabh' }" 21 | ].join('\n') 22 | ); 23 | }); 24 | -------------------------------------------------------------------------------- /cli-examples/__tests__/prompts.test.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { createCommandInterface } = require('../../lib'); 3 | const { 4 | FORWARD_ARROW, 5 | CHECK_MARK, 6 | THREE_DOTS 7 | } = require('../test-utils/icons'); 8 | 9 | test('should block 12 year old', async () => { 10 | const commandInterface = createCommandInterface('node ./prompts.js', { 11 | cwd: path.join(__dirname, '..') 12 | }); 13 | await commandInterface.type('12\n'); 14 | const terminal = await commandInterface.getOutput(); 15 | expect(terminal.stringOutput.replace(/ /g, '')).toBe( 16 | // eslint-disable-next-line max-len 17 | `? How old are you? ${FORWARD_ARROW} 12\n${FORWARD_ARROW} Nightclub is 18+ only` 18 | ); 19 | }); 20 | 21 | test('should allow 20 year old', async () => { 22 | const commandInterface = createCommandInterface('node ./prompts.js', { 23 | cwd: path.join(__dirname, '..') 24 | }); 25 | await commandInterface.type('20\n'); 26 | const terminal = await commandInterface.getOutput(); 27 | expect(terminal.stringOutput).toBe( 28 | `${CHECK_MARK} How old are you? ${THREE_DOTS} 20\n{ value: 20 }` 29 | ); 30 | }); 31 | -------------------------------------------------------------------------------- /cli-examples/__tests__/select.test.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { createCommandInterface } = require('../../lib'); 3 | const { 4 | FORWARD_ARROW, 5 | THREE_DOTS, 6 | SELECT_ICON 7 | } = require('../test-utils/icons'); 8 | 9 | test('select', async () => { 10 | const commandInterface = createCommandInterface('node ./select.js', { 11 | cwd: path.join(__dirname, '..'), 12 | typeDelay: 500 13 | }); 14 | const terminalBeforeDownArrow = await commandInterface.getOutput(); 15 | expect(terminalBeforeDownArrow.stringOutput).toMatch( 16 | // eslint-disable-next-line max-len 17 | `? test:${THREE_DOTS} \n? test:${FORWARD_ARROW} \n${SELECT_ICON}test 1\ntest 2` 18 | ); 19 | expect(terminalBeforeDownArrow.stringOutput).toMatch(''); 20 | // move to next item 21 | await commandInterface.keys.arrowDown(); 22 | const terminalAfterDownArrow = await commandInterface.getOutput(); 23 | expect(terminalAfterDownArrow.stringOutput).toMatch( 24 | // eslint-disable-next-line max-len 25 | `? test:${THREE_DOTS} \n? test:${FORWARD_ARROW} \n${SELECT_ICON}test 1\n? test:${FORWARD_ARROW} \ntest 1\n${SELECT_ICON}test 2` 26 | ); 27 | expect(1).toBe(1); 28 | }); 29 | -------------------------------------------------------------------------------- /cli-examples/__tests__/simple-print.test.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { createCommandInterface } = require('../../lib'); 3 | 4 | test('should pass', async () => { 5 | const commandInterface = createCommandInterface('node ./simple-print.js', { 6 | cwd: path.join(__dirname, '..') 7 | }); 8 | await commandInterface.type('Saurabh\n'); 9 | await commandInterface.type('22\n'); 10 | const terminal = await commandInterface.getOutput(); 11 | 12 | expect(terminal.stringOutput).toBe( 13 | "What's your name?Hi, Saurabh!\nWhat's your age?So you are 22!" 14 | ); 15 | }); 16 | -------------------------------------------------------------------------------- /cli-examples/graphic-print.js: -------------------------------------------------------------------------------- 1 | const readline = require('readline').createInterface({ 2 | input: process.stdin, 3 | output: process.stdout 4 | }); 5 | 6 | const bold = (str) => `\x1b[1m${str}\x1b[22m`; 7 | const red = (str) => `\x1b[31m${str}\x1b[39m`; 8 | 9 | readline.question(`What's your name?`, (name) => { 10 | console.log(`Hi, ${bold(red('Saurabh'))}!`); 11 | readline.close(); 12 | }); 13 | -------------------------------------------------------------------------------- /cli-examples/multiple-prompts.js: -------------------------------------------------------------------------------- 1 | const prompts = require('prompts'); 2 | 3 | (async () => { 4 | const response1 = await prompts({ 5 | type: 'number', 6 | name: 'value', 7 | message: 'How old are you?', 8 | validate: (value) => (value < 18 ? `Nightclub is 18+ only` : true) 9 | }); 10 | 11 | console.log(response1); // => { value: 24 } 12 | 13 | const response2 = await prompts({ 14 | type: 'text', 15 | name: 'value', 16 | message: 'What is your name?' 17 | }); 18 | 19 | console.log(response2); 20 | })(); 21 | -------------------------------------------------------------------------------- /cli-examples/prompts.js: -------------------------------------------------------------------------------- 1 | const prompts = require('prompts'); 2 | 3 | (async () => { 4 | const response = await prompts({ 5 | type: 'number', 6 | name: 'value', 7 | message: 'How old are you?', 8 | validate: (value) => (value < 18 ? `Nightclub is 18+ only` : true) 9 | }); 10 | 11 | console.log(response); // => { value: 24 } 12 | })(); 13 | -------------------------------------------------------------------------------- /cli-examples/select.js: -------------------------------------------------------------------------------- 1 | const prompts = require('prompts'); 2 | 3 | const questions = [ 4 | { 5 | type: 'autocomplete', 6 | message: `test: `, 7 | name: 'selectedProject', 8 | choices: [ 9 | { 10 | title: 'test 1', 11 | value: 'test1' 12 | }, 13 | { 14 | title: 'test 2', 15 | value: 'test2' 16 | } 17 | ], 18 | limit: 4 19 | } 20 | ]; 21 | 22 | (async () => { 23 | const { selectedProject } = await prompts(questions); 24 | console.log(selectedProject); 25 | })(); 26 | -------------------------------------------------------------------------------- /cli-examples/simple-print.js: -------------------------------------------------------------------------------- 1 | const readline = require('readline').createInterface({ 2 | input: process.stdin, 3 | output: process.stdout 4 | }); 5 | 6 | readline.question(`What's your name?`, (name) => { 7 | console.log(`Hi, ${name}!`); 8 | readline.question(`What's your age?`, (age) => { 9 | console.log(`So you are ${age}!`); 10 | readline.close(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /cli-examples/test-utils/icons.js: -------------------------------------------------------------------------------- 1 | // Windows is just weird. 2 | const win = process.platform === 'win32'; 3 | 4 | module.exports = { 5 | FORWARD_ARROW: win ? '»' : '›', 6 | CHECK_MARK: win ? '√' : '✔', 7 | THREE_DOTS: win ? '...' : '…', 8 | SELECT_ICON: win ? '>' : '❯' 9 | }; 10 | -------------------------------------------------------------------------------- /lib/__tests__/cli-ansi-parser.test.js: -------------------------------------------------------------------------------- 1 | const { parseOutput } = require('../cli-ansi-parser'); 2 | 3 | describe('parseOutput', () => { 4 | test('should tokenize cursor move escape codes', () => { 5 | const cursorMove = '\x1b[10A\x1b[12B\x1b[34C\x1b[56D'; 6 | const { tokenizedOutput: cursorMoveOutput } = parseOutput(cursorMove); 7 | expect(cursorMoveOutput).toBe( 8 | '[CURSOR_UP_10][CURSOR_DOWN_12][CURSOR_RIGHT_34][CURSOR_LEFT_56]' 9 | ); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /lib/cli-ansi-parser.js: -------------------------------------------------------------------------------- 1 | const AnsiTerminal = require('node-ansiterminal').AnsiTerminal; 2 | const AnsiParser = require('node-ansiparser'); 3 | 4 | // Big shoutout to https://gist.github.com/fnky/458719343aabd01cfb17a3a4f7296797 for super cool reference to all ansi codes 5 | const ESC = '(?:\\x1[bB])|(?:\\u001b)\\['; 6 | const ESC_NO_BRACKET = ESC.replace('\\[', ''); 7 | const STRING_ESC = '\x1b['; 8 | const ANY_NUMBER = '(\\d+)'; 9 | 10 | const ANSI_CHARACTER_MAPS = { 11 | // Erase 12 | CLEAR_SCREEN: `${ESC}J`, 13 | CLEAR_CURSOR_TO_END_SCREEN: `${ESC}0J`, 14 | CLEAR_CURSOR_TO_START_SCREEN: `${ESC}1J`, 15 | CLEAR_ENTIRE_SCREEN: `${ESC}2J`, 16 | CLEAR_LINE: `${ESC}K`, 17 | CLEAR_CURSOR_TO_END_LINE: `${ESC}0K`, 18 | CLEAR_CURSOR_TO_START_LINE: `${ESC}1K`, 19 | CLEAR_ENTIRE_LINE: `${ESC}2K`, 20 | 21 | // Cursor 22 | CURSOR_TO_HOME: `${ESC}${ANY_NUMBER};${ANY_NUMBER}H`, 23 | CURSOR_TO_END: `${ESC}${ANY_NUMBER};${ANY_NUMBER}f`, 24 | CURSOR_UP: `${ESC}${ANY_NUMBER}A`, 25 | CURSOR_DOWN: `${ESC}${ANY_NUMBER}B`, 26 | CURSOR_RIGHT: `${ESC}${ANY_NUMBER}C`, 27 | CURSOR_LEFT: `${ESC}${ANY_NUMBER}D`, 28 | CURSOR_UP_FROM_BEGINNING: `${ESC}${ANY_NUMBER}E`, 29 | CURSOR_DOWN_FROM_BEGINNING: `${ESC}${ANY_NUMBER}F`, 30 | CURSOR_TO_COL: `${ESC}${ANY_NUMBER}G`, 31 | CURSOR_STORE: `${ESC_NO_BRACKET}7`, 32 | CURSOR_RESTORE: `${ESC_NO_BRACKET}8`, 33 | CURSOR_VISIBLE: `${ESC}\\?25h`, 34 | CURSOR_INVISIBLE: `${ESC}\\?25l`, 35 | CURSOR_SAVE: `${ESC}s`, 36 | CURSOR_RESET: `${ESC}u`, 37 | 38 | // Colors 39 | BLACK_START: `${ESC}30m`, 40 | RED_START: `${ESC}31m`, 41 | GREEN_START: `${ESC}32m`, 42 | YELLOW_START: `${ESC}33m`, 43 | BLUE_START: `${ESC}34m`, 44 | MAGENTA_START: `${ESC}35m`, 45 | CYAN_START: `${ESC}36m`, 46 | WHITE_START: `${ESC}37m`, 47 | COLOR_END: `${ESC}39m`, 48 | GREY_START: `${ESC}90m`, 49 | BLACK_BG_START: `${ESC}40m`, 50 | RED_BG_START: `${ESC}41m`, 51 | GREEN_BG_START: `${ESC}42m`, 52 | YELLOW_BG_START: `${ESC}43m`, 53 | BLUE_BG_START: `${ESC}44m`, 54 | MAGENTA_BG_START: `${ESC}45m`, 55 | CYAN_BG_START: `${ESC}46m`, 56 | WHITE_BG_START: `${ESC}47m`, 57 | BG_END: `${ESC}49m`, 58 | 59 | // Graphics 60 | BOLD_START: `${ESC}1m`, 61 | BOLD_END: `${ESC}22m`, 62 | FAINT_START: `${ESC}2m`, 63 | FAINT_END: `${ESC}22m`, 64 | ITALIC_START: `${ESC}3m`, 65 | ITALIC_END: `${ESC}23m`, 66 | UNDERLINE_START: `${ESC}4m`, 67 | UNDERLINE_END: `${ESC}24m`, 68 | BLINKING_START: `${ESC}5m`, 69 | BLINKING_END: `${ESC}25m`, 70 | INVERSE_START: `${ESC}7m`, 71 | INVERSE_END: `${ESC}27m`, 72 | HIDDEN_START: `${ESC}8m`, 73 | HIDDEN_END: `${ESC}28m`, 74 | STRIKE_START: `${ESC}9m`, 75 | STRIKE_END: `${ESC}29m` 76 | 77 | // @TODO: Screen Modes 78 | }; 79 | 80 | const parseOutput = (output) => { 81 | const tokenizeOutput = () => { 82 | let out = output; 83 | for (const [ 84 | ESCAPE_CHARACTER_NAME, 85 | ESCAPE_CHARACTER_REGEX 86 | ] of Object.entries(ANSI_CHARACTER_MAPS)) { 87 | out = out.replace( 88 | new RegExp(`${ESCAPE_CHARACTER_REGEX}`, 'g'), 89 | (...args) => { 90 | const hasCapturedElement = args.length > 3; 91 | const firstCapture = args[1]; 92 | return `[${ESCAPE_CHARACTER_NAME}${ 93 | hasCapturedElement ? `_${firstCapture}` : '' 94 | }]`; 95 | } 96 | ); 97 | } 98 | 99 | return out; 100 | }; 101 | 102 | const finalString = () => { 103 | const terminal = new AnsiTerminal(80, 25, 500); 104 | const terminalParser = new AnsiParser(terminal); 105 | terminalParser.parse(output); 106 | const trimmedOutput = terminal 107 | .toString() 108 | .trim() 109 | .replace(/[ \t]{2,}/g, ''); 110 | return trimmedOutput; 111 | }; 112 | 113 | return { 114 | rawOutput: output, 115 | tokenizedOutput: tokenizeOutput(), 116 | stringOutput: finalString() 117 | }; 118 | }; 119 | 120 | module.exports = { parseOutput, STRING_ESC, ANSI_CHARACTER_MAPS }; 121 | -------------------------------------------------------------------------------- /lib/cli-testing-tool.js: -------------------------------------------------------------------------------- 1 | const { spawn } = require('child_process'); 2 | const { parseOutput, STRING_ESC } = require('./cli-ansi-parser'); 3 | 4 | const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 5 | 6 | // types 7 | /** 8 | * @typedef {({ 9 | * stringOutput: string, 10 | * tokenizedOutput: string, 11 | * rawOutput: string 12 | * })} ParsedOutput 13 | * 14 | * @typedef {({ 15 | * typeDelay: number, 16 | * logData: boolean, 17 | * logError: boolean, 18 | * cwd: string, 19 | * env: any 20 | * })} Options 21 | */ 22 | 23 | /** @type {Options} defaultOptions */ 24 | const defaultOptions = { 25 | typeDelay: 100, 26 | logData: false, 27 | logError: true, 28 | cwd: process.cwd(), 29 | env: undefined 30 | }; 31 | 32 | /** 33 | * 34 | * @param {string} commandString 35 | * @param {Options} userOptions 36 | */ 37 | const createCommandInterface = (commandString, userOptions = {}) => { 38 | const options = { ...defaultOptions, ...userOptions }; 39 | 40 | const commandArgs = commandString.split(' '); 41 | 42 | let outputs = ''; 43 | let isFinishTypingCalled = false; 44 | 45 | const initCommandListeners = () => { 46 | outputs = ''; 47 | isFinishTypingCalled = false; 48 | const commandInterface = spawn(commandArgs[0], commandArgs.slice(1), { 49 | detached: true, 50 | stdio: 'pipe', 51 | cwd: options.cwd, 52 | env: options.env 53 | }); 54 | 55 | commandInterface.stdout.on('data', (data) => { 56 | if (options.logData) { 57 | console.log(data.toString()); 58 | } 59 | outputs += data.toString(); 60 | }); 61 | 62 | commandInterface.stderr.on('data', (error) => { 63 | if (options.logData) { 64 | console.error(error.toString()); 65 | } 66 | outputs += error.toString(); 67 | }); 68 | 69 | commandInterface.on('error', (error) => { 70 | throw error; 71 | }); 72 | 73 | return commandInterface; 74 | }; 75 | 76 | let command = initCommandListeners(); 77 | 78 | const type = async (text) => { 79 | if (isFinishTypingCalled) { 80 | command = initCommandListeners(); 81 | } 82 | 83 | await wait(options.typeDelay); 84 | 85 | return new Promise((resolve) => { 86 | command.stdin.write(`${text}`, () => { 87 | resolve(); 88 | }); 89 | }); 90 | }; 91 | 92 | const keys = { 93 | enter: () => type('\n'), 94 | arrowDown: () => type(`${STRING_ESC}1B`), 95 | arrowUp: () => type(`${STRING_ESC}1A`) 96 | }; 97 | 98 | /** 99 | * @returns {Promise} 100 | */ 101 | const getOutput = () => { 102 | if (!isFinishTypingCalled) { 103 | finishTyping(); 104 | } 105 | return new Promise((resolve) => { 106 | command.stdout.on('end', () => { 107 | return resolve(parseOutput(outputs.trim())); 108 | }); 109 | }); 110 | }; 111 | 112 | const finishTyping = () => { 113 | isFinishTypingCalled = true; 114 | command.stdin.end(); 115 | }; 116 | 117 | return { 118 | type, 119 | finishTyping, 120 | getOutput, 121 | command, 122 | keys 123 | }; 124 | }; 125 | 126 | module.exports = { 127 | createCommandInterface, 128 | parseOutput 129 | }; 130 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./cli-testing-tool'); 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cli-testing-tool", 3 | "version": "0.3.0", 4 | "description": "", 5 | "main": "lib/index.js", 6 | "directories": { 7 | "lib": "lib" 8 | }, 9 | "scripts": { 10 | "test": "jest", 11 | "eslint": "eslint cli-examples lib", 12 | "eslint:fix": "npm run eslint -- --fix", 13 | "prettier": "prettier --write cli-examples lib", 14 | "prepare": "husky install", 15 | "prepublishOnly": "npm run eslint && npm test" 16 | }, 17 | "keywords": [], 18 | "author": "", 19 | "license": "MIT", 20 | "devDependencies": { 21 | "eslint": "^7.0.0", 22 | "eslint-config-google": "^0.14.0", 23 | "eslint-config-prettier": "^6.11.0", 24 | "eslint-plugin-prettier": "^3.1.3", 25 | "husky": "^7.0.2", 26 | "jest": "^27.2.0", 27 | "prettier": "^2.0.5", 28 | "prompts": "^2.4.1" 29 | }, 30 | "dependencies": { 31 | "node-ansiparser": "^2.2.0", 32 | "node-ansiterminal": "^0.2.1-beta" 33 | } 34 | } 35 | --------------------------------------------------------------------------------