[READ-ONLY] Mirror of https://github.com/bombshell-dev/clack. Effortlessly build beautiful command-line apps bomb.sh/docs/clack/basics/getting-started/
cli command-line command-line-app node prompt prompts
5

Configure Feed

Select the types of activity you want to include in your feed.

test(core): migrate core to test-utils

Nate Moore (Mar 26, 2026, 10:56 PM EDT) 8e36060c d92d504d

+343 -451
+38 -45
packages/core/src/prompts/autocomplete.test.ts
··· 1 1 import { cursor } from 'sisteransi'; 2 - import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; 2 + import { beforeEach, describe, expect, test } from 'vitest'; 3 3 import { default as AutocompletePrompt } from './autocomplete.js'; 4 - import { MockReadable } from '../mock-readable.js'; 5 - import { MockWritable } from '../mock-writable.js'; 4 + import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; 6 5 7 6 describe('AutocompletePrompt', () => { 8 - let input: MockReadable; 9 - let output: MockWritable; 7 + let mocks: Mocks<{ input: true; output: true }>; 10 8 const testOptions = [ 11 9 { value: 'apple', label: 'Apple' }, 12 10 { value: 'banana', label: 'Banana' }, ··· 16 14 ]; 17 15 18 16 beforeEach(() => { 19 - input = new MockReadable(); 20 - output = new MockWritable(); 21 - }); 22 - 23 - afterEach(() => { 24 - vi.restoreAllMocks(); 17 + mocks = createMocks({ input: true, output: true }); 25 18 }); 26 19 27 20 test('renders render() result', () => { 28 21 const instance = new AutocompletePrompt({ 29 - input, 30 - output, 22 + input: mocks.input, 23 + output: mocks.output, 31 24 render: () => 'foo', 32 25 options: testOptions, 33 26 }); 34 27 instance.prompt(); 35 - expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); 28 + expect(mocks.output.buffer).to.deep.equal([cursor.hide, 'foo']); 36 29 }); 37 30 38 31 test('initial options match provided options', () => { 39 32 const instance = new AutocompletePrompt({ 40 - input, 41 - output, 33 + input: mocks.input, 34 + output: mocks.output, 42 35 render: () => 'foo', 43 36 options: testOptions, 44 37 }); ··· 52 45 53 46 test('cursor navigation with event emitter', () => { 54 47 const instance = new AutocompletePrompt({ 55 - input, 56 - output, 48 + input: mocks.input, 49 + output: mocks.output, 57 50 render: () => 'foo', 58 51 options: testOptions, 59 52 }); ··· 78 71 79 72 test('initialValue selects correct option', () => { 80 73 const instance = new AutocompletePrompt({ 81 - input, 82 - output, 74 + input: mocks.input, 75 + output: mocks.output, 83 76 render: () => 'foo', 84 77 options: testOptions, 85 78 initialValue: ['cherry'], ··· 95 88 96 89 test('initialValue defaults to first option when non-multiple', () => { 97 90 const instance = new AutocompletePrompt({ 98 - input, 99 - output, 91 + input: mocks.input, 92 + output: mocks.output, 100 93 render: () => 'foo', 101 94 options: testOptions, 102 95 }); ··· 107 100 108 101 test('initialValue is empty when multiple', () => { 109 102 const instance = new AutocompletePrompt({ 110 - input, 111 - output, 103 + input: mocks.input, 104 + output: mocks.output, 112 105 render: () => 'foo', 113 106 options: testOptions, 114 107 multiple: true, ··· 120 113 121 114 test('filtering through user input', () => { 122 115 const instance = new AutocompletePrompt({ 123 - input, 124 - output, 116 + input: mocks.input, 117 + output: mocks.output, 125 118 render: () => 'foo', 126 119 options: testOptions, 127 120 }); ··· 132 125 expect(instance.filteredOptions.length).to.equal(testOptions.length); 133 126 134 127 // Simulate typing 'a' by emitting keypress event 135 - input.emit('keypress', 'a', { name: 'a' }); 128 + mocks.input.emit('keypress', 'a', { name: 'a' }); 136 129 137 130 // Check that filtered options are updated to include options with 'a' 138 131 expect(instance.filteredOptions.length).to.be.lessThan(testOptions.length); ··· 144 137 145 138 test('default filter function works correctly', () => { 146 139 const instance = new AutocompletePrompt({ 147 - input, 148 - output, 140 + input: mocks.input, 141 + output: mocks.output, 149 142 render: () => 'foo', 150 143 options: testOptions, 151 144 }); 152 145 153 146 instance.prompt(); 154 147 155 - input.emit('keypress', 'a', { name: 'a' }); 156 - input.emit('keypress', 'p', { name: 'p' }); 148 + mocks.input.emit('keypress', 'a', { name: 'a' }); 149 + mocks.input.emit('keypress', 'p', { name: 'p' }); 157 150 158 151 expect(instance.filteredOptions).toEqual([ 159 152 { value: 'apple', label: 'Apple' }, 160 153 { value: 'grape', label: 'Grape' }, 161 154 ]); 162 155 163 - input.emit('keypress', 'z', { name: 'z' }); 156 + mocks.input.emit('keypress', 'z', { name: 'z' }); 164 157 165 158 expect(instance.filteredOptions).toEqual([]); 166 159 }); 167 160 168 161 test('submit without nav resolves to first option in non-multiple', async () => { 169 162 const instance = new AutocompletePrompt({ 170 - input, 171 - output, 163 + input: mocks.input, 164 + output: mocks.output, 172 165 render: () => 'foo', 173 166 options: testOptions, 174 167 }); 175 168 176 169 const promise = instance.prompt(); 177 - input.emit('keypress', '', { name: 'return' }); 170 + mocks.input.emit('keypress', '', { name: 'return' }); 178 171 const result = await promise; 179 172 180 173 expect(instance.selectedValues).to.deep.equal(['apple']); ··· 183 176 184 177 test('submit without nav resolves to [] in multiple', async () => { 185 178 const instance = new AutocompletePrompt({ 186 - input, 187 - output, 179 + input: mocks.input, 180 + output: mocks.output, 188 181 render: () => 'foo', 189 182 options: testOptions, 190 183 multiple: true, 191 184 }); 192 185 193 186 const promise = instance.prompt(); 194 - input.emit('keypress', '', { name: 'return' }); 187 + mocks.input.emit('keypress', '', { name: 'return' }); 195 188 const result = await promise; 196 189 197 190 expect(instance.selectedValues).to.deep.equal([]); ··· 200 193 201 194 test('Tab with empty input and placeholder fills input and submit returns matching option', async () => { 202 195 const instance = new AutocompletePrompt({ 203 - input, 204 - output, 196 + input: mocks.input, 197 + output: mocks.output, 205 198 render: () => 'foo', 206 199 options: testOptions, 207 200 placeholder: 'apple', 208 201 }); 209 202 210 203 const promise = instance.prompt(); 211 - input.emit('keypress', '\t', { name: 'tab' }); 212 - input.emit('keypress', '', { name: 'return' }); 204 + mocks.input.emit('keypress', '\t', { name: 'tab' }); 205 + mocks.input.emit('keypress', '', { name: 'return' }); 213 206 const result = await promise; 214 207 215 208 expect(instance.userInput).to.equal('apple'); ··· 218 211 219 212 test('Tab with non-matching placeholder does not fill input', async () => { 220 213 const instance = new AutocompletePrompt({ 221 - input, 222 - output, 214 + input: mocks.input, 215 + output: mocks.output, 223 216 render: () => 'foo', 224 217 options: testOptions, 225 218 placeholder: 'Type to search...', 226 219 }); 227 220 228 221 instance.prompt(); 229 - input.emit('keypress', '\t', { name: 'tab' }); 222 + mocks.input.emit('keypress', '\t', { name: 'tab' }); 230 223 231 224 // Placeholder does not match any option, so input must not be filled with placeholder 232 225 expect(instance.userInput).not.to.equal('Type to search...');
+19 -26
packages/core/src/prompts/confirm.test.ts
··· 1 1 import { cursor } from 'sisteransi'; 2 - import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; 2 + import { beforeEach, describe, expect, test } from 'vitest'; 3 3 import { default as ConfirmPrompt } from './confirm.js'; 4 - import { MockReadable } from '../mock-readable.js'; 5 - import { MockWritable } from '../mock-writable.js'; 4 + import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; 6 5 7 6 describe('ConfirmPrompt', () => { 8 - let input: MockReadable; 9 - let output: MockWritable; 7 + let mocks: Mocks<{ input: true; output: true }>; 10 8 11 9 beforeEach(() => { 12 - input = new MockReadable(); 13 - output = new MockWritable(); 14 - }); 15 - 16 - afterEach(() => { 17 - vi.restoreAllMocks(); 10 + mocks = createMocks({ input: true, output: true }); 18 11 }); 19 12 20 13 test('renders render() result', () => { 21 14 const instance = new ConfirmPrompt({ 22 - input, 23 - output, 15 + input: mocks.input, 16 + output: mocks.output, 24 17 render: () => 'foo', 25 18 active: 'yes', 26 19 inactive: 'no', 27 20 }); 28 21 instance.prompt(); 29 - expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); 22 + expect(mocks.output.buffer).to.deep.equal([cursor.hide, 'foo']); 30 23 }); 31 24 32 25 test('sets value and submits on confirm (y)', () => { 33 26 const instance = new ConfirmPrompt({ 34 - input, 35 - output, 27 + input: mocks.input, 28 + output: mocks.output, 36 29 render: () => 'foo', 37 30 active: 'yes', 38 31 inactive: 'no', ··· 40 33 }); 41 34 42 35 instance.prompt(); 43 - input.emit('keypress', 'y', { name: 'y' }); 36 + mocks.input.emit('keypress', 'y', { name: 'y' }); 44 37 45 38 expect(instance.value).to.equal(true); 46 39 expect(instance.state).to.equal('submit'); ··· 48 41 49 42 test('sets value and submits on confirm (n)', () => { 50 43 const instance = new ConfirmPrompt({ 51 - input, 52 - output, 44 + input: mocks.input, 45 + output: mocks.output, 53 46 render: () => 'foo', 54 47 active: 'yes', 55 48 inactive: 'no', ··· 57 50 }); 58 51 59 52 instance.prompt(); 60 - input.emit('keypress', 'n', { name: 'n' }); 53 + mocks.input.emit('keypress', 'n', { name: 'n' }); 61 54 62 55 expect(instance.value).to.equal(false); 63 56 expect(instance.state).to.equal('submit'); ··· 66 59 describe('cursor', () => { 67 60 test('cursor is 1 when inactive', () => { 68 61 const instance = new ConfirmPrompt({ 69 - input, 70 - output, 62 + input: mocks.input, 63 + output: mocks.output, 71 64 render: () => 'foo', 72 65 active: 'yes', 73 66 inactive: 'no', ··· 75 68 }); 76 69 77 70 instance.prompt(); 78 - input.emit('keypress', '', { name: 'return' }); 71 + mocks.input.emit('keypress', '', { name: 'return' }); 79 72 expect(instance.cursor).to.equal(1); 80 73 }); 81 74 82 75 test('cursor is 0 when active', () => { 83 76 const instance = new ConfirmPrompt({ 84 - input, 85 - output, 77 + input: mocks.input, 78 + output: mocks.output, 86 79 render: () => 'foo', 87 80 active: 'yes', 88 81 inactive: 'no', ··· 90 83 }); 91 84 92 85 instance.prompt(); 93 - input.emit('keypress', '', { name: 'return' }); 86 + mocks.input.emit('keypress', '', { name: 'return' }); 94 87 expect(instance.cursor).to.equal(0); 95 88 }); 96 89 });
+87 -94
packages/core/src/prompts/date.test.ts
··· 1 1 import { cursor } from 'sisteransi'; 2 - import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; 2 + import { beforeEach, describe, expect, test } from 'vitest'; 3 3 import type { DateFormatConfig, DateParts } from './date.js'; 4 4 import { default as DatePrompt } from './date.js'; 5 5 import { isCancel } from '../utils/index.js'; 6 - import { MockReadable } from '../mock-readable.js'; 7 - import { MockWritable } from '../mock-writable.js'; 6 + import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; 8 7 9 8 function buildFormatConfig( 10 9 format: (p: DateParts) => string, ··· 36 35 }; 37 36 38 37 describe('DatePrompt', () => { 39 - let input: MockReadable; 40 - let output: MockWritable; 38 + let mocks: Mocks<{ input: true; output: true }>; 41 39 42 40 beforeEach(() => { 43 - input = new MockReadable(); 44 - output = new MockWritable(); 45 - }); 46 - 47 - afterEach(() => { 48 - vi.restoreAllMocks(); 41 + mocks = createMocks({ input: true, output: true }); 49 42 }); 50 43 51 44 test('renders render() result', () => { 52 45 const instance = new DatePrompt({ 53 - input, 54 - output, 46 + input: mocks.input, 47 + output: mocks.output, 55 48 render: () => 'foo', 56 49 formatConfig: YYYY_MM_DD, 57 50 }); 58 51 instance.prompt(); 59 - expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); 52 + expect(mocks.output.buffer).to.deep.equal([cursor.hide, 'foo']); 60 53 }); 61 54 62 55 test('initial value displays correctly', () => { 63 56 const instance = new DatePrompt({ 64 - input, 65 - output, 57 + input: mocks.input, 58 + output: mocks.output, 66 59 render: () => 'foo', 67 60 formatConfig: YYYY_MM_DD, 68 61 initialValue: d('2025-01-15'), ··· 75 68 76 69 test('left/right navigates between segments', () => { 77 70 const instance = new DatePrompt({ 78 - input, 79 - output, 71 + input: mocks.input, 72 + output: mocks.output, 80 73 render: () => 'foo', 81 74 formatConfig: YYYY_MM_DD, 82 75 initialValue: d('2025-01-15'), ··· 88 81 }); 89 82 // Move within year (0->1->2->3), then right from end goes to month 90 83 for (let i = 0; i < 4; i++) { 91 - input.emit('keypress', undefined, { name: 'right' }); 84 + mocks.input.emit('keypress', undefined, { name: 'right' }); 92 85 } 93 86 expect(instance.segmentCursor).to.deep.equal({ 94 87 segmentIndex: 1, 95 88 positionInSegment: 0, 96 89 }); 97 90 for (let i = 0; i < 2; i++) { 98 - input.emit('keypress', undefined, { name: 'right' }); 91 + mocks.input.emit('keypress', undefined, { name: 'right' }); 99 92 } 100 93 expect(instance.segmentCursor).to.deep.equal({ 101 94 segmentIndex: 2, 102 95 positionInSegment: 0, 103 96 }); 104 - input.emit('keypress', undefined, { name: 'left' }); 97 + mocks.input.emit('keypress', undefined, { name: 'left' }); 105 98 expect(instance.segmentCursor).to.deep.equal({ 106 99 segmentIndex: 1, 107 100 positionInSegment: 0, ··· 110 103 111 104 test('up/down increments and decrements segment', () => { 112 105 const instance = new DatePrompt({ 113 - input, 114 - output, 106 + input: mocks.input, 107 + output: mocks.output, 115 108 render: () => 'foo', 116 109 formatConfig: YYYY_MM_DD, 117 110 initialValue: d('2025-01-15'), 118 111 }); 119 112 instance.prompt(); 120 - for (let i = 0; i < 4; i++) input.emit('keypress', undefined, { name: 'right' }); // move to month 121 - input.emit('keypress', undefined, { name: 'up' }); 113 + for (let i = 0; i < 4; i++) mocks.input.emit('keypress', undefined, { name: 'right' }); // move to month 114 + mocks.input.emit('keypress', undefined, { name: 'up' }); 122 115 expect(instance.userInput).to.equal('2025/02/15'); 123 - input.emit('keypress', undefined, { name: 'down' }); 116 + mocks.input.emit('keypress', undefined, { name: 'down' }); 124 117 expect(instance.userInput).to.equal('2025/01/15'); 125 118 }); 126 119 127 120 test('up/down on one segment leaves other segments blank', () => { 128 121 const instance = new DatePrompt({ 129 - input, 130 - output, 122 + input: mocks.input, 123 + output: mocks.output, 131 124 render: () => 'foo', 132 125 formatConfig: YYYY_MM_DD, 133 126 }); 134 127 instance.prompt(); 135 128 expect(instance.userInput).to.equal('____/__/__'); 136 - input.emit('keypress', undefined, { name: 'up' }); // up on year (first segment) 129 + mocks.input.emit('keypress', undefined, { name: 'up' }); // up on year (first segment) 137 130 expect(instance.userInput).to.equal('1000/__/__'); 138 - input.emit('keypress', undefined, { name: 'right' }); 139 - input.emit('keypress', undefined, { name: 'right' }); 140 - input.emit('keypress', undefined, { name: 'right' }); 141 - input.emit('keypress', undefined, { name: 'right' }); // move to month 142 - input.emit('keypress', undefined, { name: 'up' }); 131 + mocks.input.emit('keypress', undefined, { name: 'right' }); 132 + mocks.input.emit('keypress', undefined, { name: 'right' }); 133 + mocks.input.emit('keypress', undefined, { name: 'right' }); 134 + mocks.input.emit('keypress', undefined, { name: 'right' }); // move to month 135 + mocks.input.emit('keypress', undefined, { name: 'up' }); 143 136 expect(instance.userInput).to.equal('1000/01/__'); 144 137 }); 145 138 146 139 test('with minDate/maxDate, up on blank segment starts at min', () => { 147 140 const instance = new DatePrompt({ 148 - input, 149 - output, 141 + input: mocks.input, 142 + output: mocks.output, 150 143 render: () => 'foo', 151 144 formatConfig: YYYY_MM_DD, 152 145 minDate: d('2025-03-10'), ··· 154 147 }); 155 148 instance.prompt(); 156 149 expect(instance.userInput).to.equal('____/__/__'); 157 - input.emit('keypress', undefined, { name: 'up' }); 150 + mocks.input.emit('keypress', undefined, { name: 'up' }); 158 151 expect(instance.userInput).to.equal('2025/__/__'); 159 - for (let i = 0; i < 4; i++) input.emit('keypress', undefined, { name: 'right' }); 160 - input.emit('keypress', undefined, { name: 'up' }); 152 + for (let i = 0; i < 4; i++) mocks.input.emit('keypress', undefined, { name: 'right' }); 153 + mocks.input.emit('keypress', undefined, { name: 'up' }); 161 154 expect(instance.userInput).to.equal('2025/03/__'); 162 - for (let i = 0; i < 2; i++) input.emit('keypress', undefined, { name: 'right' }); 163 - input.emit('keypress', undefined, { name: 'up' }); 155 + for (let i = 0; i < 2; i++) mocks.input.emit('keypress', undefined, { name: 'right' }); 156 + mocks.input.emit('keypress', undefined, { name: 'up' }); 164 157 expect(instance.userInput).to.equal('2025/03/10'); 165 158 }); 166 159 167 160 test('with minDate/maxDate, down on blank segment starts at max', () => { 168 161 const instance = new DatePrompt({ 169 - input, 170 - output, 162 + input: mocks.input, 163 + output: mocks.output, 171 164 render: () => 'foo', 172 165 formatConfig: YYYY_MM_DD, 173 166 minDate: d('2025-03-10'), 174 167 maxDate: d('2025-11-20'), 175 168 }); 176 169 instance.prompt(); 177 - input.emit('keypress', undefined, { name: 'down' }); 170 + mocks.input.emit('keypress', undefined, { name: 'down' }); 178 171 expect(instance.userInput).to.equal('2025/__/__'); 179 - for (let i = 0; i < 4; i++) input.emit('keypress', undefined, { name: 'right' }); 180 - input.emit('keypress', undefined, { name: 'down' }); 172 + for (let i = 0; i < 4; i++) mocks.input.emit('keypress', undefined, { name: 'right' }); 173 + mocks.input.emit('keypress', undefined, { name: 'down' }); 181 174 expect(instance.userInput).to.equal('2025/11/__'); 182 - for (let i = 0; i < 2; i++) input.emit('keypress', undefined, { name: 'right' }); 183 - input.emit('keypress', undefined, { name: 'down' }); 175 + for (let i = 0; i < 2; i++) mocks.input.emit('keypress', undefined, { name: 'right' }); 176 + mocks.input.emit('keypress', undefined, { name: 'down' }); 184 177 expect(instance.userInput).to.equal('2025/11/20'); 185 178 }); 186 179 187 180 test('digit-by-digit editing from left to right', () => { 188 181 const instance = new DatePrompt({ 189 - input, 190 - output, 182 + input: mocks.input, 183 + output: mocks.output, 191 184 render: () => 'foo', 192 185 formatConfig: YYYY_MM_DD, 193 186 initialValue: d('2025-01-15'), ··· 198 191 positionInSegment: 0, 199 192 }); 200 193 // Type 2,0,2,3 to change 2025 -> 2023 (edit digit by digit) 201 - input.emit('keypress', '2', { name: undefined, sequence: '2' }); 202 - input.emit('keypress', '0', { name: undefined, sequence: '0' }); 203 - input.emit('keypress', '2', { name: undefined, sequence: '2' }); 204 - input.emit('keypress', '3', { name: undefined, sequence: '3' }); 194 + mocks.input.emit('keypress', '2', { name: undefined, sequence: '2' }); 195 + mocks.input.emit('keypress', '0', { name: undefined, sequence: '0' }); 196 + mocks.input.emit('keypress', '2', { name: undefined, sequence: '2' }); 197 + mocks.input.emit('keypress', '3', { name: undefined, sequence: '3' }); 205 198 expect(instance.userInput).to.equal('2023/01/15'); 206 199 expect(instance.segmentCursor).to.deep.equal({ 207 200 segmentIndex: 0, ··· 211 204 212 205 test('backspace clears entire segment at any cursor position', () => { 213 206 const instance = new DatePrompt({ 214 - input, 215 - output, 207 + input: mocks.input, 208 + output: mocks.output, 216 209 render: () => 'foo', 217 210 formatConfig: YYYY_MM_DD, 218 211 initialValue: d('2025-12-21'), ··· 224 217 positionInSegment: 0, 225 218 }); 226 219 // Backspace at first position clears whole year segment 227 - input.emit('keypress', undefined, { name: 'backspace', sequence: '\x7f' }); 220 + mocks.input.emit('keypress', undefined, { name: 'backspace', sequence: '\x7f' }); 228 221 expect(instance.userInput).to.equal('____/12/21'); 229 222 expect(instance.segmentCursor).to.deep.equal({ 230 223 segmentIndex: 0, ··· 234 227 235 228 test('backspace clears segment when cursor at first char (2___)', () => { 236 229 const instance = new DatePrompt({ 237 - input, 238 - output, 230 + input: mocks.input, 231 + output: mocks.output, 239 232 render: () => 'foo', 240 233 formatConfig: YYYY_MM_DD, 241 234 }); 242 235 instance.prompt(); 243 236 // Type "2" to get "2___" 244 - input.emit('keypress', '2', { name: undefined, sequence: '2' }); 237 + mocks.input.emit('keypress', '2', { name: undefined, sequence: '2' }); 245 238 expect(instance.userInput).to.equal('2___/__/__'); 246 239 expect(instance.segmentCursor).to.deep.equal({ 247 240 segmentIndex: 0, 248 241 positionInSegment: 1, 249 242 }); 250 243 // Move to first char (position 0) 251 - input.emit('keypress', undefined, { name: 'left' }); 244 + mocks.input.emit('keypress', undefined, { name: 'left' }); 252 245 expect(instance.segmentCursor).to.deep.equal({ 253 246 segmentIndex: 0, 254 247 positionInSegment: 0, 255 248 }); 256 249 // Backspace should clear whole segment - also test char-based detection 257 - input.emit('keypress', '\x7f', { name: undefined, sequence: '\x7f' }); 250 + mocks.input.emit('keypress', '\x7f', { name: undefined, sequence: '\x7f' }); 258 251 expect(instance.userInput).to.equal('____/__/__'); 259 252 expect(instance.segmentCursor).to.deep.equal({ 260 253 segmentIndex: 0, ··· 264 257 265 258 test('digit input updates segment and jumps to next when complete', () => { 266 259 const instance = new DatePrompt({ 267 - input, 268 - output, 260 + input: mocks.input, 261 + output: mocks.output, 269 262 render: () => 'foo', 270 263 formatConfig: YYYY_MM_DD, 271 264 }); 272 265 instance.prompt(); 273 266 // Type year 2025 - left-to-right, jumps to month when year complete 274 267 for (const c of '2025') { 275 - input.emit('keypress', c, { name: undefined, sequence: c }); 268 + mocks.input.emit('keypress', c, { name: undefined, sequence: c }); 276 269 } 277 270 expect(instance.userInput).to.equal('2025/__/__'); 278 271 expect(instance.segmentCursor).to.deep.equal({ ··· 283 276 284 277 test('submit returns ISO string for valid date', async () => { 285 278 const instance = new DatePrompt({ 286 - input, 287 - output, 279 + input: mocks.input, 280 + output: mocks.output, 288 281 render: () => 'foo', 289 282 formatConfig: YYYY_MM_DD, 290 283 initialValue: d('2025-01-31'), 291 284 }); 292 285 const resultPromise = instance.prompt(); 293 - input.emit('keypress', undefined, { name: 'return' }); 286 + mocks.input.emit('keypress', undefined, { name: 'return' }); 294 287 const result = await resultPromise; 295 288 expect(result).toBeInstanceOf(Date); 296 289 expect((result as Date).toISOString().slice(0, 10)).to.equal('2025-01-31'); ··· 298 291 299 292 test('can cancel', async () => { 300 293 const instance = new DatePrompt({ 301 - input, 302 - output, 294 + input: mocks.input, 295 + output: mocks.output, 303 296 render: () => 'foo', 304 297 formatConfig: YYYY_MM_DD, 305 298 initialValue: d('2025-01-15'), 306 299 }); 307 300 const resultPromise = instance.prompt(); 308 - input.emit('keypress', 'escape', { name: 'escape' }); 301 + mocks.input.emit('keypress', 'escape', { name: 'escape' }); 309 302 const result = await resultPromise; 310 303 expect(isCancel(result)).toBe(true); 311 304 }); 312 305 313 306 test('defaultValue used when invalid date submitted', async () => { 314 307 const instance = new DatePrompt({ 315 - input, 316 - output, 308 + input: mocks.input, 309 + output: mocks.output, 317 310 render: () => 'foo', 318 311 formatConfig: YYYY_MM_DD, 319 312 defaultValue: d('2025-06-15'), 320 313 }); 321 314 const resultPromise = instance.prompt(); 322 - input.emit('keypress', undefined, { name: 'return' }); 315 + mocks.input.emit('keypress', undefined, { name: 'return' }); 323 316 const result = await resultPromise; 324 317 expect(result).toBeInstanceOf(Date); 325 318 expect((result as Date).toISOString().slice(0, 10)).to.equal('2025-06-15'); ··· 327 320 328 321 test('supports MM/DD/YYYY format', () => { 329 322 const instance = new DatePrompt({ 330 - input, 331 - output, 323 + input: mocks.input, 324 + output: mocks.output, 332 325 render: () => 'foo', 333 326 formatConfig: MM_DD_YYYY, 334 327 initialValue: d('2025-01-15'), ··· 339 332 340 333 test('rejects invalid month and shows inline error', () => { 341 334 const instance = new DatePrompt({ 342 - input, 343 - output, 335 + input: mocks.input, 336 + output: mocks.output, 344 337 render: () => 'foo', 345 338 formatConfig: YYYY_MM_DD, 346 339 initialValue: d('2025-01-15'), // month is 01 347 340 }); 348 341 instance.prompt(); 349 - for (let i = 0; i < 4; i++) input.emit('keypress', undefined, { name: 'right' }); // move to month (cursor at start) 350 - input.emit('keypress', '3', { name: undefined, sequence: '3' }); // 0→3 gives 31, invalid 342 + for (let i = 0; i < 4; i++) mocks.input.emit('keypress', undefined, { name: 'right' }); // move to month (cursor at start) 343 + mocks.input.emit('keypress', '3', { name: undefined, sequence: '3' }); // 0→3 gives 31, invalid 351 344 expect(instance.userInput).to.equal('2025/01/15'); // stayed - 31 rejected 352 345 expect(instance.inlineError).to.equal('There are only 12 months in a year'); 353 346 }); 354 347 355 348 test('rejects invalid day and shows inline error', () => { 356 349 const instance = new DatePrompt({ 357 - input, 358 - output, 350 + input: mocks.input, 351 + output: mocks.output, 359 352 render: () => 'foo', 360 353 formatConfig: YYYY_MM_DD, 361 354 initialValue: d('2025-01-15'), // January has 31 days 362 355 }); 363 356 instance.prompt(); 364 - for (let i = 0; i < 6; i++) input.emit('keypress', undefined, { name: 'right' }); // move to day (cursor at start) 365 - input.emit('keypress', '4', { name: undefined, sequence: '4' }); // 1→4 gives 45, invalid for Jan 357 + for (let i = 0; i < 6; i++) mocks.input.emit('keypress', undefined, { name: 'right' }); // move to day (cursor at start) 358 + mocks.input.emit('keypress', '4', { name: undefined, sequence: '4' }); // 1→4 gives 45, invalid for Jan 366 359 expect(instance.userInput).to.equal('2025/01/15'); // stayed - 45 rejected 367 360 expect(instance.inlineError).to.contain('31 days'); 368 361 expect(instance.inlineError).to.contain('January'); ··· 370 363 371 364 test('supports DD/MM/YYYY format', () => { 372 365 const instance = new DatePrompt({ 373 - input, 374 - output, 366 + input: mocks.input, 367 + output: mocks.output, 375 368 render: () => 'foo', 376 369 formatConfig: DD_MM_YYYY, 377 370 initialValue: d('2025-01-15'), ··· 383 376 describe('segmentValues and segmentCursor', () => { 384 377 test('segmentValues reflects current input', () => { 385 378 const instance = new DatePrompt({ 386 - input, 387 - output, 379 + input: mocks.input, 380 + output: mocks.output, 388 381 render: () => 'foo', 389 382 formatConfig: YYYY_MM_DD, 390 383 initialValue: d('2025-01-15'), ··· 398 391 399 392 test('segmentCursor tracks cursor position', () => { 400 393 const instance = new DatePrompt({ 401 - input, 402 - output, 394 + input: mocks.input, 395 + output: mocks.output, 403 396 render: () => 'foo', 404 397 formatConfig: YYYY_MM_DD, 405 398 initialValue: d('2025-01-15'), 406 399 }); 407 400 instance.prompt(); 408 - for (let i = 0; i < 4; i++) input.emit('keypress', undefined, { name: 'right' }); // move to month 401 + for (let i = 0; i < 4; i++) mocks.input.emit('keypress', undefined, { name: 'right' }); // move to month 409 402 const cursor = instance.segmentCursor; 410 403 expect(cursor.segmentIndex).to.equal(1); // month segment 411 404 expect(cursor.positionInSegment).to.equal(0); // start of segment ··· 413 406 414 407 test('segmentValues updates on submit', () => { 415 408 const instance = new DatePrompt({ 416 - input, 417 - output, 409 + input: mocks.input, 410 + output: mocks.output, 418 411 render: () => 'foo', 419 412 formatConfig: YYYY_MM_DD, 420 413 initialValue: d('2025-01-15'), 421 414 }); 422 415 instance.prompt(); 423 - input.emit('keypress', undefined, { name: 'return' }); 416 + mocks.input.emit('keypress', undefined, { name: 'return' }); 424 417 const segmentValues = instance.segmentValues; 425 418 expect(segmentValues.year).to.equal('2025'); 426 419 expect(segmentValues.month).to.equal('01');
+47 -54
packages/core/src/prompts/multi-select.test.ts
··· 1 1 import { cursor } from 'sisteransi'; 2 - import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; 2 + import { beforeEach, describe, expect, test } from 'vitest'; 3 3 import { default as MultiSelectPrompt } from './multi-select.js'; 4 - import { MockReadable } from '../mock-readable.js'; 5 - import { MockWritable } from '../mock-writable.js'; 4 + import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; 6 5 7 6 describe('MultiSelectPrompt', () => { 8 - let input: MockReadable; 9 - let output: MockWritable; 7 + let mocks: Mocks<{ input: true; output: true }>; 10 8 11 9 beforeEach(() => { 12 - input = new MockReadable(); 13 - output = new MockWritable(); 14 - }); 15 - 16 - afterEach(() => { 17 - vi.restoreAllMocks(); 10 + mocks = createMocks({ input: true, output: true }); 18 11 }); 19 12 20 13 test('renders render() result', () => { 21 14 const instance = new MultiSelectPrompt({ 22 - input, 23 - output, 15 + input: mocks.input, 16 + output: mocks.output, 24 17 render: () => 'foo', 25 18 options: [{ value: 'foo' }, { value: 'bar' }], 26 19 }); 27 20 instance.prompt(); 28 - expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); 21 + expect(mocks.output.buffer).to.deep.equal([cursor.hide, 'foo']); 29 22 }); 30 23 31 24 describe('cursor', () => { 32 25 test('cursor is index of selected item', () => { 33 26 const instance = new MultiSelectPrompt({ 34 - input, 35 - output, 27 + input: mocks.input, 28 + output: mocks.output, 36 29 render: () => 'foo', 37 30 options: [{ value: 'foo' }, { value: 'bar' }], 38 31 }); ··· 40 33 instance.prompt(); 41 34 42 35 expect(instance.cursor).to.equal(0); 43 - input.emit('keypress', 'down', { name: 'down' }); 36 + mocks.input.emit('keypress', 'down', { name: 'down' }); 44 37 expect(instance.cursor).to.equal(1); 45 38 }); 46 39 47 40 test('cursor loops around', () => { 48 41 const instance = new MultiSelectPrompt({ 49 - input, 50 - output, 42 + input: mocks.input, 43 + output: mocks.output, 51 44 render: () => 'foo', 52 45 options: [{ value: 'foo' }, { value: 'bar' }, { value: 'baz' }], 53 46 }); ··· 55 48 instance.prompt(); 56 49 57 50 expect(instance.cursor).to.equal(0); 58 - input.emit('keypress', 'up', { name: 'up' }); 51 + mocks.input.emit('keypress', 'up', { name: 'up' }); 59 52 expect(instance.cursor).to.equal(2); 60 - input.emit('keypress', 'down', { name: 'down' }); 53 + mocks.input.emit('keypress', 'down', { name: 'down' }); 61 54 expect(instance.cursor).to.equal(0); 62 55 }); 63 56 64 57 test('left behaves as up', () => { 65 58 const instance = new MultiSelectPrompt({ 66 - input, 67 - output, 59 + input: mocks.input, 60 + output: mocks.output, 68 61 render: () => 'foo', 69 62 options: [{ value: 'foo' }, { value: 'bar' }, { value: 'baz' }], 70 63 }); 71 64 72 65 instance.prompt(); 73 66 74 - input.emit('keypress', 'left', { name: 'left' }); 67 + mocks.input.emit('keypress', 'left', { name: 'left' }); 75 68 expect(instance.cursor).to.equal(2); 76 69 }); 77 70 78 71 test('right behaves as down', () => { 79 72 const instance = new MultiSelectPrompt({ 80 - input, 81 - output, 73 + input: mocks.input, 74 + output: mocks.output, 82 75 render: () => 'foo', 83 76 options: [{ value: 'foo' }, { value: 'bar' }], 84 77 }); 85 78 86 79 instance.prompt(); 87 80 88 - input.emit('keypress', 'left', { name: 'left' }); 81 + mocks.input.emit('keypress', 'left', { name: 'left' }); 89 82 expect(instance.cursor).to.equal(1); 90 83 }); 91 84 92 85 test('initial values is selected', () => { 93 86 const instance = new MultiSelectPrompt({ 94 - input, 95 - output, 87 + input: mocks.input, 88 + output: mocks.output, 96 89 render: () => 'foo', 97 90 options: [{ value: 'foo' }, { value: 'bar' }], 98 91 initialValues: ['bar'], ··· 103 96 104 97 test('select all when press "a" key', () => { 105 98 const instance = new MultiSelectPrompt({ 106 - input, 107 - output, 99 + input: mocks.input, 100 + output: mocks.output, 108 101 render: () => 'foo', 109 102 options: [{ value: 'foo' }, { value: 'bar' }], 110 103 }); 111 104 instance.prompt(); 112 105 113 - input.emit('keypress', 'down', { name: 'down' }); 114 - input.emit('keypress', 'space', { name: 'space' }); 115 - input.emit('keypress', 'a', { name: 'a' }); 106 + mocks.input.emit('keypress', 'down', { name: 'down' }); 107 + mocks.input.emit('keypress', 'space', { name: 'space' }); 108 + mocks.input.emit('keypress', 'a', { name: 'a' }); 116 109 expect(instance.value).toEqual(['foo', 'bar']); 117 110 }); 118 111 119 112 test('select invert when press "i" key', () => { 120 113 const instance = new MultiSelectPrompt({ 121 - input, 122 - output, 114 + input: mocks.input, 115 + output: mocks.output, 123 116 render: () => 'foo', 124 117 options: [{ value: 'foo' }, { value: 'bar' }], 125 118 }); 126 119 instance.prompt(); 127 120 128 - input.emit('keypress', 'down', { name: 'down' }); 129 - input.emit('keypress', 'space', { name: 'space' }); 130 - input.emit('keypress', 'i', { name: 'i' }); 121 + mocks.input.emit('keypress', 'down', { name: 'down' }); 122 + mocks.input.emit('keypress', 'space', { name: 'space' }); 123 + mocks.input.emit('keypress', 'i', { name: 'i' }); 131 124 expect(instance.value).toEqual(['foo']); 132 125 }); 133 126 134 127 test('disabled options are skipped', () => { 135 128 const instance = new MultiSelectPrompt({ 136 - input, 137 - output, 129 + input: mocks.input, 130 + output: mocks.output, 138 131 render: () => 'foo', 139 132 options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }], 140 133 }); 141 134 instance.prompt(); 142 135 143 136 expect(instance.cursor).to.equal(0); 144 - input.emit('keypress', 'down', { name: 'down' }); 137 + mocks.input.emit('keypress', 'down', { name: 'down' }); 145 138 expect(instance.cursor).to.equal(2); 146 - input.emit('keypress', 'up', { name: 'up' }); 139 + mocks.input.emit('keypress', 'up', { name: 'up' }); 147 140 expect(instance.cursor).to.equal(0); 148 141 }); 149 142 150 143 test('initial cursorAt on disabled option', () => { 151 144 const instance = new MultiSelectPrompt({ 152 - input, 153 - output, 145 + input: mocks.input, 146 + output: mocks.output, 154 147 render: () => 'foo', 155 148 options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }], 156 149 cursorAt: 'bar', ··· 164 157 describe('toggleAll', () => { 165 158 test('selects all enabled options', () => { 166 159 const instance = new MultiSelectPrompt({ 167 - input, 168 - output, 160 + input: mocks.input, 161 + output: mocks.output, 169 162 render: () => 'foo', 170 163 options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }], 171 164 }); 172 165 instance.prompt(); 173 166 174 - input.emit('keypress', 'a', { name: 'a' }); 167 + mocks.input.emit('keypress', 'a', { name: 'a' }); 175 168 expect(instance.value).toEqual(['foo', 'baz']); 176 169 }); 177 170 178 171 test('unselects all enabled options if all selected', () => { 179 172 const instance = new MultiSelectPrompt({ 180 - input, 181 - output, 173 + input: mocks.input, 174 + output: mocks.output, 182 175 render: () => 'foo', 183 176 options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }], 184 177 initialValues: ['foo', 'baz'], 185 178 }); 186 179 instance.prompt(); 187 180 188 - input.emit('keypress', 'a', { name: 'a' }); 181 + mocks.input.emit('keypress', 'a', { name: 'a' }); 189 182 expect(instance.value).toEqual([]); 190 183 }); 191 184 }); ··· 193 186 describe('toggleInvert', () => { 194 187 test('inverts selection of enabled options', () => { 195 188 const instance = new MultiSelectPrompt({ 196 - input, 197 - output, 189 + input: mocks.input, 190 + output: mocks.output, 198 191 render: () => 'foo', 199 192 options: [ 200 193 { value: 'foo' }, ··· 206 199 }); 207 200 instance.prompt(); 208 201 209 - input.emit('keypress', 'i', { name: 'i' }); 202 + mocks.input.emit('keypress', 'i', { name: 'i' }); 210 203 expect(instance.value).toEqual(['qux']); 211 204 }); 212 205 });
+26 -33
packages/core/src/prompts/password.test.ts
··· 1 1 import { styleText } from 'node:util'; 2 2 import { cursor } from 'sisteransi'; 3 - import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; 3 + import { beforeEach, describe, expect, test } from 'vitest'; 4 4 import { default as PasswordPrompt } from './password.js'; 5 - import { MockReadable } from '../mock-readable.js'; 6 - import { MockWritable } from '../mock-writable.js'; 5 + import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; 7 6 8 7 describe('PasswordPrompt', () => { 9 - let input: MockReadable; 10 - let output: MockWritable; 8 + let mocks: Mocks<{ input: true; output: true }>; 11 9 12 10 beforeEach(() => { 13 - input = new MockReadable(); 14 - output = new MockWritable(); 15 - }); 16 - 17 - afterEach(() => { 18 - vi.restoreAllMocks(); 11 + mocks = createMocks({ input: true, output: true }); 19 12 }); 20 13 21 14 test('renders render() result', () => { 22 15 const instance = new PasswordPrompt({ 23 - input, 24 - output, 16 + input: mocks.input, 17 + output: mocks.output, 25 18 render: () => 'foo', 26 19 }); 27 20 // leave the promise hanging since we don't want to submit in this test 28 21 instance.prompt(); 29 - expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); 22 + expect(mocks.output.buffer).to.deep.equal([cursor.hide, 'foo']); 30 23 }); 31 24 32 25 describe('cursor', () => { 33 26 test('can get cursor', () => { 34 27 const instance = new PasswordPrompt({ 35 - input, 36 - output, 28 + input: mocks.input, 29 + output: mocks.output, 37 30 render: () => 'foo', 38 31 }); 39 32 ··· 44 37 describe('userInputWithCursor', () => { 45 38 test('returns masked value on submit', () => { 46 39 const instance = new PasswordPrompt({ 47 - input, 48 - output, 40 + input: mocks.input, 41 + output: mocks.output, 49 42 render: () => 'foo', 50 43 }); 51 44 instance.prompt(); 52 45 const keys = 'foo'; 53 46 for (let i = 0; i < keys.length; i++) { 54 - input.emit('keypress', keys[i], { name: keys[i] }); 47 + mocks.input.emit('keypress', keys[i], { name: keys[i] }); 55 48 } 56 - input.emit('keypress', '', { name: 'return' }); 49 + mocks.input.emit('keypress', '', { name: 'return' }); 57 50 expect(instance.userInputWithCursor).to.equal('•••'); 58 51 }); 59 52 60 53 test('renders marker at end', () => { 61 54 const instance = new PasswordPrompt({ 62 - input, 63 - output, 55 + input: mocks.input, 56 + output: mocks.output, 64 57 render: () => 'foo', 65 58 }); 66 59 instance.prompt(); 67 - input.emit('keypress', 'x', { name: 'x' }); 60 + mocks.input.emit('keypress', 'x', { name: 'x' }); 68 61 expect(instance.userInputWithCursor).to.equal(`•${styleText(['inverse', 'hidden'], '_')}`); 69 62 }); 70 63 71 64 test('renders cursor inside value', () => { 72 65 const instance = new PasswordPrompt({ 73 - input, 74 - output, 66 + input: mocks.input, 67 + output: mocks.output, 75 68 render: () => 'foo', 76 69 }); 77 70 instance.prompt(); 78 - input.emit('keypress', 'x', { name: 'x' }); 79 - input.emit('keypress', 'y', { name: 'y' }); 80 - input.emit('keypress', 'z', { name: 'z' }); 81 - input.emit('keypress', undefined, { name: 'left' }); 82 - input.emit('keypress', undefined, { name: 'left' }); 71 + mocks.input.emit('keypress', 'x', { name: 'x' }); 72 + mocks.input.emit('keypress', 'y', { name: 'y' }); 73 + mocks.input.emit('keypress', 'z', { name: 'z' }); 74 + mocks.input.emit('keypress', undefined, { name: 'left' }); 75 + mocks.input.emit('keypress', undefined, { name: 'left' }); 83 76 expect(instance.userInputWithCursor).to.equal(`•${styleText('inverse', '•')}•`); 84 77 }); 85 78 86 79 test('renders custom mask', () => { 87 80 const instance = new PasswordPrompt({ 88 - input, 89 - output, 81 + input: mocks.input, 82 + output: mocks.output, 90 83 render: () => 'foo', 91 84 mask: 'X', 92 85 }); 93 86 instance.prompt(); 94 - input.emit('keypress', 'x', { name: 'x' }); 87 + mocks.input.emit('keypress', 'x', { name: 'x' }); 95 88 expect(instance.userInputWithCursor).to.equal(`X${styleText(['inverse', 'hidden'], '_')}`); 96 89 }); 97 90 });
+55 -62
packages/core/src/prompts/prompt.test.ts
··· 1 1 import { cursor } from 'sisteransi'; 2 - import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; 2 + import { beforeEach, describe, expect, test, vi } from 'vitest'; 3 3 import { default as Prompt } from './prompt.js'; 4 4 import { isCancel } from '../utils/index.js'; 5 - import { MockReadable } from '../mock-readable.js'; 6 - import { MockWritable } from '../mock-writable.js'; 5 + import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; 7 6 8 7 describe('Prompt', () => { 9 - let input: MockReadable; 10 - let output: MockWritable; 8 + let mocks: Mocks<{ input: true; output: true }>; 11 9 12 10 beforeEach(() => { 13 - input = new MockReadable(); 14 - output = new MockWritable(); 15 - }); 16 - 17 - afterEach(() => { 18 - vi.restoreAllMocks(); 11 + mocks = createMocks({ input: true, output: true }); 19 12 }); 20 13 21 14 test('renders render() result', () => { 22 15 const instance = new Prompt({ 23 - input, 24 - output, 16 + input: mocks.input, 17 + output: mocks.output, 25 18 render: () => 'foo', 26 19 }); 27 20 // leave the promise hanging since we don't want to submit in this test 28 21 instance.prompt(); 29 - expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); 22 + expect(mocks.output.buffer).to.deep.equal([cursor.hide, 'foo']); 30 23 }); 31 24 32 25 test('submits on return key', async () => { 33 26 const instance = new Prompt({ 34 - input, 35 - output, 27 + input: mocks.input, 28 + output: mocks.output, 36 29 render: () => 'foo', 37 30 }); 38 31 const resultPromise = instance.prompt(); 39 - input.emit('keypress', '', { name: 'return' }); 32 + mocks.input.emit('keypress', '', { name: 'return' }); 40 33 const result = await resultPromise; 41 34 expect(result).to.equal(undefined); 42 35 expect(isCancel(result)).to.equal(false); 43 36 expect(instance.state).to.equal('submit'); 44 - expect(output.buffer).to.deep.equal([cursor.hide, 'foo', '\n', cursor.show]); 37 + expect(mocks.output.buffer).to.deep.equal([cursor.hide, 'foo', '\n', cursor.show]); 45 38 }); 46 39 47 40 test('cancels on ctrl-c', async () => { 48 41 const instance = new Prompt({ 49 - input, 50 - output, 42 + input: mocks.input, 43 + output: mocks.output, 51 44 render: () => 'foo', 52 45 }); 53 46 const resultPromise = instance.prompt(); 54 - input.emit('keypress', '\x03', { name: 'c' }); 47 + mocks.input.emit('keypress', '\x03', { name: 'c' }); 55 48 const result = await resultPromise; 56 49 expect(isCancel(result)).to.equal(true); 57 50 expect(instance.state).to.equal('cancel'); 58 - expect(output.buffer).to.deep.equal([cursor.hide, 'foo', '\n', cursor.show]); 51 + expect(mocks.output.buffer).to.deep.equal([cursor.hide, 'foo', '\n', cursor.show]); 59 52 }); 60 53 61 54 test('does not write initialValue to value', () => { 62 55 const eventSpy = vi.fn(); 63 56 const instance = new Prompt({ 64 - input, 65 - output, 57 + input: mocks.input, 58 + output: mocks.output, 66 59 render: () => 'foo', 67 60 initialValue: 'bananas', 68 61 }); ··· 75 68 test('re-renders on resize', () => { 76 69 const renderFn = vi.fn().mockImplementation(() => 'foo'); 77 70 const instance = new Prompt({ 78 - input, 79 - output, 71 + input: mocks.input, 72 + output: mocks.output, 80 73 render: renderFn, 81 74 }); 82 75 instance.prompt(); 83 76 84 77 expect(renderFn).toHaveBeenCalledTimes(1); 85 78 86 - output.emit('resize'); 79 + mocks.output.emit('resize'); 87 80 88 81 expect(renderFn).toHaveBeenCalledTimes(2); 89 82 }); 90 83 91 84 test('state is active after first render', async () => { 92 85 const instance = new Prompt({ 93 - input, 94 - output, 86 + input: mocks.input, 87 + output: mocks.output, 95 88 render: () => 'foo', 96 89 }); 97 90 ··· 105 98 test('emits truthy confirm on y press', () => { 106 99 const eventFn = vi.fn(); 107 100 const instance = new Prompt({ 108 - input, 109 - output, 101 + input: mocks.input, 102 + output: mocks.output, 110 103 render: () => 'foo', 111 104 }); 112 105 ··· 114 107 115 108 instance.prompt(); 116 109 117 - input.emit('keypress', 'y', { name: 'y' }); 110 + mocks.input.emit('keypress', 'y', { name: 'y' }); 118 111 119 112 expect(eventFn).toBeCalledWith(true); 120 113 }); ··· 122 115 test('emits falsey confirm on n press', () => { 123 116 const eventFn = vi.fn(); 124 117 const instance = new Prompt({ 125 - input, 126 - output, 118 + input: mocks.input, 119 + output: mocks.output, 127 120 render: () => 'foo', 128 121 }); 129 122 ··· 131 124 132 125 instance.prompt(); 133 126 134 - input.emit('keypress', 'n', { name: 'n' }); 127 + mocks.input.emit('keypress', 'n', { name: 'n' }); 135 128 136 129 expect(eventFn).toBeCalledWith(false); 137 130 }); ··· 139 132 test('emits key event for unknown chars', () => { 140 133 const eventSpy = vi.fn(); 141 134 const instance = new Prompt({ 142 - input, 143 - output, 135 + input: mocks.input, 136 + output: mocks.output, 144 137 render: () => 'foo', 145 138 }); 146 139 ··· 148 141 149 142 instance.prompt(); 150 143 151 - input.emit('keypress', 'z', { name: 'z' }); 144 + mocks.input.emit('keypress', 'z', { name: 'z' }); 152 145 153 146 expect(eventSpy).toBeCalledWith('z', { name: 'z' }); 154 147 }); ··· 157 150 const keys = ['up', 'down', 'left', 'right']; 158 151 const eventSpy = vi.fn(); 159 152 const instance = new Prompt({ 160 - input, 161 - output, 153 + input: mocks.input, 154 + output: mocks.output, 162 155 render: () => 'foo', 163 156 }); 164 157 ··· 167 160 instance.prompt(); 168 161 169 162 for (const key of keys) { 170 - input.emit('keypress', key, { name: key }); 163 + mocks.input.emit('keypress', key, { name: key }); 171 164 expect(eventSpy).toBeCalledWith(key); 172 165 } 173 166 }); ··· 182 175 const eventSpy = vi.fn(); 183 176 const instance = new Prompt( 184 177 { 185 - input, 186 - output, 178 + input: mocks.input, 179 + output: mocks.output, 187 180 render: () => 'foo', 188 181 }, 189 182 false ··· 194 187 instance.prompt(); 195 188 196 189 for (const [alias, key] of keys) { 197 - input.emit('keypress', alias, { name: alias }); 190 + mocks.input.emit('keypress', alias, { name: alias }); 198 191 expect(eventSpy).toBeCalledWith(key); 199 192 } 200 193 }); ··· 203 196 const abortController = new AbortController(); 204 197 205 198 const instance = new Prompt({ 206 - input, 207 - output, 199 + input: mocks.input, 200 + output: mocks.output, 208 201 render: () => 'foo', 209 202 signal: abortController.signal, 210 203 }); ··· 223 216 abortController.abort(); 224 217 225 218 const instance = new Prompt({ 226 - input, 227 - output, 219 + input: mocks.input, 220 + output: mocks.output, 228 221 render: () => 'foo', 229 222 signal: abortController.signal, 230 223 }); ··· 235 228 236 229 test('accepts invalid initial value', () => { 237 230 const instance = new Prompt({ 238 - input, 239 - output, 231 + input: mocks.input, 232 + output: mocks.output, 240 233 render: () => 'foo', 241 234 initialValue: 'invalid', 242 235 validate: (value) => (value === 'valid' ? undefined : 'must be valid'), ··· 249 242 250 243 test('validates value on return', () => { 251 244 const instance = new Prompt({ 252 - input, 253 - output, 245 + input: mocks.input, 246 + output: mocks.output, 254 247 render: () => 'foo', 255 248 validate: (value) => (value === 'valid' ? undefined : 'must be valid'), 256 249 }); ··· 258 251 259 252 instance.value = 'invalid'; 260 253 261 - input.emit('keypress', '', { name: 'return' }); 254 + mocks.input.emit('keypress', '', { name: 'return' }); 262 255 263 256 expect(instance.state).to.equal('error'); 264 257 expect(instance.error).to.equal('must be valid'); ··· 266 259 267 260 test('validates value with Error object', () => { 268 261 const instance = new Prompt({ 269 - input, 270 - output, 262 + input: mocks.input, 263 + output: mocks.output, 271 264 render: () => 'foo', 272 265 validate: (value) => (value === 'valid' ? undefined : new Error('must be valid')), 273 266 }); 274 267 instance.prompt(); 275 268 276 269 instance.value = 'invalid'; 277 - input.emit('keypress', '', { name: 'return' }); 270 + mocks.input.emit('keypress', '', { name: 'return' }); 278 271 279 272 expect(instance.state).to.equal('error'); 280 273 expect(instance.error).to.equal('must be valid'); ··· 282 275 283 276 test('validates value with regex validation', () => { 284 277 const instance = new Prompt<string>({ 285 - input, 286 - output, 278 + input: mocks.input, 279 + output: mocks.output, 287 280 render: () => 'foo', 288 281 validate: (value) => (/^[A-Z]+$/.test(value ?? '') ? undefined : 'Invalid value'), 289 282 }); 290 283 instance.prompt(); 291 284 292 285 instance.value = 'Invalid Value $$$'; 293 - input.emit('keypress', '', { name: 'return' }); 286 + mocks.input.emit('keypress', '', { name: 'return' }); 294 287 295 288 expect(instance.state).to.equal('error'); 296 289 expect(instance.error).to.equal('Invalid value'); ··· 298 291 299 292 test('accepts valid value with regex validation', () => { 300 293 const instance = new Prompt<string>({ 301 - input, 302 - output, 294 + input: mocks.input, 295 + output: mocks.output, 303 296 render: () => 'foo', 304 297 validate: (value) => (/^[A-Z]+$/.test(value ?? '') ? undefined : 'Invalid value'), 305 298 }); 306 299 instance.prompt(); 307 300 308 301 instance.value = 'VALID'; 309 - input.emit('keypress', '', { name: 'return' }); 302 + mocks.input.emit('keypress', '', { name: 'return' }); 310 303 311 304 expect(instance.state).to.equal('submit'); 312 305 expect(instance.error).to.equal('');
+30 -37
packages/core/src/prompts/select.test.ts
··· 1 1 import { cursor } from 'sisteransi'; 2 - import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; 2 + import { beforeEach, describe, expect, test } from 'vitest'; 3 3 import { default as SelectPrompt } from './select.js'; 4 - import { MockReadable } from '../mock-readable.js'; 5 - import { MockWritable } from '../mock-writable.js'; 4 + import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; 6 5 7 6 describe('SelectPrompt', () => { 8 - let input: MockReadable; 9 - let output: MockWritable; 7 + let mocks: Mocks<{ input: true; output: true }>; 10 8 11 9 beforeEach(() => { 12 - input = new MockReadable(); 13 - output = new MockWritable(); 14 - }); 15 - 16 - afterEach(() => { 17 - vi.restoreAllMocks(); 10 + mocks = createMocks({ input: true, output: true }); 18 11 }); 19 12 20 13 test('renders render() result', () => { 21 14 const instance = new SelectPrompt({ 22 - input, 23 - output, 15 + input: mocks.input, 16 + output: mocks.output, 24 17 render: () => 'foo', 25 18 options: [{ value: 'foo' }, { value: 'bar' }], 26 19 }); 27 20 instance.prompt(); 28 - expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); 21 + expect(mocks.output.buffer).to.deep.equal([cursor.hide, 'foo']); 29 22 }); 30 23 31 24 describe('cursor', () => { 32 25 test('cursor is index of selected item', () => { 33 26 const instance = new SelectPrompt({ 34 - input, 35 - output, 27 + input: mocks.input, 28 + output: mocks.output, 36 29 render: () => 'foo', 37 30 options: [{ value: 'foo' }, { value: 'bar' }], 38 31 }); ··· 40 33 instance.prompt(); 41 34 42 35 expect(instance.cursor).to.equal(0); 43 - input.emit('keypress', 'down', { name: 'down' }); 36 + mocks.input.emit('keypress', 'down', { name: 'down' }); 44 37 expect(instance.cursor).to.equal(1); 45 38 }); 46 39 47 40 test('cursor loops around', () => { 48 41 const instance = new SelectPrompt({ 49 - input, 50 - output, 42 + input: mocks.input, 43 + output: mocks.output, 51 44 render: () => 'foo', 52 45 options: [{ value: 'foo' }, { value: 'bar' }, { value: 'baz' }], 53 46 }); ··· 55 48 instance.prompt(); 56 49 57 50 expect(instance.cursor).to.equal(0); 58 - input.emit('keypress', 'up', { name: 'up' }); 51 + mocks.input.emit('keypress', 'up', { name: 'up' }); 59 52 expect(instance.cursor).to.equal(2); 60 - input.emit('keypress', 'down', { name: 'down' }); 53 + mocks.input.emit('keypress', 'down', { name: 'down' }); 61 54 expect(instance.cursor).to.equal(0); 62 55 }); 63 56 64 57 test('left behaves as up', () => { 65 58 const instance = new SelectPrompt({ 66 - input, 67 - output, 59 + input: mocks.input, 60 + output: mocks.output, 68 61 render: () => 'foo', 69 62 options: [{ value: 'foo' }, { value: 'bar' }, { value: 'baz' }], 70 63 }); 71 64 72 65 instance.prompt(); 73 66 74 - input.emit('keypress', 'left', { name: 'left' }); 67 + mocks.input.emit('keypress', 'left', { name: 'left' }); 75 68 expect(instance.cursor).to.equal(2); 76 69 }); 77 70 78 71 test('right behaves as down', () => { 79 72 const instance = new SelectPrompt({ 80 - input, 81 - output, 73 + input: mocks.input, 74 + output: mocks.output, 82 75 render: () => 'foo', 83 76 options: [{ value: 'foo' }, { value: 'bar' }], 84 77 }); 85 78 86 79 instance.prompt(); 87 80 88 - input.emit('keypress', 'left', { name: 'left' }); 81 + mocks.input.emit('keypress', 'left', { name: 'left' }); 89 82 expect(instance.cursor).to.equal(1); 90 83 }); 91 84 92 85 test('initial value is selected', () => { 93 86 const instance = new SelectPrompt({ 94 - input, 95 - output, 87 + input: mocks.input, 88 + output: mocks.output, 96 89 render: () => 'foo', 97 90 options: [{ value: 'foo' }, { value: 'bar' }], 98 91 initialValue: 'bar', ··· 103 96 104 97 test('cursor skips disabled options (down)', () => { 105 98 const instance = new SelectPrompt({ 106 - input, 107 - output, 99 + input: mocks.input, 100 + output: mocks.output, 108 101 render: () => 'foo', 109 102 options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }], 110 103 }); 111 104 instance.prompt(); 112 105 expect(instance.cursor).to.equal(0); 113 - input.emit('keypress', 'down', { name: 'down' }); 106 + mocks.input.emit('keypress', 'down', { name: 'down' }); 114 107 expect(instance.cursor).to.equal(2); 115 108 }); 116 109 117 110 test('cursor skips disabled options (up)', () => { 118 111 const instance = new SelectPrompt({ 119 - input, 120 - output, 112 + input: mocks.input, 113 + output: mocks.output, 121 114 render: () => 'foo', 122 115 initialValue: 'baz', 123 116 options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }], 124 117 }); 125 118 instance.prompt(); 126 119 expect(instance.cursor).to.equal(2); 127 - input.emit('keypress', 'up', { name: 'up' }); 120 + mocks.input.emit('keypress', 'up', { name: 'up' }); 128 121 expect(instance.cursor).to.equal(0); 129 122 }); 130 123 131 124 test('cursor skips initial disabled option', () => { 132 125 const instance = new SelectPrompt({ 133 - input, 134 - output, 126 + input: mocks.input, 127 + output: mocks.output, 135 128 render: () => 'foo', 136 129 options: [{ value: 'foo', disabled: true }, { value: 'bar' }, { value: 'baz' }], 137 130 });
+34 -41
packages/core/src/prompts/text.test.ts
··· 1 1 import { styleText } from 'node:util'; 2 2 import { cursor } from 'sisteransi'; 3 - import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; 3 + import { beforeEach, describe, expect, test } from 'vitest'; 4 4 import { default as TextPrompt } from './text.js'; 5 - import { MockReadable } from '../mock-readable.js'; 6 - import { MockWritable } from '../mock-writable.js'; 5 + import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; 7 6 8 7 describe('TextPrompt', () => { 9 - let input: MockReadable; 10 - let output: MockWritable; 8 + let mocks: Mocks<{ input: true; output: true }>; 11 9 12 10 beforeEach(() => { 13 - input = new MockReadable(); 14 - output = new MockWritable(); 15 - }); 16 - 17 - afterEach(() => { 18 - vi.restoreAllMocks(); 11 + mocks = createMocks({ input: true, output: true }); 19 12 }); 20 13 21 14 test('renders render() result', () => { 22 15 const instance = new TextPrompt({ 23 - input, 24 - output, 16 + input: mocks.input, 17 + output: mocks.output, 25 18 render: () => 'foo', 26 19 }); 27 20 // leave the promise hanging since we don't want to submit in this test 28 21 instance.prompt(); 29 - expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); 22 + expect(mocks.output.buffer).to.deep.equal([cursor.hide, 'foo']); 30 23 }); 31 24 32 25 test('sets default value on finalize if no value', async () => { 33 26 const instance = new TextPrompt({ 34 - input, 35 - output, 27 + input: mocks.input, 28 + output: mocks.output, 36 29 render: () => 'foo', 37 30 defaultValue: 'bleep bloop', 38 31 }); 39 32 const resultPromise = instance.prompt(); 40 - input.emit('keypress', '', { name: 'return' }); 33 + mocks.input.emit('keypress', '', { name: 'return' }); 41 34 const result = await resultPromise; 42 35 expect(result).to.equal('bleep bloop'); 43 36 }); 44 37 45 38 test('keeps value on finalize', async () => { 46 39 const instance = new TextPrompt({ 47 - input, 48 - output, 40 + input: mocks.input, 41 + output: mocks.output, 49 42 render: () => 'foo', 50 43 defaultValue: 'bleep bloop', 51 44 }); 52 45 const resultPromise = instance.prompt(); 53 - input.emit('keypress', 'x', { name: 'x' }); 54 - input.emit('keypress', '', { name: 'return' }); 46 + mocks.input.emit('keypress', 'x', { name: 'x' }); 47 + mocks.input.emit('keypress', '', { name: 'return' }); 55 48 const result = await resultPromise; 56 49 expect(result).to.equal('x'); 57 50 }); ··· 59 52 describe('cursor', () => { 60 53 test('can get cursor', () => { 61 54 const instance = new TextPrompt({ 62 - input, 63 - output, 55 + input: mocks.input, 56 + output: mocks.output, 64 57 render: () => 'foo', 65 58 }); 66 59 ··· 71 64 describe('userInputWithCursor', () => { 72 65 test('returns value on submit', () => { 73 66 const instance = new TextPrompt({ 74 - input, 75 - output, 67 + input: mocks.input, 68 + output: mocks.output, 76 69 render: () => 'foo', 77 70 }); 78 71 instance.prompt(); 79 - input.emit('keypress', 'x', { name: 'x' }); 80 - input.emit('keypress', '', { name: 'return' }); 72 + mocks.input.emit('keypress', 'x', { name: 'x' }); 73 + mocks.input.emit('keypress', '', { name: 'return' }); 81 74 expect(instance.userInputWithCursor).to.equal('x'); 82 75 }); 83 76 84 77 test('highlights cursor position', () => { 85 78 const instance = new TextPrompt({ 86 - input, 87 - output, 79 + input: mocks.input, 80 + output: mocks.output, 88 81 render: () => 'foo', 89 82 }); 90 83 instance.prompt(); 91 84 const keys = 'foo'; 92 85 for (let i = 0; i < keys.length; i++) { 93 - input.emit('keypress', keys[i], { name: keys[i] }); 86 + mocks.input.emit('keypress', keys[i], { name: keys[i] }); 94 87 } 95 - input.emit('keypress', undefined, { name: 'left' }); 88 + mocks.input.emit('keypress', undefined, { name: 'left' }); 96 89 expect(instance.userInputWithCursor).to.equal(`fo${styleText('inverse', 'o')}`); 97 90 }); 98 91 99 92 test('shows cursor at end if beyond value', () => { 100 93 const instance = new TextPrompt({ 101 - input, 102 - output, 94 + input: mocks.input, 95 + output: mocks.output, 103 96 render: () => 'foo', 104 97 }); 105 98 instance.prompt(); 106 99 const keys = 'foo'; 107 100 for (let i = 0; i < keys.length; i++) { 108 - input.emit('keypress', keys[i], { name: keys[i] }); 101 + mocks.input.emit('keypress', keys[i], { name: keys[i] }); 109 102 } 110 - input.emit('keypress', undefined, { name: 'right' }); 103 + mocks.input.emit('keypress', undefined, { name: 'right' }); 111 104 expect(instance.userInputWithCursor).to.equal('foo█'); 112 105 }); 113 106 114 107 test('does not use placeholder as value when pressing enter', async () => { 115 108 const instance = new TextPrompt({ 116 - input, 117 - output, 109 + input: mocks.input, 110 + output: mocks.output, 118 111 render: () => 'foo', 119 112 placeholder: ' (hit Enter to use default)', 120 113 defaultValue: 'default-value', 121 114 }); 122 115 const resultPromise = instance.prompt(); 123 - input.emit('keypress', '', { name: 'return' }); 116 + mocks.input.emit('keypress', '', { name: 'return' }); 124 117 const result = await resultPromise; 125 118 expect(result).to.equal('default-value'); 126 119 }); 127 120 128 121 test('returns empty string when no value and no default', async () => { 129 122 const instance = new TextPrompt({ 130 - input, 131 - output, 123 + input: mocks.input, 124 + output: mocks.output, 132 125 render: () => 'foo', 133 126 placeholder: ' (hit Enter to use default)', 134 127 }); 135 128 const resultPromise = instance.prompt(); 136 - input.emit('keypress', '', { name: 'return' }); 129 + mocks.input.emit('keypress', '', { name: 'return' }); 137 130 const result = await resultPromise; 138 131 expect(result).to.equal(''); 139 132 });
-42
packages/core/src/test-utils.ts
··· 1 - import { Readable, Writable } from 'node:stream'; 2 - 3 - export class MockReadable extends Readable { 4 - protected _buffer: unknown[] | null = []; 5 - 6 - _read() { 7 - if (this._buffer === null) { 8 - this.push(null); 9 - return; 10 - } 11 - 12 - for (const val of this._buffer) { 13 - this.push(val); 14 - } 15 - 16 - this._buffer = []; 17 - } 18 - 19 - pushValue(val: unknown): void { 20 - this._buffer?.push(val); 21 - } 22 - 23 - close(): void { 24 - this._buffer = null; 25 - } 26 - } 27 - 28 - export class MockWritable extends Writable { 29 - public buffer: string[] = []; 30 - public isTTY = false; 31 - public columns = 80; 32 - public rows = 20; 33 - 34 - _write( 35 - chunk: any, 36 - _encoding: BufferEncoding, 37 - callback: (error?: Error | null | undefined) => void 38 - ): void { 39 - this.buffer.push(chunk.toString()); 40 - callback(); 41 - } 42 - }
+7 -17
packages/core/src/utils/index.test.ts
··· 1 1 import type { Key } from 'node:readline'; 2 2 import { cursor } from 'sisteransi'; 3 - import { afterEach, describe, expect, test, vi } from 'vitest'; 3 + import { describe, expect, test, vi } from 'vitest'; 4 4 import { block } from './index.js'; 5 - import { MockReadable } from '../mock-readable.js'; 6 - import { MockWritable } from '../mock-writable.js'; 5 + import { createMocks } from '@bomb.sh/tools/test-utils'; 7 6 8 7 describe('utils', () => { 9 - afterEach(() => { 10 - vi.restoreAllMocks(); 11 - }); 12 - 13 8 describe('block', () => { 14 9 test('clears output on keypress', () => { 15 - const input = new MockReadable(); 16 - const output = new MockWritable(); 10 + const { input, output } = createMocks({ input: true, output: true }); 17 11 const callback = block({ input, output }); 18 12 19 13 const event: Key = { ··· 26 20 }); 27 21 28 22 test('clears output vertically when return pressed', () => { 29 - const input = new MockReadable(); 30 - const output = new MockWritable(); 23 + const { input, output } = createMocks({ input: true, output: true }); 31 24 const callback = block({ input, output }); 32 25 33 26 const event: Key = { ··· 40 33 }); 41 34 42 35 test('ignores additional keypresses after dispose', () => { 43 - const input = new MockReadable(); 44 - const output = new MockWritable(); 36 + const { input, output } = createMocks({ input: true, output: true }); 45 37 const callback = block({ input, output }); 46 38 47 39 const event: Key = { ··· 55 47 }); 56 48 57 49 test('exits on ctrl-c', () => { 58 - const input = new MockReadable(); 59 - const output = new MockWritable(); 50 + const { input, output } = createMocks({ input: true, output: true }); 60 51 // purposely don't keep the callback since we would exit the process 61 52 block({ input, output }); 62 53 const spy = vi.spyOn(process, 'exit').mockImplementation((() => { ··· 73 64 }); 74 65 75 66 test('does not clear if overwrite=false', () => { 76 - const input = new MockReadable(); 77 - const output = new MockWritable(); 67 + const { input, output } = createMocks({ input: true, output: true }); 78 68 const callback = block({ input, output, overwrite: false }); 79 69 80 70 const event: Key = {