diff --git a/packages/core/package.json b/packages/core/package.json index 7ae47af9..af363b5f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -48,7 +48,7 @@ "license": "MIT", "packageManager": "pnpm@9.14.2", "scripts": { - "build": "bsh build", + "build": "bsh build \"src/**/*.ts\" \"!src/**/*.test.ts\"", "prepack": "pnpm build", "test": "bsh test", "lint": "bsh lint" diff --git a/packages/core/test/prompts/autocomplete.test.ts b/packages/core/src/prompts/autocomplete.test.ts similarity index 78% rename from packages/core/test/prompts/autocomplete.test.ts rename to packages/core/src/prompts/autocomplete.test.ts index f1d98e24..9044c8f0 100644 --- a/packages/core/test/prompts/autocomplete.test.ts +++ b/packages/core/src/prompts/autocomplete.test.ts @@ -1,12 +1,10 @@ import { cursor } from 'sisteransi'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { default as AutocompletePrompt } from '../../src/prompts/autocomplete.js'; -import { MockReadable } from '../mock-readable.js'; -import { MockWritable } from '../mock-writable.js'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { default as AutocompletePrompt } from './autocomplete.js'; +import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; describe('AutocompletePrompt', () => { - let input: MockReadable; - let output: MockWritable; + let mocks: Mocks<{ input: true; output: true }>; const testOptions = [ { value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' }, @@ -16,29 +14,24 @@ describe('AutocompletePrompt', () => { ]; beforeEach(() => { - input = new MockReadable(); - output = new MockWritable(); - }); - - afterEach(() => { - vi.restoreAllMocks(); + mocks = createMocks({ input: true, output: true }); }); test('renders render() result', () => { const instance = new AutocompletePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: testOptions, }); instance.prompt(); - expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); + expect(mocks.output.buffer).to.deep.equal([cursor.hide, 'foo']); }); test('initial options match provided options', () => { const instance = new AutocompletePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: testOptions, }); @@ -52,8 +45,8 @@ describe('AutocompletePrompt', () => { test('cursor navigation with event emitter', () => { const instance = new AutocompletePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: testOptions, }); @@ -78,8 +71,8 @@ describe('AutocompletePrompt', () => { test('initialValue selects correct option', () => { const instance = new AutocompletePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: testOptions, initialValue: ['cherry'], @@ -95,8 +88,8 @@ describe('AutocompletePrompt', () => { test('initialValue defaults to first option when non-multiple', () => { const instance = new AutocompletePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: testOptions, }); @@ -107,8 +100,8 @@ describe('AutocompletePrompt', () => { test('initialValue is empty when multiple', () => { const instance = new AutocompletePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: testOptions, multiple: true, @@ -120,8 +113,8 @@ describe('AutocompletePrompt', () => { test('filtering through user input', () => { const instance = new AutocompletePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: testOptions, }); @@ -132,7 +125,7 @@ describe('AutocompletePrompt', () => { expect(instance.filteredOptions.length).to.equal(testOptions.length); // Simulate typing 'a' by emitting keypress event - input.emit('keypress', 'a', { name: 'a' }); + mocks.input.emit('keypress', 'a', { name: 'a' }); // Check that filtered options are updated to include options with 'a' expect(instance.filteredOptions.length).to.be.lessThan(testOptions.length); @@ -144,37 +137,37 @@ describe('AutocompletePrompt', () => { test('default filter function works correctly', () => { const instance = new AutocompletePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: testOptions, }); instance.prompt(); - input.emit('keypress', 'a', { name: 'a' }); - input.emit('keypress', 'p', { name: 'p' }); + mocks.input.emit('keypress', 'a', { name: 'a' }); + mocks.input.emit('keypress', 'p', { name: 'p' }); expect(instance.filteredOptions).toEqual([ { value: 'apple', label: 'Apple' }, { value: 'grape', label: 'Grape' }, ]); - input.emit('keypress', 'z', { name: 'z' }); + mocks.input.emit('keypress', 'z', { name: 'z' }); expect(instance.filteredOptions).toEqual([]); }); test('submit without nav resolves to first option in non-multiple', async () => { const instance = new AutocompletePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: testOptions, }); const promise = instance.prompt(); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const result = await promise; expect(instance.selectedValues).to.deep.equal(['apple']); @@ -183,15 +176,15 @@ describe('AutocompletePrompt', () => { test('submit without nav resolves to [] in multiple', async () => { const instance = new AutocompletePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: testOptions, multiple: true, }); const promise = instance.prompt(); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const result = await promise; expect(instance.selectedValues).to.deep.equal([]); @@ -200,16 +193,16 @@ describe('AutocompletePrompt', () => { test('Tab with empty input and placeholder fills input and submit returns matching option', async () => { const instance = new AutocompletePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: testOptions, placeholder: 'apple', }); const promise = instance.prompt(); - input.emit('keypress', '\t', { name: 'tab' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '\t', { name: 'tab' }); + mocks.input.emit('keypress', '', { name: 'return' }); const result = await promise; expect(instance.userInput).to.equal('apple'); @@ -222,15 +215,15 @@ describe('AutocompletePrompt', () => { { value: 'banana', label: 'Banana' }, ]; const instance = new AutocompletePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: () => dynamicOptions, }); instance.prompt(); - input.emit('keypress', 'z', { name: 'z' }); + mocks.input.emit('keypress', 'z', { name: 'z' }); expect(instance.filteredOptions).toEqual(dynamicOptions); }); @@ -242,8 +235,8 @@ describe('AutocompletePrompt', () => { { value: 'cherry', label: 'Cherry' }, ]; const instance = new AutocompletePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: () => dynamicOptions, filter: (search, opt) => (opt.label ?? '').toLowerCase().endsWith(search.toLowerCase()), @@ -251,7 +244,7 @@ describe('AutocompletePrompt', () => { instance.prompt(); - input.emit('keypress', 'a', { name: 'a' }); + mocks.input.emit('keypress', 'a', { name: 'a' }); // 'endsWith' matches Banana but not Apple or Cherry expect(instance.filteredOptions).toEqual([{ value: 'banana', label: 'Banana' }]); @@ -259,15 +252,15 @@ describe('AutocompletePrompt', () => { test('Tab with non-matching placeholder does not fill input', async () => { const instance = new AutocompletePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: testOptions, placeholder: 'Type to search...', }); instance.prompt(); - input.emit('keypress', '\t', { name: 'tab' }); + mocks.input.emit('keypress', '\t', { name: 'tab' }); // Placeholder does not match any option, so input must not be filled with placeholder expect(instance.userInput).not.to.equal('Type to search...'); diff --git a/packages/core/test/prompts/confirm.test.ts b/packages/core/src/prompts/confirm.test.ts similarity index 63% rename from packages/core/test/prompts/confirm.test.ts rename to packages/core/src/prompts/confirm.test.ts index 8ad14c56..0f95bd87 100644 --- a/packages/core/test/prompts/confirm.test.ts +++ b/packages/core/src/prompts/confirm.test.ts @@ -1,38 +1,31 @@ import { cursor } from 'sisteransi'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { default as ConfirmPrompt } from '../../src/prompts/confirm.js'; -import { MockReadable } from '../mock-readable.js'; -import { MockWritable } from '../mock-writable.js'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { default as ConfirmPrompt } from './confirm.js'; +import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; describe('ConfirmPrompt', () => { - let input: MockReadable; - let output: MockWritable; + let mocks: Mocks<{ input: true; output: true }>; beforeEach(() => { - input = new MockReadable(); - output = new MockWritable(); - }); - - afterEach(() => { - vi.restoreAllMocks(); + mocks = createMocks({ input: true, output: true }); }); test('renders render() result', () => { const instance = new ConfirmPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', active: 'yes', inactive: 'no', }); instance.prompt(); - expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); + expect(mocks.output.buffer).to.deep.equal([cursor.hide, 'foo']); }); test('sets value and submits on confirm (y)', () => { const instance = new ConfirmPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', active: 'yes', inactive: 'no', @@ -40,7 +33,7 @@ describe('ConfirmPrompt', () => { }); instance.prompt(); - input.emit('keypress', 'y', { name: 'y' }); + mocks.input.emit('keypress', 'y', { name: 'y' }); expect(instance.value).to.equal(true); expect(instance.state).to.equal('submit'); @@ -48,8 +41,8 @@ describe('ConfirmPrompt', () => { test('sets value and submits on confirm (n)', () => { const instance = new ConfirmPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', active: 'yes', inactive: 'no', @@ -57,7 +50,7 @@ describe('ConfirmPrompt', () => { }); instance.prompt(); - input.emit('keypress', 'n', { name: 'n' }); + mocks.input.emit('keypress', 'n', { name: 'n' }); expect(instance.value).to.equal(false); expect(instance.state).to.equal('submit'); @@ -66,8 +59,8 @@ describe('ConfirmPrompt', () => { describe('cursor', () => { test('cursor is 1 when inactive', () => { const instance = new ConfirmPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', active: 'yes', inactive: 'no', @@ -75,14 +68,14 @@ describe('ConfirmPrompt', () => { }); instance.prompt(); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); expect(instance.cursor).to.equal(1); }); test('cursor is 0 when active', () => { const instance = new ConfirmPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', active: 'yes', inactive: 'no', @@ -90,7 +83,7 @@ describe('ConfirmPrompt', () => { }); instance.prompt(); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); expect(instance.cursor).to.equal(0); }); }); diff --git a/packages/core/test/prompts/date.test.ts b/packages/core/src/prompts/date.test.ts similarity index 69% rename from packages/core/test/prompts/date.test.ts rename to packages/core/src/prompts/date.test.ts index 93cf6055..8d6ddd3c 100644 --- a/packages/core/test/prompts/date.test.ts +++ b/packages/core/src/prompts/date.test.ts @@ -1,9 +1,8 @@ import { cursor } from 'sisteransi'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { default as DatePrompt } from '../../src/prompts/date.js'; -import { isCancel } from '../../src/utils/index.js'; -import { MockReadable } from '../mock-readable.js'; -import { MockWritable } from '../mock-writable.js'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { default as DatePrompt } from './date.js'; +import { isCancel } from '../utils/index.js'; +import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; const d = (iso: string) => { const [y, m, day] = iso.slice(0, 10).split('-').map(Number); @@ -11,33 +10,27 @@ const d = (iso: string) => { }; describe('DatePrompt', () => { - let input: MockReadable; - let output: MockWritable; + let mocks: Mocks<{ input: true; output: true }>; beforeEach(() => { - input = new MockReadable(); - output = new MockWritable(); - }); - - afterEach(() => { - vi.restoreAllMocks(); + mocks = createMocks({ input: true, output: true }); }); test('renders render() result', () => { const instance = new DatePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', format: 'YMD', }); instance.prompt(); - expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); + expect(mocks.output.buffer).to.deep.equal([cursor.hide, 'foo']); }); test('initial value displays correctly', () => { const instance = new DatePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', format: 'YMD', initialValue: d('2025-01-15'), @@ -50,58 +43,58 @@ describe('DatePrompt', () => { test('left/right navigates between segments', () => { const instance = new DatePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', format: 'YMD', initialValue: d('2025-01-15'), }); instance.prompt(); expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 }); - input.emit('keypress', undefined, { name: 'right' }); + mocks.input.emit('keypress', undefined, { name: 'right' }); expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 1, positionInSegment: 0 }); - input.emit('keypress', undefined, { name: 'right' }); + mocks.input.emit('keypress', undefined, { name: 'right' }); expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 2, positionInSegment: 0 }); - input.emit('keypress', undefined, { name: 'left' }); + mocks.input.emit('keypress', undefined, { name: 'left' }); expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 1, positionInSegment: 0 }); }); test('up/down increments and decrements segment', () => { const instance = new DatePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', format: 'YMD', initialValue: d('2025-01-15'), }); instance.prompt(); - input.emit('keypress', undefined, { name: 'right' }); // move to month - input.emit('keypress', undefined, { name: 'up' }); + mocks.input.emit('keypress', undefined, { name: 'right' }); // move to month + mocks.input.emit('keypress', undefined, { name: 'up' }); expect(instance.userInput).to.equal('2025/02/15'); - input.emit('keypress', undefined, { name: 'down' }); + mocks.input.emit('keypress', undefined, { name: 'down' }); expect(instance.userInput).to.equal('2025/01/15'); }); test('up/down on one segment leaves other segments blank', () => { const instance = new DatePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', format: 'YMD', }); instance.prompt(); expect(instance.userInput).to.equal('____/__/__'); - input.emit('keypress', undefined, { name: 'up' }); // up on year (first segment) + mocks.input.emit('keypress', undefined, { name: 'up' }); // up on year (first segment) expect(instance.userInput).to.equal('0001/__/__'); - input.emit('keypress', undefined, { name: 'right' }); // move to month - input.emit('keypress', undefined, { name: 'up' }); + mocks.input.emit('keypress', undefined, { name: 'right' }); // move to month + mocks.input.emit('keypress', undefined, { name: 'up' }); expect(instance.userInput).to.equal('0001/01/__'); }); test('with minDate/maxDate, up on blank segment starts at min', () => { const instance = new DatePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', format: 'YMD', minDate: d('2025-03-10'), @@ -109,40 +102,40 @@ describe('DatePrompt', () => { }); instance.prompt(); expect(instance.userInput).to.equal('____/__/__'); - input.emit('keypress', undefined, { name: 'up' }); + mocks.input.emit('keypress', undefined, { name: 'up' }); expect(instance.userInput).to.equal('2025/__/__'); - input.emit('keypress', undefined, { name: 'right' }); - input.emit('keypress', undefined, { name: 'up' }); + mocks.input.emit('keypress', undefined, { name: 'right' }); + mocks.input.emit('keypress', undefined, { name: 'up' }); expect(instance.userInput).to.equal('2025/03/__'); - input.emit('keypress', undefined, { name: 'right' }); - input.emit('keypress', undefined, { name: 'up' }); + mocks.input.emit('keypress', undefined, { name: 'right' }); + mocks.input.emit('keypress', undefined, { name: 'up' }); expect(instance.userInput).to.equal('2025/03/10'); }); test('with minDate/maxDate, down on blank segment starts at max', () => { const instance = new DatePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', format: 'YMD', minDate: d('2025-03-10'), maxDate: d('2025-11-20'), }); instance.prompt(); - input.emit('keypress', undefined, { name: 'down' }); + mocks.input.emit('keypress', undefined, { name: 'down' }); expect(instance.userInput).to.equal('2025/__/__'); - input.emit('keypress', undefined, { name: 'right' }); - input.emit('keypress', undefined, { name: 'down' }); + mocks.input.emit('keypress', undefined, { name: 'right' }); + mocks.input.emit('keypress', undefined, { name: 'down' }); expect(instance.userInput).to.equal('2025/11/__'); - input.emit('keypress', undefined, { name: 'right' }); - input.emit('keypress', undefined, { name: 'down' }); + mocks.input.emit('keypress', undefined, { name: 'right' }); + mocks.input.emit('keypress', undefined, { name: 'down' }); expect(instance.userInput).to.equal('2025/11/20'); }); test('digit-by-digit editing from left to right', () => { const instance = new DatePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', format: 'YMD', initialValue: d('2025-01-15'), @@ -150,56 +143,56 @@ describe('DatePrompt', () => { instance.prompt(); expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 }); // Type 2,0,2,3 to change year to 2023 (right-to-left fill) - input.emit('keypress', '2', { name: undefined, sequence: '2' }); + mocks.input.emit('keypress', '2', { name: undefined, sequence: '2' }); expect(instance.userInput).to.equal('___2/01/15'); - input.emit('keypress', '0', { name: undefined, sequence: '0' }); + mocks.input.emit('keypress', '0', { name: undefined, sequence: '0' }); expect(instance.userInput).to.equal('__20/01/15'); - input.emit('keypress', '2', { name: undefined, sequence: '2' }); + mocks.input.emit('keypress', '2', { name: undefined, sequence: '2' }); expect(instance.userInput).to.equal('_202/01/15'); - input.emit('keypress', '3', { name: undefined, sequence: '3' }); + mocks.input.emit('keypress', '3', { name: undefined, sequence: '3' }); expect(instance.userInput).to.equal('2023/01/15'); }); test('backspace clears entire segment at any cursor position', () => { const instance = new DatePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', format: 'YMD', initialValue: d('2025-12-21'), }); instance.prompt(); expect(instance.userInput).to.equal('2025/12/21'); - input.emit('keypress', undefined, { name: 'backspace', sequence: '\x7f' }); + mocks.input.emit('keypress', undefined, { name: 'backspace', sequence: '\x7f' }); expect(instance.userInput).to.equal('____/12/21'); expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 }); }); test('backspace clears segment when cursor at first char (2___)', () => { const instance = new DatePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', format: 'YMD', }); instance.prompt(); - input.emit('keypress', '2', { name: undefined, sequence: '2' }); + mocks.input.emit('keypress', '2', { name: undefined, sequence: '2' }); expect(instance.userInput).to.equal('___2/__/__'); - input.emit('keypress', '\x7f', { name: undefined, sequence: '\x7f' }); + mocks.input.emit('keypress', '\x7f', { name: undefined, sequence: '\x7f' }); expect(instance.userInput).to.equal('____/__/__'); expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 }); }); test('digit input updates segment and jumps to next when complete', () => { const instance = new DatePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', format: 'YMD', }); instance.prompt(); for (const c of '2025') { - input.emit('keypress', c, { name: undefined, sequence: c }); + mocks.input.emit('keypress', c, { name: undefined, sequence: c }); } expect(instance.userInput).to.equal('2025/__/__'); expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 1, positionInSegment: 0 }); @@ -207,14 +200,14 @@ describe('DatePrompt', () => { test('submit returns Date for valid date', async () => { const instance = new DatePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', format: 'YMD', initialValue: d('2025-01-31'), }); const resultPromise = instance.prompt(); - input.emit('keypress', undefined, { name: 'return' }); + mocks.input.emit('keypress', undefined, { name: 'return' }); const result = await resultPromise; expect(result).toBeInstanceOf(Date); expect((result as Date).toISOString().slice(0, 10)).to.equal('2025-01-31'); @@ -222,28 +215,28 @@ describe('DatePrompt', () => { test('can cancel', async () => { const instance = new DatePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', format: 'YMD', initialValue: d('2025-01-15'), }); const resultPromise = instance.prompt(); - input.emit('keypress', 'escape', { name: 'escape' }); + mocks.input.emit('keypress', 'escape', { name: 'escape' }); const result = await resultPromise; expect(isCancel(result)).toBe(true); }); test('defaultValue used when invalid date submitted', async () => { const instance = new DatePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', format: 'YMD', defaultValue: d('2025-06-15'), }); const resultPromise = instance.prompt(); - input.emit('keypress', undefined, { name: 'return' }); + mocks.input.emit('keypress', undefined, { name: 'return' }); const result = await resultPromise; expect(result).toBeInstanceOf(Date); expect((result as Date).toISOString().slice(0, 10)).to.equal('2025-06-15'); @@ -251,8 +244,8 @@ describe('DatePrompt', () => { test('supports MDY format', () => { const instance = new DatePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', format: 'MDY', initialValue: d('2025-01-15'), @@ -263,8 +256,8 @@ describe('DatePrompt', () => { test('supports DMY format', () => { const instance = new DatePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', format: 'DMY', initialValue: d('2025-01-15'), @@ -275,44 +268,44 @@ describe('DatePrompt', () => { test('rejects invalid month via pending tens digit', () => { const instance = new DatePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', format: 'YMD', }); instance.prompt(); // Navigate to month - input.emit('keypress', undefined, { name: 'right' }); + mocks.input.emit('keypress', undefined, { name: 'right' }); // Type '1' → '01' with pending tens digit (since 1 <= 1) - input.emit('keypress', '1', { name: undefined, sequence: '1' }); + mocks.input.emit('keypress', '1', { name: undefined, sequence: '1' }); expect(instance.segmentValues.month).to.equal('01'); // Type '3' → tries '13' which is > 12 → inline error - input.emit('keypress', '3', { name: undefined, sequence: '3' }); + mocks.input.emit('keypress', '3', { name: undefined, sequence: '3' }); expect(instance.inlineError).to.equal('There are only 12 months in a year'); }); test('rejects invalid day via pending tens digit', () => { const instance = new DatePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', format: 'YMD', }); instance.prompt(); // Navigate to day - input.emit('keypress', undefined, { name: 'right' }); - input.emit('keypress', undefined, { name: 'right' }); + mocks.input.emit('keypress', undefined, { name: 'right' }); + mocks.input.emit('keypress', undefined, { name: 'right' }); // Type '2' → '02' with pending (2 <= 2) - input.emit('keypress', '2', { name: undefined, sequence: '2' }); - input.emit('keypress', '0', { name: undefined, sequence: '0' }); + mocks.input.emit('keypress', '2', { name: undefined, sequence: '2' }); + mocks.input.emit('keypress', '0', { name: undefined, sequence: '0' }); expect(instance.inlineError).to.equal(''); }); describe('segmentValues and segmentCursor', () => { test('segmentValues reflects current input', () => { const instance = new DatePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', format: 'YMD', initialValue: d('2025-01-15'), @@ -326,14 +319,14 @@ describe('DatePrompt', () => { test('segmentCursor tracks cursor position', () => { const instance = new DatePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', format: 'YMD', initialValue: d('2025-01-15'), }); instance.prompt(); - input.emit('keypress', undefined, { name: 'right' }); // move to month + mocks.input.emit('keypress', undefined, { name: 'right' }); // move to month const cursor = instance.segmentCursor; expect(cursor.segmentIndex).to.equal(1); expect(cursor.positionInSegment).to.equal(0); @@ -341,14 +334,14 @@ describe('DatePrompt', () => { test('segmentValues updates on submit', () => { const instance = new DatePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', format: 'YMD', initialValue: d('2025-01-15'), }); instance.prompt(); - input.emit('keypress', undefined, { name: 'return' }); + mocks.input.emit('keypress', undefined, { name: 'return' }); const segmentValues = instance.segmentValues; expect(segmentValues.year).to.equal('2025'); expect(segmentValues.month).to.equal('01'); @@ -359,8 +352,8 @@ describe('DatePrompt', () => { describe('formattedValue and segments', () => { test('formattedValue returns formatted string', () => { const instance = new DatePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', format: 'MDY', initialValue: d('2025-03-15'), @@ -371,8 +364,8 @@ describe('DatePrompt', () => { test('segments exposes segment config', () => { const instance = new DatePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', format: 'DMY', }); @@ -386,8 +379,8 @@ describe('DatePrompt', () => { test('separator defaults to / for explicit format', () => { const instance = new DatePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', format: 'YMD', }); @@ -399,8 +392,8 @@ describe('DatePrompt', () => { describe('locale detection', () => { test('locale auto-detects format from Intl', () => { const instance = new DatePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', locale: 'en-US', initialValue: d('2025-03-15'), @@ -414,8 +407,8 @@ describe('DatePrompt', () => { test('explicit format overrides locale', () => { const instance = new DatePrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', format: 'YMD', locale: 'en-US', // would be MDY, but format takes precedence diff --git a/packages/core/src/prompts/multi-line.test.ts b/packages/core/src/prompts/multi-line.test.ts new file mode 100644 index 00000000..375d2962 --- /dev/null +++ b/packages/core/src/prompts/multi-line.test.ts @@ -0,0 +1,348 @@ +import { styleText } from 'node:util'; +import { cursor } from 'sisteransi'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { default as MultiLinePrompt } from './multi-line.js'; +import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; + +describe('MultiLinePrompt', () => { + let mocks: Mocks<{ input: true; output: true }>; + + beforeEach(() => { + mocks = createMocks({ input: true, output: true }); + }); + + test('renders render() result', () => { + const instance = new MultiLinePrompt({ + input: mocks.input, + output: mocks.output, + render: () => 'foo', + }); + instance.prompt(); + expect(mocks.output.buffer).to.deep.equal([cursor.hide, 'foo']); + }); + + test('sets default value on finalize if no value', async () => { + const instance = new MultiLinePrompt({ + input: mocks.input, + output: mocks.output, + render: () => 'foo', + defaultValue: 'bleep bloop', + }); + const resultPromise = instance.prompt(); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('bleep bloop'); + }); + + test('keeps value on finalize', async () => { + const instance = new MultiLinePrompt({ + input: mocks.input, + output: mocks.output, + render: () => 'foo', + defaultValue: 'bleep bloop', + }); + const resultPromise = instance.prompt(); + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('x'); + }); + + describe('cursor', () => { + test('can get cursor', () => { + const instance = new MultiLinePrompt({ + input: mocks.input, + output: mocks.output, + render: () => 'foo', + }); + + expect(instance.cursor).to.equal(0); + }); + }); + + describe('userInputWithCursor', () => { + test('returns value on submit', () => { + const instance = new MultiLinePrompt({ + input: mocks.input, + output: mocks.output, + render: () => 'foo', + }); + instance.prompt(); + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); + expect(instance.userInputWithCursor).to.equal('x'); + }); + + test('highlights cursor position', () => { + const instance = new MultiLinePrompt({ + input: mocks.input, + output: mocks.output, + render: () => 'foo', + }); + instance.prompt(); + const keys = 'foo'; + for (let i = 0; i < keys.length; i++) { + mocks.input.emit('keypress', keys[i], { name: keys[i] }); + } + mocks.input.emit('keypress', undefined, { name: 'left' }); + expect(instance.userInputWithCursor).to.equal(`fo${styleText('inverse', 'o')}`); + }); + + test('shows cursor at end if beyond value', () => { + const instance = new MultiLinePrompt({ + input: mocks.input, + output: mocks.output, + render: () => 'foo', + }); + instance.prompt(); + const keys = 'foo'; + for (let i = 0; i < keys.length; i++) { + mocks.input.emit('keypress', keys[i], { name: keys[i] }); + } + mocks.input.emit('keypress', undefined, { name: 'right' }); + expect(instance.userInputWithCursor).to.equal('foo█'); + }); + }); + + describe('key', () => { + test('return inserts newline', () => { + const instance = new MultiLinePrompt({ + input: mocks.input, + output: mocks.output, + render: () => 'foo', + }); + instance.prompt(); + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', '', { name: 'return' }); + expect(instance.userInput).to.equal('x\n'); + }); + + test('double return submits', async () => { + const instance = new MultiLinePrompt({ + input: mocks.input, + output: mocks.output, + render: () => 'foo', + }); + const resultPromise = instance.prompt(); + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('x'); + }); + + test('double return inserts when showSubmit is true', async () => { + const instance = new MultiLinePrompt({ + input: mocks.input, + output: mocks.output, + render: () => 'foo', + showSubmit: true, + }); + const resultPromise = instance.prompt(); + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '\t', { name: 'tab' }); + mocks.input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('x\n\n'); + }); + + test('typing when submit selected jumps back to text', async () => { + const instance = new MultiLinePrompt({ + input: mocks.input, + output: mocks.output, + render: () => 'foo', + showSubmit: true, + }); + const resultPromise = instance.prompt(); + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', '\t', { name: 'tab' }); + mocks.input.emit('keypress', 'y', { name: 'y' }); + mocks.input.emit('keypress', '\t', { name: 'tab' }); + mocks.input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('xy'); + }); + + test('backspace deletes previous char', async () => { + const instance = new MultiLinePrompt({ + input: mocks.input, + output: mocks.output, + render: () => 'foo', + }); + const resultPromise = instance.prompt(); + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', 'y', { name: 'y' }); + mocks.input.emit('keypress', '', { name: 'backspace' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('x'); + }); + + test('delete deletes next char', async () => { + const instance = new MultiLinePrompt({ + input: mocks.input, + output: mocks.output, + render: () => 'foo', + }); + const resultPromise = instance.prompt(); + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', 'y', { name: 'y' }); + mocks.input.emit('keypress', '', { name: 'left' }); + mocks.input.emit('keypress', '', { name: 'delete' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('x'); + }); + + test('delete does nothing at end', async () => { + const instance = new MultiLinePrompt({ + input: mocks.input, + output: mocks.output, + render: () => 'foo', + }); + const resultPromise = instance.prompt(); + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', '', { name: 'delete' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('x'); + }); + + test('backspace does nothing at start', async () => { + const instance = new MultiLinePrompt({ + input: mocks.input, + output: mocks.output, + render: () => 'foo', + }); + const resultPromise = instance.prompt(); + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', '', { name: 'left' }); + mocks.input.emit('keypress', '', { name: 'backspace' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('x'); + }); + + test('left moves left until start', async () => { + const instance = new MultiLinePrompt({ + input: mocks.input, + output: mocks.output, + render: () => 'foo', + }); + const resultPromise = instance.prompt(); + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', '', { name: 'left' }); + mocks.input.emit('keypress', '', { name: 'left' }); + mocks.input.emit('keypress', 'y', { name: 'y' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('yx'); + }); + + test('right moves right until end', async () => { + const instance = new MultiLinePrompt({ + input: mocks.input, + output: mocks.output, + render: () => 'foo', + }); + const resultPromise = instance.prompt(); + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', 'y', { name: 'y' }); + mocks.input.emit('keypress', '', { name: 'left' }); + mocks.input.emit('keypress', '', { name: 'right' }); + mocks.input.emit('keypress', '', { name: 'right' }); + mocks.input.emit('keypress', 'z', { name: 'z' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('xyz'); + }); + + test('left moves across lines', async () => { + const instance = new MultiLinePrompt({ + input: mocks.input, + output: mocks.output, + render: () => 'foo', + }); + const resultPromise = instance.prompt(); + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'y', { name: 'y' }); + mocks.input.emit('keypress', '', { name: 'left' }); + mocks.input.emit('keypress', '', { name: 'left' }); + mocks.input.emit('keypress', 'z', { name: 'z' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('xz\ny'); + }); + + test('right moves across lines', async () => { + const instance = new MultiLinePrompt({ + input: mocks.input, + output: mocks.output, + render: () => 'foo', + }); + const resultPromise = instance.prompt(); + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'y', { name: 'y' }); + mocks.input.emit('keypress', '', { name: 'left' }); + mocks.input.emit('keypress', '', { name: 'left' }); + mocks.input.emit('keypress', '', { name: 'right' }); + mocks.input.emit('keypress', '', { name: 'right' }); + mocks.input.emit('keypress', 'z', { name: 'z' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('x\nyz'); + }); + + test('up moves up a line', async () => { + const instance = new MultiLinePrompt({ + input: mocks.input, + output: mocks.output, + render: () => 'foo', + }); + const resultPromise = instance.prompt(); + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'y', { name: 'y' }); + mocks.input.emit('keypress', '', { name: 'up' }); + mocks.input.emit('keypress', 'z', { name: 'z' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('xz\ny'); + }); + + test('down moves down a line', async () => { + const instance = new MultiLinePrompt({ + input: mocks.input, + output: mocks.output, + render: () => 'foo', + }); + const resultPromise = instance.prompt(); + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'y', { name: 'y' }); + mocks.input.emit('keypress', '', { name: 'up' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', 'z', { name: 'z' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('x\nyz'); + }); + }); +}); diff --git a/packages/core/test/prompts/multi-select.test.ts b/packages/core/src/prompts/multi-select.test.ts similarity index 67% rename from packages/core/test/prompts/multi-select.test.ts rename to packages/core/src/prompts/multi-select.test.ts index 99695793..a44e01fd 100644 --- a/packages/core/test/prompts/multi-select.test.ts +++ b/packages/core/src/prompts/multi-select.test.ts @@ -1,38 +1,31 @@ import { cursor } from 'sisteransi'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { default as MultiSelectPrompt } from '../../src/prompts/multi-select.js'; -import { MockReadable } from '../mock-readable.js'; -import { MockWritable } from '../mock-writable.js'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { default as MultiSelectPrompt } from './multi-select.js'; +import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; describe('MultiSelectPrompt', () => { - let input: MockReadable; - let output: MockWritable; + let mocks: Mocks<{ input: true; output: true }>; beforeEach(() => { - input = new MockReadable(); - output = new MockWritable(); - }); - - afterEach(() => { - vi.restoreAllMocks(); + mocks = createMocks({ input: true, output: true }); }); test('renders render() result', () => { const instance = new MultiSelectPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: [{ value: 'foo' }, { value: 'bar' }], }); instance.prompt(); - expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); + expect(mocks.output.buffer).to.deep.equal([cursor.hide, 'foo']); }); describe('cursor', () => { test('cursor is index of selected item', () => { const instance = new MultiSelectPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: [{ value: 'foo' }, { value: 'bar' }], }); @@ -40,14 +33,14 @@ describe('MultiSelectPrompt', () => { instance.prompt(); expect(instance.cursor).to.equal(0); - input.emit('keypress', 'down', { name: 'down' }); + mocks.input.emit('keypress', 'down', { name: 'down' }); expect(instance.cursor).to.equal(1); }); test('cursor loops around', () => { const instance = new MultiSelectPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: [{ value: 'foo' }, { value: 'bar' }, { value: 'baz' }], }); @@ -55,44 +48,44 @@ describe('MultiSelectPrompt', () => { instance.prompt(); expect(instance.cursor).to.equal(0); - input.emit('keypress', 'up', { name: 'up' }); + mocks.input.emit('keypress', 'up', { name: 'up' }); expect(instance.cursor).to.equal(2); - input.emit('keypress', 'down', { name: 'down' }); + mocks.input.emit('keypress', 'down', { name: 'down' }); expect(instance.cursor).to.equal(0); }); test('left behaves as up', () => { const instance = new MultiSelectPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: [{ value: 'foo' }, { value: 'bar' }, { value: 'baz' }], }); instance.prompt(); - input.emit('keypress', 'left', { name: 'left' }); + mocks.input.emit('keypress', 'left', { name: 'left' }); expect(instance.cursor).to.equal(2); }); test('right behaves as down', () => { const instance = new MultiSelectPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: [{ value: 'foo' }, { value: 'bar' }], }); instance.prompt(); - input.emit('keypress', 'left', { name: 'left' }); + mocks.input.emit('keypress', 'left', { name: 'left' }); expect(instance.cursor).to.equal(1); }); test('initial values is selected', () => { const instance = new MultiSelectPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: [{ value: 'foo' }, { value: 'bar' }], initialValues: ['bar'], @@ -103,54 +96,54 @@ describe('MultiSelectPrompt', () => { test('select all when press "a" key', () => { const instance = new MultiSelectPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: [{ value: 'foo' }, { value: 'bar' }], }); instance.prompt(); - input.emit('keypress', 'down', { name: 'down' }); - input.emit('keypress', 'space', { name: 'space' }); - input.emit('keypress', 'a', { name: 'a' }); + mocks.input.emit('keypress', 'down', { name: 'down' }); + mocks.input.emit('keypress', 'space', { name: 'space' }); + mocks.input.emit('keypress', 'a', { name: 'a' }); expect(instance.value).toEqual(['foo', 'bar']); }); test('select invert when press "i" key', () => { const instance = new MultiSelectPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: [{ value: 'foo' }, { value: 'bar' }], }); instance.prompt(); - input.emit('keypress', 'down', { name: 'down' }); - input.emit('keypress', 'space', { name: 'space' }); - input.emit('keypress', 'i', { name: 'i' }); + mocks.input.emit('keypress', 'down', { name: 'down' }); + mocks.input.emit('keypress', 'space', { name: 'space' }); + mocks.input.emit('keypress', 'i', { name: 'i' }); expect(instance.value).toEqual(['foo']); }); test('disabled options are skipped', () => { const instance = new MultiSelectPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }], }); instance.prompt(); expect(instance.cursor).to.equal(0); - input.emit('keypress', 'down', { name: 'down' }); + mocks.input.emit('keypress', 'down', { name: 'down' }); expect(instance.cursor).to.equal(2); - input.emit('keypress', 'up', { name: 'up' }); + mocks.input.emit('keypress', 'up', { name: 'up' }); expect(instance.cursor).to.equal(0); }); test('initial cursorAt on disabled option', () => { const instance = new MultiSelectPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }], cursorAt: 'bar', @@ -164,28 +157,28 @@ describe('MultiSelectPrompt', () => { describe('toggleAll', () => { test('selects all enabled options', () => { const instance = new MultiSelectPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }], }); instance.prompt(); - input.emit('keypress', 'a', { name: 'a' }); + mocks.input.emit('keypress', 'a', { name: 'a' }); expect(instance.value).toEqual(['foo', 'baz']); }); test('unselects all enabled options if all selected', () => { const instance = new MultiSelectPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }], initialValues: ['foo', 'baz'], }); instance.prompt(); - input.emit('keypress', 'a', { name: 'a' }); + mocks.input.emit('keypress', 'a', { name: 'a' }); expect(instance.value).toEqual([]); }); }); @@ -193,8 +186,8 @@ describe('MultiSelectPrompt', () => { describe('toggleInvert', () => { test('inverts selection of enabled options', () => { const instance = new MultiSelectPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: [ { value: 'foo' }, @@ -206,7 +199,7 @@ describe('MultiSelectPrompt', () => { }); instance.prompt(); - input.emit('keypress', 'i', { name: 'i' }); + mocks.input.emit('keypress', 'i', { name: 'i' }); expect(instance.value).toEqual(['qux']); }); }); diff --git a/packages/core/test/prompts/password.test.ts b/packages/core/src/prompts/password.test.ts similarity index 57% rename from packages/core/test/prompts/password.test.ts rename to packages/core/src/prompts/password.test.ts index 3b37e9d3..ba5af6e3 100644 --- a/packages/core/test/prompts/password.test.ts +++ b/packages/core/src/prompts/password.test.ts @@ -1,39 +1,32 @@ import { styleText } from 'node:util'; import { cursor } from 'sisteransi'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { default as PasswordPrompt } from '../../src/prompts/password.js'; -import { MockReadable } from '../mock-readable.js'; -import { MockWritable } from '../mock-writable.js'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { default as PasswordPrompt } from './password.js'; +import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; describe('PasswordPrompt', () => { - let input: MockReadable; - let output: MockWritable; + let mocks: Mocks<{ input: true; output: true }>; beforeEach(() => { - input = new MockReadable(); - output = new MockWritable(); - }); - - afterEach(() => { - vi.restoreAllMocks(); + mocks = createMocks({ input: true, output: true }); }); test('renders render() result', () => { const instance = new PasswordPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', }); // leave the promise hanging since we don't want to submit in this test instance.prompt(); - expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); + expect(mocks.output.buffer).to.deep.equal([cursor.hide, 'foo']); }); describe('cursor', () => { test('can get cursor', () => { const instance = new PasswordPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', }); @@ -44,54 +37,54 @@ describe('PasswordPrompt', () => { describe('userInputWithCursor', () => { test('returns masked value on submit', () => { const instance = new PasswordPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', }); instance.prompt(); const keys = 'foo'; for (let i = 0; i < keys.length; i++) { - input.emit('keypress', keys[i], { name: keys[i] }); + mocks.input.emit('keypress', keys[i], { name: keys[i] }); } - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); expect(instance.userInputWithCursor).to.equal('•••'); }); test('renders marker at end', () => { const instance = new PasswordPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', }); instance.prompt(); - input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', 'x', { name: 'x' }); expect(instance.userInputWithCursor).to.equal(`•${styleText(['inverse', 'hidden'], '_')}`); }); test('renders cursor inside value', () => { const instance = new PasswordPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', }); instance.prompt(); - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', 'y', { name: 'y' }); - input.emit('keypress', 'z', { name: 'z' }); - input.emit('keypress', undefined, { name: 'left' }); - input.emit('keypress', undefined, { name: 'left' }); + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', 'y', { name: 'y' }); + mocks.input.emit('keypress', 'z', { name: 'z' }); + mocks.input.emit('keypress', undefined, { name: 'left' }); + mocks.input.emit('keypress', undefined, { name: 'left' }); expect(instance.userInputWithCursor).to.equal(`•${styleText('inverse', '•')}•`); }); test('renders custom mask', () => { const instance = new PasswordPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', mask: 'X', }); instance.prompt(); - input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', 'x', { name: 'x' }); expect(instance.userInputWithCursor).to.equal(`X${styleText(['inverse', 'hidden'], '_')}`); }); }); diff --git a/packages/core/test/prompts/prompt.test.ts b/packages/core/src/prompts/prompt.test.ts similarity index 73% rename from packages/core/test/prompts/prompt.test.ts rename to packages/core/src/prompts/prompt.test.ts index bc4fa5e6..51ebd98c 100644 --- a/packages/core/test/prompts/prompt.test.ts +++ b/packages/core/src/prompts/prompt.test.ts @@ -1,68 +1,61 @@ import { cursor } from 'sisteransi'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { default as Prompt } from '../../src/prompts/prompt.js'; -import { isCancel } from '../../src/utils/index.js'; -import { MockReadable } from '../mock-readable.js'; -import { MockWritable } from '../mock-writable.js'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { default as Prompt } from './prompt.js'; +import { isCancel } from '../utils/index.js'; +import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; describe('Prompt', () => { - let input: MockReadable; - let output: MockWritable; + let mocks: Mocks<{ input: true; output: true }>; beforeEach(() => { - input = new MockReadable(); - output = new MockWritable(); - }); - - afterEach(() => { - vi.restoreAllMocks(); + mocks = createMocks({ input: true, output: true }); }); test('renders render() result', () => { const instance = new Prompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', }); // leave the promise hanging since we don't want to submit in this test instance.prompt(); - expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); + expect(mocks.output.buffer).to.deep.equal([cursor.hide, 'foo']); }); test('submits on return key', async () => { const instance = new Prompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', }); const resultPromise = instance.prompt(); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const result = await resultPromise; expect(result).to.equal(undefined); expect(isCancel(result)).to.equal(false); expect(instance.state).to.equal('submit'); - expect(output.buffer).to.deep.equal([cursor.hide, 'foo', '\n', cursor.show]); + expect(mocks.output.buffer).to.deep.equal([cursor.hide, 'foo', '\n', cursor.show]); }); test('cancels on ctrl-c', async () => { const instance = new Prompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', }); const resultPromise = instance.prompt(); - input.emit('keypress', '\x03', { name: 'c' }); + mocks.input.emit('keypress', '\x03', { name: 'c' }); const result = await resultPromise; expect(isCancel(result)).to.equal(true); expect(instance.state).to.equal('cancel'); - expect(output.buffer).to.deep.equal([cursor.hide, 'foo', '\n', cursor.show]); + expect(mocks.output.buffer).to.deep.equal([cursor.hide, 'foo', '\n', cursor.show]); }); test('does not write initialValue to value', () => { const eventSpy = vi.fn(); const instance = new Prompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', initialValue: 'bananas', }); @@ -75,23 +68,23 @@ describe('Prompt', () => { test('re-renders on resize', () => { const renderFn = vi.fn().mockImplementation(() => 'foo'); const instance = new Prompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: renderFn, }); instance.prompt(); expect(renderFn).toHaveBeenCalledTimes(1); - output.emit('resize'); + mocks.output.emit('resize'); expect(renderFn).toHaveBeenCalledTimes(2); }); test('state is active after first render', async () => { const instance = new Prompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', }); @@ -105,8 +98,8 @@ describe('Prompt', () => { test('emits truthy confirm on y press', () => { const eventFn = vi.fn(); const instance = new Prompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', }); @@ -114,7 +107,7 @@ describe('Prompt', () => { instance.prompt(); - input.emit('keypress', 'y', { name: 'y' }); + mocks.input.emit('keypress', 'y', { name: 'y' }); expect(eventFn).toBeCalledWith(true); }); @@ -122,8 +115,8 @@ describe('Prompt', () => { test('emits falsey confirm on n press', () => { const eventFn = vi.fn(); const instance = new Prompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', }); @@ -131,7 +124,7 @@ describe('Prompt', () => { instance.prompt(); - input.emit('keypress', 'n', { name: 'n' }); + mocks.input.emit('keypress', 'n', { name: 'n' }); expect(eventFn).toBeCalledWith(false); }); @@ -139,8 +132,8 @@ describe('Prompt', () => { test('emits key event for unknown chars', () => { const eventSpy = vi.fn(); const instance = new Prompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', }); @@ -148,7 +141,7 @@ describe('Prompt', () => { instance.prompt(); - input.emit('keypress', 'z', { name: 'z' }); + mocks.input.emit('keypress', 'z', { name: 'z' }); expect(eventSpy).toBeCalledWith('z', { name: 'z' }); }); @@ -157,8 +150,8 @@ describe('Prompt', () => { const keys = ['up', 'down', 'left', 'right']; const eventSpy = vi.fn(); const instance = new Prompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', }); @@ -167,7 +160,7 @@ describe('Prompt', () => { instance.prompt(); for (const key of keys) { - input.emit('keypress', key, { name: key }); + mocks.input.emit('keypress', key, { name: key }); expect(eventSpy).toBeCalledWith(key); } }); @@ -182,8 +175,8 @@ describe('Prompt', () => { const eventSpy = vi.fn(); const instance = new Prompt( { - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', }, false @@ -194,7 +187,7 @@ describe('Prompt', () => { instance.prompt(); for (const [alias, key] of keys) { - input.emit('keypress', alias, { name: alias }); + mocks.input.emit('keypress', alias, { name: alias }); expect(eventSpy).toBeCalledWith(key); } }); @@ -203,8 +196,8 @@ describe('Prompt', () => { const abortController = new AbortController(); const instance = new Prompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', signal: abortController.signal, }); @@ -223,8 +216,8 @@ describe('Prompt', () => { abortController.abort(); const instance = new Prompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', signal: abortController.signal, }); @@ -235,8 +228,8 @@ describe('Prompt', () => { test('accepts invalid initial value', () => { const instance = new Prompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', initialValue: 'invalid', validate: (value) => (value === 'valid' ? undefined : 'must be valid'), @@ -249,8 +242,8 @@ describe('Prompt', () => { test('validates value on return', () => { const instance = new Prompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', validate: (value) => (value === 'valid' ? undefined : 'must be valid'), }); @@ -258,7 +251,7 @@ describe('Prompt', () => { instance.value = 'invalid'; - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); expect(instance.state).to.equal('error'); expect(instance.error).to.equal('must be valid'); @@ -266,15 +259,15 @@ describe('Prompt', () => { test('validates value with Error object', () => { const instance = new Prompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', validate: (value) => (value === 'valid' ? undefined : new Error('must be valid')), }); instance.prompt(); instance.value = 'invalid'; - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); expect(instance.state).to.equal('error'); expect(instance.error).to.equal('must be valid'); @@ -282,15 +275,15 @@ describe('Prompt', () => { test('validates value with regex validation', () => { const instance = new Prompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', validate: (value) => (/^[A-Z]+$/.test(value ?? '') ? undefined : 'Invalid value'), }); instance.prompt(); instance.value = 'Invalid Value $$$'; - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); expect(instance.state).to.equal('error'); expect(instance.error).to.equal('Invalid value'); @@ -298,15 +291,15 @@ describe('Prompt', () => { test('accepts valid value with regex validation', () => { const instance = new Prompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', validate: (value) => (/^[A-Z]+$/.test(value ?? '') ? undefined : 'Invalid value'), }); instance.prompt(); instance.value = 'VALID'; - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); expect(instance.state).to.equal('submit'); expect(instance.error).to.equal(''); diff --git a/packages/core/test/prompts/select.test.ts b/packages/core/src/prompts/select.test.ts similarity index 68% rename from packages/core/test/prompts/select.test.ts rename to packages/core/src/prompts/select.test.ts index a3583061..f8883aa1 100644 --- a/packages/core/test/prompts/select.test.ts +++ b/packages/core/src/prompts/select.test.ts @@ -1,38 +1,31 @@ import { cursor } from 'sisteransi'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { default as SelectPrompt } from '../../src/prompts/select.js'; -import { MockReadable } from '../mock-readable.js'; -import { MockWritable } from '../mock-writable.js'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { default as SelectPrompt } from './select.js'; +import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; describe('SelectPrompt', () => { - let input: MockReadable; - let output: MockWritable; + let mocks: Mocks<{ input: true; output: true }>; beforeEach(() => { - input = new MockReadable(); - output = new MockWritable(); - }); - - afterEach(() => { - vi.restoreAllMocks(); + mocks = createMocks({ input: true, output: true }); }); test('renders render() result', () => { const instance = new SelectPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: [{ value: 'foo' }, { value: 'bar' }], }); instance.prompt(); - expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); + expect(mocks.output.buffer).to.deep.equal([cursor.hide, 'foo']); }); describe('cursor', () => { test('cursor is index of selected item', () => { const instance = new SelectPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: [{ value: 'foo' }, { value: 'bar' }], }); @@ -40,14 +33,14 @@ describe('SelectPrompt', () => { instance.prompt(); expect(instance.cursor).to.equal(0); - input.emit('keypress', 'down', { name: 'down' }); + mocks.input.emit('keypress', 'down', { name: 'down' }); expect(instance.cursor).to.equal(1); }); test('cursor loops around', () => { const instance = new SelectPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: [{ value: 'foo' }, { value: 'bar' }, { value: 'baz' }], }); @@ -55,44 +48,44 @@ describe('SelectPrompt', () => { instance.prompt(); expect(instance.cursor).to.equal(0); - input.emit('keypress', 'up', { name: 'up' }); + mocks.input.emit('keypress', 'up', { name: 'up' }); expect(instance.cursor).to.equal(2); - input.emit('keypress', 'down', { name: 'down' }); + mocks.input.emit('keypress', 'down', { name: 'down' }); expect(instance.cursor).to.equal(0); }); test('left behaves as up', () => { const instance = new SelectPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: [{ value: 'foo' }, { value: 'bar' }, { value: 'baz' }], }); instance.prompt(); - input.emit('keypress', 'left', { name: 'left' }); + mocks.input.emit('keypress', 'left', { name: 'left' }); expect(instance.cursor).to.equal(2); }); test('right behaves as down', () => { const instance = new SelectPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: [{ value: 'foo' }, { value: 'bar' }], }); instance.prompt(); - input.emit('keypress', 'left', { name: 'left' }); + mocks.input.emit('keypress', 'left', { name: 'left' }); expect(instance.cursor).to.equal(1); }); test('initial value is selected', () => { const instance = new SelectPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: [{ value: 'foo' }, { value: 'bar' }], initialValue: 'bar', @@ -103,35 +96,35 @@ describe('SelectPrompt', () => { test('cursor skips disabled options (down)', () => { const instance = new SelectPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }], }); instance.prompt(); expect(instance.cursor).to.equal(0); - input.emit('keypress', 'down', { name: 'down' }); + mocks.input.emit('keypress', 'down', { name: 'down' }); expect(instance.cursor).to.equal(2); }); test('cursor skips disabled options (up)', () => { const instance = new SelectPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', initialValue: 'baz', options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }], }); instance.prompt(); expect(instance.cursor).to.equal(2); - input.emit('keypress', 'up', { name: 'up' }); + mocks.input.emit('keypress', 'up', { name: 'up' }); expect(instance.cursor).to.equal(0); }); test('cursor skips initial disabled option', () => { const instance = new SelectPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', options: [{ value: 'foo', disabled: true }, { value: 'bar' }, { value: 'baz' }], }); diff --git a/packages/core/test/prompts/text.test.ts b/packages/core/src/prompts/text.test.ts similarity index 64% rename from packages/core/test/prompts/text.test.ts rename to packages/core/src/prompts/text.test.ts index 91338a9b..bfd7888d 100644 --- a/packages/core/test/prompts/text.test.ts +++ b/packages/core/src/prompts/text.test.ts @@ -1,57 +1,50 @@ import { styleText } from 'node:util'; import { cursor } from 'sisteransi'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { default as TextPrompt } from '../../src/prompts/text.js'; -import { MockReadable } from '../mock-readable.js'; -import { MockWritable } from '../mock-writable.js'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { default as TextPrompt } from './text.js'; +import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; describe('TextPrompt', () => { - let input: MockReadable; - let output: MockWritable; + let mocks: Mocks<{ input: true; output: true }>; beforeEach(() => { - input = new MockReadable(); - output = new MockWritable(); - }); - - afterEach(() => { - vi.restoreAllMocks(); + mocks = createMocks({ input: true, output: true }); }); test('renders render() result', () => { const instance = new TextPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', }); // leave the promise hanging since we don't want to submit in this test instance.prompt(); - expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); + expect(mocks.output.buffer).to.deep.equal([cursor.hide, 'foo']); }); test('sets default value on finalize if no value', async () => { const instance = new TextPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', defaultValue: 'bleep bloop', }); const resultPromise = instance.prompt(); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const result = await resultPromise; expect(result).to.equal('bleep bloop'); }); test('keeps value on finalize', async () => { const instance = new TextPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', defaultValue: 'bleep bloop', }); const resultPromise = instance.prompt(); - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', '', { name: 'return' }); const result = await resultPromise; expect(result).to.equal('x'); }); @@ -59,8 +52,8 @@ describe('TextPrompt', () => { describe('cursor', () => { test('can get cursor', () => { const instance = new TextPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', }); @@ -71,69 +64,69 @@ describe('TextPrompt', () => { describe('userInputWithCursor', () => { test('returns value on submit', () => { const instance = new TextPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', }); instance.prompt(); - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', '', { name: 'return' }); expect(instance.userInputWithCursor).to.equal('x'); }); test('highlights cursor position', () => { const instance = new TextPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', }); instance.prompt(); const keys = 'foo'; for (let i = 0; i < keys.length; i++) { - input.emit('keypress', keys[i], { name: keys[i] }); + mocks.input.emit('keypress', keys[i], { name: keys[i] }); } - input.emit('keypress', undefined, { name: 'left' }); + mocks.input.emit('keypress', undefined, { name: 'left' }); expect(instance.userInputWithCursor).to.equal(`fo${styleText('inverse', 'o')}`); }); test('shows cursor at end if beyond value', () => { const instance = new TextPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', }); instance.prompt(); const keys = 'foo'; for (let i = 0; i < keys.length; i++) { - input.emit('keypress', keys[i], { name: keys[i] }); + mocks.input.emit('keypress', keys[i], { name: keys[i] }); } - input.emit('keypress', undefined, { name: 'right' }); + mocks.input.emit('keypress', undefined, { name: 'right' }); expect(instance.userInputWithCursor).to.equal('foo█'); }); test('does not use placeholder as value when pressing enter', async () => { const instance = new TextPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', placeholder: ' (hit Enter to use default)', defaultValue: 'default-value', }); const resultPromise = instance.prompt(); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const result = await resultPromise; expect(result).to.equal('default-value'); }); test('returns empty string when no value and no default', async () => { const instance = new TextPrompt({ - input, - output, + input: mocks.input, + output: mocks.output, render: () => 'foo', placeholder: ' (hit Enter to use default)', }); const resultPromise = instance.prompt(); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const result = await resultPromise; expect(result).to.equal(''); }); diff --git a/packages/core/test/utils/cursor.test.ts b/packages/core/src/utils/cursor.test.ts similarity index 100% rename from packages/core/test/utils/cursor.test.ts rename to packages/core/src/utils/cursor.test.ts diff --git a/packages/core/test/utils.test.ts b/packages/core/src/utils/index.test.ts similarity index 76% rename from packages/core/test/utils.test.ts rename to packages/core/src/utils/index.test.ts index 7909f4f0..7696524e 100644 --- a/packages/core/test/utils.test.ts +++ b/packages/core/src/utils/index.test.ts @@ -1,19 +1,13 @@ import type { Key } from 'node:readline'; import { cursor } from 'sisteransi'; -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { block } from '../src/utils/index.js'; -import { MockReadable } from './mock-readable.js'; -import { MockWritable } from './mock-writable.js'; +import { describe, expect, test, vi } from 'vitest'; +import { block } from './index.js'; +import { createMocks } from '@bomb.sh/tools/test-utils'; describe('utils', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - describe('block', () => { test('clears output on keypress', () => { - const input = new MockReadable(); - const output = new MockWritable(); + const { input, output } = createMocks({ input: true, output: true }); const callback = block({ input, output }); const event: Key = { @@ -26,8 +20,7 @@ describe('utils', () => { }); test('clears output vertically when return pressed', () => { - const input = new MockReadable(); - const output = new MockWritable(); + const { input, output } = createMocks({ input: true, output: true }); const callback = block({ input, output }); const event: Key = { @@ -40,8 +33,7 @@ describe('utils', () => { }); test('ignores additional keypresses after dispose', () => { - const input = new MockReadable(); - const output = new MockWritable(); + const { input, output } = createMocks({ input: true, output: true }); const callback = block({ input, output }); const event: Key = { @@ -55,8 +47,7 @@ describe('utils', () => { }); test('exits on ctrl-c', () => { - const input = new MockReadable(); - const output = new MockWritable(); + const { input, output } = createMocks({ input: true, output: true }); // purposely don't keep the callback since we would exit the process block({ input, output }); const spy = vi.spyOn(process, 'exit').mockImplementation((() => { @@ -73,8 +64,7 @@ describe('utils', () => { }); test('does not clear if overwrite=false', () => { - const input = new MockReadable(); - const output = new MockWritable(); + const { input, output } = createMocks({ input: true, output: true }); const callback = block({ input, output, overwrite: false }); const event: Key = { diff --git a/packages/core/test/mock-readable.ts b/packages/core/test/mock-readable.ts deleted file mode 100644 index b08e4879..00000000 --- a/packages/core/test/mock-readable.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Readable } from 'node:stream'; - -export class MockReadable extends Readable { - protected _buffer: unknown[] | null = []; - - _read() { - if (this._buffer === null) { - this.push(null); - return; - } - - for (const val of this._buffer) { - this.push(val); - } - - this._buffer = []; - } - - pushValue(val: unknown): void { - this._buffer?.push(val); - } - - close(): void { - this._buffer = null; - } -} diff --git a/packages/core/test/mock-writable.ts b/packages/core/test/mock-writable.ts deleted file mode 100644 index 746b0a0d..00000000 --- a/packages/core/test/mock-writable.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Writable } from 'node:stream'; - -export class MockWritable extends Writable { - public buffer: string[] = []; - - _write( - chunk: any, - _encoding: BufferEncoding, - callback: (error?: Error | null | undefined) => void - ): void { - this.buffer.push(chunk.toString()); - callback(); - } -} diff --git a/packages/core/test/prompts/multi-line.test.ts b/packages/core/test/prompts/multi-line.test.ts deleted file mode 100644 index 53f86a5d..00000000 --- a/packages/core/test/prompts/multi-line.test.ts +++ /dev/null @@ -1,355 +0,0 @@ -import { styleText } from 'node:util'; -import { cursor } from 'sisteransi'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { default as MultiLinePrompt } from '../../src/prompts/multi-line.js'; -import { MockReadable } from '../mock-readable.js'; -import { MockWritable } from '../mock-writable.js'; - -describe('MultiLinePrompt', () => { - let input: MockReadable; - let output: MockWritable; - - beforeEach(() => { - input = new MockReadable(); - output = new MockWritable(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - test('renders render() result', () => { - const instance = new MultiLinePrompt({ - input, - output, - render: () => 'foo', - }); - instance.prompt(); - expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); - }); - - test('sets default value on finalize if no value', async () => { - const instance = new MultiLinePrompt({ - input, - output, - render: () => 'foo', - defaultValue: 'bleep bloop', - }); - const resultPromise = instance.prompt(); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'return' }); - const result = await resultPromise; - expect(result).to.equal('bleep bloop'); - }); - - test('keeps value on finalize', async () => { - const instance = new MultiLinePrompt({ - input, - output, - render: () => 'foo', - defaultValue: 'bleep bloop', - }); - const resultPromise = instance.prompt(); - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'return' }); - const result = await resultPromise; - expect(result).to.equal('x'); - }); - - describe('cursor', () => { - test('can get cursor', () => { - const instance = new MultiLinePrompt({ - input, - output, - render: () => 'foo', - }); - - expect(instance.cursor).to.equal(0); - }); - }); - - describe('userInputWithCursor', () => { - test('returns value on submit', () => { - const instance = new MultiLinePrompt({ - input, - output, - render: () => 'foo', - }); - instance.prompt(); - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'return' }); - expect(instance.userInputWithCursor).to.equal('x'); - }); - - test('highlights cursor position', () => { - const instance = new MultiLinePrompt({ - input, - output, - render: () => 'foo', - }); - instance.prompt(); - const keys = 'foo'; - for (let i = 0; i < keys.length; i++) { - input.emit('keypress', keys[i], { name: keys[i] }); - } - input.emit('keypress', undefined, { name: 'left' }); - expect(instance.userInputWithCursor).to.equal(`fo${styleText('inverse', 'o')}`); - }); - - test('shows cursor at end if beyond value', () => { - const instance = new MultiLinePrompt({ - input, - output, - render: () => 'foo', - }); - instance.prompt(); - const keys = 'foo'; - for (let i = 0; i < keys.length; i++) { - input.emit('keypress', keys[i], { name: keys[i] }); - } - input.emit('keypress', undefined, { name: 'right' }); - expect(instance.userInputWithCursor).to.equal('foo█'); - }); - }); - - describe('key', () => { - test('return inserts newline', () => { - const instance = new MultiLinePrompt({ - input, - output, - render: () => 'foo', - }); - instance.prompt(); - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', '', { name: 'return' }); - expect(instance.userInput).to.equal('x\n'); - }); - - test('double return submits', async () => { - const instance = new MultiLinePrompt({ - input, - output, - render: () => 'foo', - }); - const resultPromise = instance.prompt(); - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'return' }); - const result = await resultPromise; - expect(result).to.equal('x'); - }); - - test('double return inserts when showSubmit is true', async () => { - const instance = new MultiLinePrompt({ - input, - output, - render: () => 'foo', - showSubmit: true, - }); - const resultPromise = instance.prompt(); - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '\t', { name: 'tab' }); - input.emit('keypress', '', { name: 'return' }); - const result = await resultPromise; - expect(result).to.equal('x\n\n'); - }); - - test('typing when submit selected jumps back to text', async () => { - const instance = new MultiLinePrompt({ - input, - output, - render: () => 'foo', - showSubmit: true, - }); - const resultPromise = instance.prompt(); - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', '\t', { name: 'tab' }); - input.emit('keypress', 'y', { name: 'y' }); - input.emit('keypress', '\t', { name: 'tab' }); - input.emit('keypress', '', { name: 'return' }); - const result = await resultPromise; - expect(result).to.equal('xy'); - }); - - test('backspace deletes previous char', async () => { - const instance = new MultiLinePrompt({ - input, - output, - render: () => 'foo', - }); - const resultPromise = instance.prompt(); - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', 'y', { name: 'y' }); - input.emit('keypress', '', { name: 'backspace' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'return' }); - const result = await resultPromise; - expect(result).to.equal('x'); - }); - - test('delete deletes next char', async () => { - const instance = new MultiLinePrompt({ - input, - output, - render: () => 'foo', - }); - const resultPromise = instance.prompt(); - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', 'y', { name: 'y' }); - input.emit('keypress', '', { name: 'left' }); - input.emit('keypress', '', { name: 'delete' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'return' }); - const result = await resultPromise; - expect(result).to.equal('x'); - }); - - test('delete does nothing at end', async () => { - const instance = new MultiLinePrompt({ - input, - output, - render: () => 'foo', - }); - const resultPromise = instance.prompt(); - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', '', { name: 'delete' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'return' }); - const result = await resultPromise; - expect(result).to.equal('x'); - }); - - test('backspace does nothing at start', async () => { - const instance = new MultiLinePrompt({ - input, - output, - render: () => 'foo', - }); - const resultPromise = instance.prompt(); - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', '', { name: 'left' }); - input.emit('keypress', '', { name: 'backspace' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'return' }); - const result = await resultPromise; - expect(result).to.equal('x'); - }); - - test('left moves left until start', async () => { - const instance = new MultiLinePrompt({ - input, - output, - render: () => 'foo', - }); - const resultPromise = instance.prompt(); - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', '', { name: 'left' }); - input.emit('keypress', '', { name: 'left' }); - input.emit('keypress', 'y', { name: 'y' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'return' }); - const result = await resultPromise; - expect(result).to.equal('yx'); - }); - - test('right moves right until end', async () => { - const instance = new MultiLinePrompt({ - input, - output, - render: () => 'foo', - }); - const resultPromise = instance.prompt(); - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', 'y', { name: 'y' }); - input.emit('keypress', '', { name: 'left' }); - input.emit('keypress', '', { name: 'right' }); - input.emit('keypress', '', { name: 'right' }); - input.emit('keypress', 'z', { name: 'z' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'return' }); - const result = await resultPromise; - expect(result).to.equal('xyz'); - }); - - test('left moves across lines', async () => { - const instance = new MultiLinePrompt({ - input, - output, - render: () => 'foo', - }); - const resultPromise = instance.prompt(); - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', 'y', { name: 'y' }); - input.emit('keypress', '', { name: 'left' }); - input.emit('keypress', '', { name: 'left' }); - input.emit('keypress', 'z', { name: 'z' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'return' }); - const result = await resultPromise; - expect(result).to.equal('xz\ny'); - }); - - test('right moves across lines', async () => { - const instance = new MultiLinePrompt({ - input, - output, - render: () => 'foo', - }); - const resultPromise = instance.prompt(); - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', 'y', { name: 'y' }); - input.emit('keypress', '', { name: 'left' }); - input.emit('keypress', '', { name: 'left' }); - input.emit('keypress', '', { name: 'right' }); - input.emit('keypress', '', { name: 'right' }); - input.emit('keypress', 'z', { name: 'z' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'return' }); - const result = await resultPromise; - expect(result).to.equal('x\nyz'); - }); - - test('up moves up a line', async () => { - const instance = new MultiLinePrompt({ - input, - output, - render: () => 'foo', - }); - const resultPromise = instance.prompt(); - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', 'y', { name: 'y' }); - input.emit('keypress', '', { name: 'up' }); - input.emit('keypress', 'z', { name: 'z' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'return' }); - const result = await resultPromise; - expect(result).to.equal('xz\ny'); - }); - - test('down moves down a line', async () => { - const instance = new MultiLinePrompt({ - input, - output, - render: () => 'foo', - }); - const resultPromise = instance.prompt(); - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', 'y', { name: 'y' }); - input.emit('keypress', '', { name: 'up' }); - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', 'z', { name: 'z' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'return' }); - const result = await resultPromise; - expect(result).to.equal('x\nyz'); - }); - }); -}); diff --git a/packages/prompts/package.json b/packages/prompts/package.json index fa9dfcf7..2a841b19 100644 --- a/packages/prompts/package.json +++ b/packages/prompts/package.json @@ -48,7 +48,7 @@ ], "packageManager": "pnpm@9.14.2", "scripts": { - "build": "bsh build", + "build": "bsh build \"src/**/*.ts\" \"!src/**/*.test.ts\"", "prepack": "pnpm build", "test": "bsh test", "lint": "bsh lint" diff --git a/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap b/packages/prompts/src/__snapshots__/autocomplete.test.ts.snap similarity index 100% rename from packages/prompts/test/__snapshots__/autocomplete.test.ts.snap rename to packages/prompts/src/__snapshots__/autocomplete.test.ts.snap diff --git a/packages/prompts/test/__snapshots__/box.test.ts.snap b/packages/prompts/src/__snapshots__/box.test.ts.snap similarity index 100% rename from packages/prompts/test/__snapshots__/box.test.ts.snap rename to packages/prompts/src/__snapshots__/box.test.ts.snap diff --git a/packages/prompts/test/__snapshots__/confirm.test.ts.snap b/packages/prompts/src/__snapshots__/confirm.test.ts.snap similarity index 100% rename from packages/prompts/test/__snapshots__/confirm.test.ts.snap rename to packages/prompts/src/__snapshots__/confirm.test.ts.snap diff --git a/packages/prompts/test/__snapshots__/date.test.ts.snap b/packages/prompts/src/__snapshots__/date.test.ts.snap similarity index 100% rename from packages/prompts/test/__snapshots__/date.test.ts.snap rename to packages/prompts/src/__snapshots__/date.test.ts.snap diff --git a/packages/prompts/test/__snapshots__/group-multi-select.test.ts.snap b/packages/prompts/src/__snapshots__/group-multi-select.test.ts.snap similarity index 100% rename from packages/prompts/test/__snapshots__/group-multi-select.test.ts.snap rename to packages/prompts/src/__snapshots__/group-multi-select.test.ts.snap diff --git a/packages/prompts/test/__snapshots__/log.test.ts.snap b/packages/prompts/src/__snapshots__/log.test.ts.snap similarity index 100% rename from packages/prompts/test/__snapshots__/log.test.ts.snap rename to packages/prompts/src/__snapshots__/log.test.ts.snap diff --git a/packages/prompts/test/__snapshots__/multi-line.test.ts.snap b/packages/prompts/src/__snapshots__/multi-line.test.ts.snap similarity index 100% rename from packages/prompts/test/__snapshots__/multi-line.test.ts.snap rename to packages/prompts/src/__snapshots__/multi-line.test.ts.snap diff --git a/packages/prompts/test/__snapshots__/multi-select.test.ts.snap b/packages/prompts/src/__snapshots__/multi-select.test.ts.snap similarity index 100% rename from packages/prompts/test/__snapshots__/multi-select.test.ts.snap rename to packages/prompts/src/__snapshots__/multi-select.test.ts.snap diff --git a/packages/prompts/test/__snapshots__/note.test.ts.snap b/packages/prompts/src/__snapshots__/note.test.ts.snap similarity index 100% rename from packages/prompts/test/__snapshots__/note.test.ts.snap rename to packages/prompts/src/__snapshots__/note.test.ts.snap diff --git a/packages/prompts/test/__snapshots__/password.test.ts.snap b/packages/prompts/src/__snapshots__/password.test.ts.snap similarity index 100% rename from packages/prompts/test/__snapshots__/password.test.ts.snap rename to packages/prompts/src/__snapshots__/password.test.ts.snap diff --git a/packages/prompts/test/__snapshots__/path.test.ts.snap b/packages/prompts/src/__snapshots__/path.test.ts.snap similarity index 100% rename from packages/prompts/test/__snapshots__/path.test.ts.snap rename to packages/prompts/src/__snapshots__/path.test.ts.snap diff --git a/packages/prompts/test/__snapshots__/progress-bar.test.ts.snap b/packages/prompts/src/__snapshots__/progress-bar.test.ts.snap similarity index 100% rename from packages/prompts/test/__snapshots__/progress-bar.test.ts.snap rename to packages/prompts/src/__snapshots__/progress-bar.test.ts.snap diff --git a/packages/prompts/test/__snapshots__/select-key.test.ts.snap b/packages/prompts/src/__snapshots__/select-key.test.ts.snap similarity index 100% rename from packages/prompts/test/__snapshots__/select-key.test.ts.snap rename to packages/prompts/src/__snapshots__/select-key.test.ts.snap diff --git a/packages/prompts/test/__snapshots__/select.test.ts.snap b/packages/prompts/src/__snapshots__/select.test.ts.snap similarity index 100% rename from packages/prompts/test/__snapshots__/select.test.ts.snap rename to packages/prompts/src/__snapshots__/select.test.ts.snap diff --git a/packages/prompts/test/__snapshots__/spinner.test.ts.snap b/packages/prompts/src/__snapshots__/spinner.test.ts.snap similarity index 100% rename from packages/prompts/test/__snapshots__/spinner.test.ts.snap rename to packages/prompts/src/__snapshots__/spinner.test.ts.snap diff --git a/packages/prompts/test/__snapshots__/task-log.test.ts.snap b/packages/prompts/src/__snapshots__/task-log.test.ts.snap similarity index 100% rename from packages/prompts/test/__snapshots__/task-log.test.ts.snap rename to packages/prompts/src/__snapshots__/task-log.test.ts.snap diff --git a/packages/prompts/test/__snapshots__/text.test.ts.snap b/packages/prompts/src/__snapshots__/text.test.ts.snap similarity index 100% rename from packages/prompts/test/__snapshots__/text.test.ts.snap rename to packages/prompts/src/__snapshots__/text.test.ts.snap diff --git a/packages/prompts/test/autocomplete.test.ts b/packages/prompts/src/autocomplete.test.ts similarity index 60% rename from packages/prompts/test/autocomplete.test.ts rename to packages/prompts/src/autocomplete.test.ts index fe55444c..2424da37 100644 --- a/packages/prompts/test/autocomplete.test.ts +++ b/packages/prompts/src/autocomplete.test.ts @@ -1,11 +1,10 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { autocomplete, autocompleteMultiselect } from '../src/autocomplete.js'; -import { isCancel, updateSettings } from '../src/index.js'; -import { MockReadable, MockWritable } from './test-utils.js'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { autocomplete, autocompleteMultiselect } from './autocomplete.js'; +import { isCancel, updateSettings } from './index.js'; +import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; describe('autocomplete', () => { - let input: MockReadable; - let output: MockWritable; + let mocks: Mocks<{ input: true; output: true }>; const testOptions = [ { value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' }, @@ -15,12 +14,10 @@ describe('autocomplete', () => { ]; beforeEach(() => { - input = new MockReadable(); - output = new MockWritable(); + mocks = createMocks({ input: true, output: true }); }); afterEach(() => { - vi.restoreAllMocks(); updateSettings({ withGuide: true }); }); @@ -28,13 +25,13 @@ describe('autocomplete', () => { const result = autocomplete({ message: 'Select a fruit', options: testOptions, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('limits displayed options when maxItems is set', async () => { @@ -47,80 +44,80 @@ describe('autocomplete', () => { message: 'Select an option', options, maxItems: 6, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('shows no matches message when search has no results', async () => { const result = autocomplete({ message: 'Select a fruit', options: testOptions, - input, - output, + input: mocks.input, + output: mocks.output, }); // Type something that won't match - input.emit('keypress', 'z', { name: 'z' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'z', { name: 'z' }); + mocks.input.emit('keypress', '', { name: 'return' }); await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('shows hint when option has hint and is focused', async () => { const result = autocomplete({ message: 'Select a fruit', options: [...testOptions, { value: 'kiwi', label: 'Kiwi', hint: 'New Zealand' }], - input, - output, + input: mocks.input, + output: mocks.output, }); // Navigate to the option with hint - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'return' }); await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('shows selected value in submit state', async () => { const result = autocomplete({ message: 'Select a fruit', options: testOptions, - input, - output, + input: mocks.input, + output: mocks.output, }); // Select an option and submit - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe('banana'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('shows strikethrough in cancel state', async () => { const result = autocomplete({ message: 'Select a fruit', options: testOptions, - input, - output, + input: mocks.input, + output: mocks.output, }); // Cancel with Ctrl+C - input.emit('keypress', '\x03', { name: 'c', ctrl: true }); + mocks.input.emit('keypress', '\x03', { name: 'c', ctrl: true }); const value = await result; expect(typeof value === 'symbol').toBe(true); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders placeholder if set', async () => { @@ -128,13 +125,13 @@ describe('autocomplete', () => { message: 'Select a fruit', placeholder: 'Type to search...', options: testOptions, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); expect(value).toBe('apple'); }); @@ -143,12 +140,12 @@ describe('autocomplete', () => { message: 'Select a fruit', placeholder: 'apple', options: testOptions, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '\t', { name: 'tab' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '\t', { name: 'tab' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe('apple'); }); @@ -158,12 +155,12 @@ describe('autocomplete', () => { message: 'Select a fruit', placeholder: 'Type to search...', options: testOptions, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '\t', { name: 'tab' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '\t', { name: 'tab' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; // Tab did not fill input with placeholder (no option matches), so Enter submits first option expect(value).toBe('apple'); @@ -174,15 +171,15 @@ describe('autocomplete', () => { message: 'Select a fruit', options: testOptions, initialValue: 'cherry', - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe('cherry'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('can be aborted by a signal', async () => { @@ -190,15 +187,15 @@ describe('autocomplete', () => { const result = autocomplete({ message: 'foo', options: testOptions, - input, - output, + input: mocks.input, + output: mocks.output, signal: controller.signal, }); controller.abort(); const value = await result; expect(isCancel(value)).toBe(true); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('autocompleteMultiselect respects withGuide: false', async () => { @@ -206,18 +203,18 @@ describe('autocomplete', () => { message: 'Select fruits', options: testOptions, withGuide: false, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['banana']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('autocompleteMultiselect respects global withGuide: false', async () => { @@ -226,22 +223,22 @@ describe('autocomplete', () => { const result = autocompleteMultiselect({ message: 'Select fruits', options: testOptions, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['banana']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders bottom ellipsis when items do not fit', async () => { - output.rows = 5; + mocks.output.rows = 5; const options = [ { @@ -258,17 +255,17 @@ describe('autocomplete', () => { message: 'Select an option', options, maxItems: 5, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders top ellipsis when scrolled down and its do not fit', async () => { - output.rows = 5; + mocks.output.rows = 5; const options = [ { @@ -288,13 +285,13 @@ describe('autocomplete', () => { options, initialValue: 'option2', maxItems: 5, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('placeholder is shown if set', async () => { @@ -302,14 +299,14 @@ describe('autocomplete', () => { message: 'Select a fruit', placeholder: 'Type to search...', options: testOptions, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'g', { name: 'g' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'g', { name: 'g' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); expect(value).toBe('grape'); }); @@ -318,18 +315,18 @@ describe('autocomplete', () => { const result = autocomplete({ message: 'Select a fruit', options: optionsWithDisabled, - input, - output, + input: mocks.input, + output: mocks.output, }); for (let i = 0; i < 5; i++) { - input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'down' }); } - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe('apple'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('cannot select disabled options when only one left', async () => { @@ -337,22 +334,21 @@ describe('autocomplete', () => { const result = autocomplete({ message: 'Select a fruit', options: optionsWithDisabled, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'k', { name: 'k' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'k', { name: 'k' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe(undefined); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); describe('autocompleteMultiselect', () => { - let input: MockReadable; - let output: MockWritable; + let mocks: Mocks<{ input: true; output: true }>; const testOptions = [ { value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' }, @@ -362,12 +358,7 @@ describe('autocompleteMultiselect', () => { ]; beforeEach(() => { - input = new MockReadable(); - output = new MockWritable(); - }); - - afterEach(() => { - vi.restoreAllMocks(); + mocks = createMocks({ input: true, output: true }); }); test('renders error when empty selection & required is true', async () => { @@ -375,15 +366,15 @@ describe('autocompleteMultiselect', () => { message: 'Select a fruit', options: testOptions, required: true, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'tab' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'tab' }); + mocks.input.emit('keypress', '', { name: 'return' }); await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('can be aborted by a signal', async () => { @@ -391,42 +382,42 @@ describe('autocompleteMultiselect', () => { const result = autocompleteMultiselect({ message: 'foo', options: testOptions, - input, - output, + input: mocks.input, + output: mocks.output, signal: controller.signal, }); controller.abort(); const value = await result; expect(isCancel(value)).toBe(true); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('can use navigation keys to select options', async () => { const result = autocompleteMultiselect({ message: 'Select fruits', options: testOptions, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['banana', 'cherry']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('supports custom filter function', async () => { const result = autocompleteMultiselect({ message: 'Select fruits', options: testOptions, - input, - output, + input: mocks.input, + output: mocks.output, // Custom filter that only matches exact prefix filter: (search, option) => { const label = option.label ?? String(option.value ?? ''); @@ -435,13 +426,13 @@ describe('autocompleteMultiselect', () => { }); // Type 'a' - should match 'Apple' only (not 'Banana' which contains 'a') - input.emit('keypress', 'a', { name: 'a' }); - input.emit('keypress', '', { name: 'tab' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'a', { name: 'a' }); + mocks.input.emit('keypress', '', { name: 'tab' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['apple']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('displays disabled options correctly', async () => { @@ -449,19 +440,19 @@ describe('autocompleteMultiselect', () => { const result = autocompleteMultiselect({ message: 'Select a fruit', options: optionsWithDisabled, - input, - output, + input: mocks.input, + output: mocks.output, }); for (let i = 0; i < testOptions.length; i++) { - input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'down' }); } - input.emit('keypress', '', { name: 'tab' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'tab' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['apple']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('cannot select disabled options when only one left', async () => { @@ -469,17 +460,17 @@ describe('autocompleteMultiselect', () => { const result = autocompleteMultiselect({ message: 'Select a fruit', options: optionsWithDisabled, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'k', { name: 'k' }); - input.emit('keypress', '', { name: 'tab' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'k', { name: 'k' }); + mocks.input.emit('keypress', '', { name: 'tab' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual([]); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('Tab with placeholder fills input; Enter submits current selection', async () => { @@ -487,20 +478,19 @@ describe('autocompleteMultiselect', () => { message: 'Select fruits', placeholder: 'apple', options: testOptions, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '\t', { name: 'tab' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '\t', { name: 'tab' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual([]); }); }); describe('autocomplete with custom filter', () => { - let input: MockReadable; - let output: MockWritable; + let mocks: Mocks<{ input: true; output: true }>; const testOptions = [ { value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' }, @@ -508,20 +498,15 @@ describe('autocomplete with custom filter', () => { ]; beforeEach(() => { - input = new MockReadable(); - output = new MockWritable(); - }); - - afterEach(() => { - vi.restoreAllMocks(); + mocks = createMocks({ input: true, output: true }); }); test('uses custom filter function when provided', async () => { const result = autocomplete({ message: 'Select a fruit', options: testOptions, - input, - output, + input: mocks.input, + output: mocks.output, // Custom filter that only matches exact prefix filter: (search, option) => { const label = option.label ?? String(option.value ?? ''); @@ -530,29 +515,29 @@ describe('autocomplete with custom filter', () => { }); // Type 'a' - should match 'Apple' only (not 'Banana' which contains 'a') - input.emit('keypress', 'a', { name: 'a' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'a', { name: 'a' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe('apple'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('falls back to default filter when not provided', async () => { const result = autocomplete({ message: 'Select a fruit', options: testOptions, - input, - output, + input: mocks.input, + output: mocks.output, }); // Type 'a' - default filter should match both 'Apple' and 'Banana' - input.emit('keypress', 'a', { name: 'a' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'a', { name: 'a' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; // First match should be selected expect(value).toBe('apple'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); diff --git a/packages/prompts/test/box.test.ts b/packages/prompts/src/box.test.ts similarity index 58% rename from packages/prompts/test/box.test.ts rename to packages/prompts/src/box.test.ts index 54378210..7fb043e4 100644 --- a/packages/prompts/test/box.test.ts +++ b/packages/prompts/src/box.test.ts @@ -1,273 +1,260 @@ import { styleText } from 'node:util'; import { updateSettings } from '@clack/core'; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; -import * as prompts from '../src/index.js'; -import { MockReadable, MockWritable } from './test-utils.js'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import * as prompts from './index.js'; +import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; describe.each(['true', 'false'])('box (isCI = %s)', (isCI) => { - let originalCI: string | undefined; - let output: MockWritable; - let input: MockReadable; - - beforeAll(() => { - originalCI = process.env.CI; - process.env.CI = isCI; - }); - - afterAll(() => { - process.env.CI = originalCI; - }); + let mocks: Mocks<{ input: true; output: true }>; beforeEach(() => { - output = new MockWritable(); - input = new MockReadable(); + mocks = createMocks({ input: true, output: true, env: { CI: isCI } }); }); afterEach(() => { - vi.restoreAllMocks(); updateSettings({ withGuide: true }); }); test('renders message', () => { prompts.box('message', undefined, { - input, - output, + input: mocks.input, + output: mocks.output, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders message with title', () => { prompts.box('message', 'some title', { - input, - output, + input: mocks.input, + output: mocks.output, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders as wide as longest line with width: auto', () => { prompts.box('short\nsomewhat questionably long line', 'title', { - input, - output, + input: mocks.input, + output: mocks.output, width: 'auto', }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders as specified width', () => { prompts.box('short\nsomewhat questionably long line', 'title', { - input, - output, + input: mocks.input, + output: mocks.output, width: 0.5, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('wraps content to fit within specified width', () => { prompts.box('foo bar'.repeat(20), 'title', { - input, - output, + input: mocks.input, + output: mocks.output, width: 0.5, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders specified titlePadding', () => { prompts.box('message', 'title', { - input, - output, + input: mocks.input, + output: mocks.output, titlePadding: 6, width: 'auto', }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders specified contentPadding', () => { prompts.box('message', 'title', { - input, - output, + input: mocks.input, + output: mocks.output, contentPadding: 6, width: 'auto', }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders without guide when withGuide is false', () => { prompts.box('message', 'title', { - input, - output, + input: mocks.input, + output: mocks.output, withGuide: false, width: 'auto', }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders without guide when global withGuide is false', () => { updateSettings({ withGuide: false }); prompts.box('message', 'title', { - input, - output, + input: mocks.input, + output: mocks.output, width: 'auto', }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders truncated long titles', () => { prompts.box('message', 'foo'.repeat(20), { - input, - output, + input: mocks.input, + output: mocks.output, width: 0.2, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('cannot have width larger than 100%', () => { prompts.box('message', 'title', { - input, - output, + input: mocks.input, + output: mocks.output, width: 1.1, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders rounded corners when rounded is true', () => { prompts.box('message', 'title', { - input, - output, + input: mocks.input, + output: mocks.output, rounded: true, width: 'auto', }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders auto width with content longer than title', () => { prompts.box('message'.repeat(4), 'title', { - input, - output, + input: mocks.input, + output: mocks.output, width: 'auto', }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders auto width with title longer than content', () => { prompts.box('message', 'title'.repeat(4), { - input, - output, + input: mocks.input, + output: mocks.output, width: 'auto', }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders left aligned title', () => { prompts.box('message', 'title', { - input, - output, + input: mocks.input, + output: mocks.output, titleAlign: 'left', width: 'auto', }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders right aligned title', () => { prompts.box('message', 'title', { - input, - output, + input: mocks.input, + output: mocks.output, titleAlign: 'right', width: 'auto', }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders center aligned title', () => { prompts.box('message', 'title', { - input, - output, + input: mocks.input, + output: mocks.output, titleAlign: 'center', width: 'auto', }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders left aligned content', () => { prompts.box('message', 'title', { - input, - output, + input: mocks.input, + output: mocks.output, contentAlign: 'left', width: 'auto', }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders right aligned content', () => { prompts.box('message', 'title', { - input, - output, + input: mocks.input, + output: mocks.output, contentAlign: 'right', width: 'auto', }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders center aligned content', () => { prompts.box('message', 'title', { - input, - output, + input: mocks.input, + output: mocks.output, contentAlign: 'center', width: 'auto', }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders with formatBorder formatting', () => { prompts.box('message', 'title', { - input, - output, + input: mocks.input, + output: mocks.output, width: 'auto', formatBorder: (text: string) => styleText('red', text), }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders wide characters with auto width', () => { const messages = ['이게 첫 번째 줄이에요', 'これは次の行です']; prompts.box(messages.join('\n'), '这是标题', { - input, - output, + input: mocks.input, + output: mocks.output, width: 'auto', }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders wide characters with specified width', () => { const messages = ['이게 첫 번째 줄이에요', 'これは次の行です']; prompts.box(messages.join('\n'), '这是标题', { - input, - output, + input: mocks.input, + output: mocks.output, width: 0.2, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); diff --git a/packages/prompts/test/confirm.test.ts b/packages/prompts/src/confirm.test.ts similarity index 52% rename from packages/prompts/test/confirm.test.ts rename to packages/prompts/src/confirm.test.ts index f79d22bb..28dcd5c5 100644 --- a/packages/prompts/test/confirm.test.ts +++ b/packages/prompts/src/confirm.test.ts @@ -1,187 +1,174 @@ import { updateSettings } from '@clack/core'; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; -import * as prompts from '../src/index.js'; -import { MockReadable, MockWritable } from './test-utils.js'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import * as prompts from './index.js'; +import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; describe.each(['true', 'false'])('confirm (isCI = %s)', (isCI) => { - let originalCI: string | undefined; - let output: MockWritable; - let input: MockReadable; - - beforeAll(() => { - originalCI = process.env.CI; - process.env.CI = isCI; - }); - - afterAll(() => { - process.env.CI = originalCI; - }); + let mocks: Mocks<{ input: true; output: true }>; beforeEach(() => { - output = new MockWritable(); - input = new MockReadable(); + mocks = createMocks({ input: true, output: true, env: { CI: isCI } }); }); afterEach(() => { - vi.restoreAllMocks(); updateSettings({ withGuide: true }); }); test('renders message with choices', async () => { const result = prompts.confirm({ message: 'foo', - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe(true); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders custom active choice', async () => { const result = prompts.confirm({ message: 'foo', active: 'bleep', - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe(true); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders custom inactive choice', async () => { const result = prompts.confirm({ message: 'foo', inactive: 'bleep', - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe(true); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders options in vertical alignment', async () => { const result = prompts.confirm({ message: 'foo', vertical: true, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe(true); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('right arrow moves to next choice', async () => { const result = prompts.confirm({ message: 'foo', - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'right', { name: 'right' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'right', { name: 'right' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe(false); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('left arrow moves to previous choice', async () => { const result = prompts.confirm({ message: 'foo', - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'right', { name: 'right' }); - input.emit('keypress', 'left', { name: 'left' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'right', { name: 'right' }); + mocks.input.emit('keypress', 'left', { name: 'left' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe(true); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('can cancel', async () => { const result = prompts.confirm({ message: 'foo', - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'escape', { name: 'escape' }); + mocks.input.emit('keypress', 'escape', { name: 'escape' }); const value = await result; expect(prompts.isCancel(value)).toBe(true); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('can set initialValue', async () => { const result = prompts.confirm({ message: 'foo', initialValue: false, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe(false); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('can be aborted by a signal', async () => { const controller = new AbortController(); const result = prompts.confirm({ message: 'yes?', - input, - output, + input: mocks.input, + output: mocks.output, signal: controller.signal, }); controller.abort(); const value = await result; expect(prompts.isCancel(value)).toBe(true); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('withGuide: false removes guide', async () => { const result = prompts.confirm({ message: 'foo', withGuide: false, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('global withGuide: false removes guide', async () => { @@ -189,28 +176,28 @@ describe.each(['true', 'false'])('confirm (isCI = %s)', (isCI) => { const result = prompts.confirm({ message: 'foo', - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders multi-line messages correctly', async () => { const result = prompts.confirm({ message: 'foo\nbar\nbaz', - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); diff --git a/packages/prompts/test/date.test.ts b/packages/prompts/src/date.test.ts similarity index 62% rename from packages/prompts/test/date.test.ts rename to packages/prompts/src/date.test.ts index 59490052..bd7e9cbc 100644 --- a/packages/prompts/test/date.test.ts +++ b/packages/prompts/src/date.test.ts @@ -1,7 +1,7 @@ import { updateSettings } from '@clack/core'; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; -import * as prompts from '../src/index.js'; -import { MockReadable, MockWritable } from './test-utils.js'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import * as prompts from './index.js'; +import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; const d = (iso: string) => { const [y, m, day] = iso.slice(0, 10).split('-').map(Number); @@ -9,26 +9,13 @@ const d = (iso: string) => { }; describe.each(['true', 'false'])('date (isCI = %s)', (isCI) => { - let originalCI: string | undefined; - let output: MockWritable; - let input: MockReadable; - - beforeAll(() => { - originalCI = process.env.CI; - process.env.CI = isCI; - }); - - afterAll(() => { - process.env.CI = originalCI; - }); + let mocks: Mocks<{ input: true; output: true }>; beforeEach(() => { - output = new MockWritable(); - input = new MockReadable(); + mocks = createMocks({ input: true, output: true, env: { CI: isCI } }); }); afterEach(() => { - vi.restoreAllMocks(); updateSettings({ withGuide: true }); }); @@ -37,15 +24,15 @@ describe.each(['true', 'false'])('date (isCI = %s)', (isCI) => { message: 'Pick a date', locale: 'en-US', initialValue: d('2025-01-15'), - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', undefined, { name: 'return' }); + mocks.input.emit('keypress', undefined, { name: 'return' }); await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders initial value', async () => { @@ -53,33 +40,33 @@ describe.each(['true', 'false'])('date (isCI = %s)', (isCI) => { message: 'Pick a date', locale: 'en-US', initialValue: d('2025-01-15'), - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', undefined, { name: 'return' }); + mocks.input.emit('keypress', undefined, { name: 'return' }); const value = await result; expect(value).toBeInstanceOf(Date); expect((value as Date).toISOString().slice(0, 10)).toBe('2025-01-15'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('can cancel', async () => { const result = prompts.date({ message: 'Pick a date', locale: 'en-US', - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'escape', { name: 'escape' }); + mocks.input.emit('keypress', 'escape', { name: 'escape' }); const value = await result; expect(prompts.isCancel(value)).toBe(true); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders submitted value', async () => { @@ -87,17 +74,17 @@ describe.each(['true', 'false'])('date (isCI = %s)', (isCI) => { message: 'Pick a date', locale: 'en-US', initialValue: d('2025-06-15'), - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', undefined, { name: 'return' }); + mocks.input.emit('keypress', undefined, { name: 'return' }); const value = await result; expect(value).toBeInstanceOf(Date); expect((value as Date).toISOString().slice(0, 10)).toBe('2025-06-15'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('defaultValue used when empty submit', async () => { @@ -105,17 +92,17 @@ describe.each(['true', 'false'])('date (isCI = %s)', (isCI) => { message: 'Pick a date', locale: 'en-US', defaultValue: d('2025-12-25'), - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', undefined, { name: 'return' }); + mocks.input.emit('keypress', undefined, { name: 'return' }); const value = await result; expect(value).toBeInstanceOf(Date); expect((value as Date).toISOString().slice(0, 10)).toBe('2025-12-25'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('withGuide: false removes guide', async () => { @@ -124,15 +111,15 @@ describe.each(['true', 'false'])('date (isCI = %s)', (isCI) => { locale: 'en-US', withGuide: false, initialValue: d('2025-01-15'), - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', undefined, { name: 'return' }); + mocks.input.emit('keypress', undefined, { name: 'return' }); await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('supports MDY format', async () => { @@ -140,17 +127,17 @@ describe.each(['true', 'false'])('date (isCI = %s)', (isCI) => { message: 'Pick a date', format: 'MDY', initialValue: d('2025-01-15'), - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', undefined, { name: 'return' }); + mocks.input.emit('keypress', undefined, { name: 'return' }); const value = await result; expect(value).toBeInstanceOf(Date); expect((value as Date).toISOString().slice(0, 10)).toBe('2025-01-15'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('minDate shows error when date before min and submit', async () => { @@ -159,19 +146,19 @@ describe.each(['true', 'false'])('date (isCI = %s)', (isCI) => { locale: 'en-US', initialValue: d('2025-01-10'), minDate: d('2025-01-15'), - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', undefined, { name: 'return' }); + mocks.input.emit('keypress', undefined, { name: 'return' }); await new Promise((r) => setImmediate(r)); - const hasError = output.buffer.some( + const hasError = mocks.output.buffer.some( (s) => typeof s === 'string' && s.includes('Date must be on or after') ); expect(hasError).toBe(true); - input.emit('keypress', 'escape', { name: 'escape' }); + mocks.input.emit('keypress', 'escape', { name: 'escape' }); const value = await result; expect(prompts.isCancel(value)).toBe(true); }); diff --git a/packages/prompts/test/group-multi-select.test.ts b/packages/prompts/src/group-multi-select.test.ts similarity index 57% rename from packages/prompts/test/group-multi-select.test.ts rename to packages/prompts/src/group-multi-select.test.ts index 7d24dc18..352778b5 100644 --- a/packages/prompts/test/group-multi-select.test.ts +++ b/packages/prompts/src/group-multi-select.test.ts @@ -1,36 +1,23 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; -import * as prompts from '../src/index.js'; -import { MockReadable, MockWritable } from './test-utils.js'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import * as prompts from './index.js'; +import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; describe.each(['true', 'false'])('groupMultiselect (isCI = %s)', (isCI) => { - let originalCI: string | undefined; - let output: MockWritable; - let input: MockReadable; - - beforeAll(() => { - originalCI = process.env.CI; - process.env.CI = isCI; - }); - - afterAll(() => { - process.env.CI = originalCI; - }); + let mocks: Mocks<{ input: true; output: true }>; beforeEach(() => { - output = new MockWritable(); - input = new MockReadable(); + mocks = createMocks({ input: true, output: true, env: { CI: isCI } }); }); afterEach(() => { - vi.restoreAllMocks(); prompts.updateSettings({ withGuide: true }); }); test('renders message with options', async () => { const result = prompts.groupMultiselect({ message: 'foo', - input, - output, + input: mocks.input, + output: mocks.output, options: { group1: [{ value: 'group1value0' }, { value: 'group1value1' }], group2: [{ value: 'group2value0' }], @@ -38,149 +25,149 @@ describe.each(['true', 'false'])('groupMultiselect (isCI = %s)', (isCI) => { }); // Select the first non-group option - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'space' }); // submit - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['group1value0']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('can select multiple options', async () => { const result = prompts.groupMultiselect({ message: 'foo', - input, - output, + input: mocks.input, + output: mocks.output, options: { group1: [{ value: 'group1value0' }, { value: 'group1value1' }, { value: 'group1value2' }], }, }); // Select the first non-group option - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'space' }); // Select the second non-group option - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'space' }); // submit - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['group1value0', 'group1value1']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('can select a group', async () => { const result = prompts.groupMultiselect({ message: 'foo', - input, - output, + input: mocks.input, + output: mocks.output, options: { group1: [{ value: 'group1value0' }, { value: 'group1value1' }], }, }); // Select the group as a whole - input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'space' }); // submit - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['group1value0', 'group1value1']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('can select a group by selecting all members', async () => { const result = prompts.groupMultiselect({ message: 'foo', - input, - output, + input: mocks.input, + output: mocks.output, options: { group1: [{ value: 'group1value0' }, { value: 'group1value1' }], }, }); // Select the first group option - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'space' }); // Select the second group option - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'space' }); // submit - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['group1value0', 'group1value1']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('can deselect an option', async () => { const result = prompts.groupMultiselect({ message: 'foo', - input, - output, + input: mocks.input, + output: mocks.output, options: { group1: [{ value: 'group1value0' }, { value: 'group1value1' }], }, }); // Select the first group option - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'space' }); // Select the second group option - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'space' }); // Deselect it - input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'space' }); // submit - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['group1value0']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders error when nothing selected', async () => { const result = prompts.groupMultiselect({ message: 'foo', - input, - output, + input: mocks.input, + output: mocks.output, options: { group1: [{ value: 'group1value0' }, { value: 'group1value1' }], }, }); // try submit - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); // now select something and submit - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['group1value0']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); describe('selectableGroups = false', () => { test('cannot select groups', async () => { const result = prompts.groupMultiselect({ message: 'foo', - input, - output, + input: mocks.input, + output: mocks.output, options: { group1: [{ value: 'group1value0' }, { value: 'group1value1' }], }, @@ -188,20 +175,20 @@ describe.each(['true', 'false'])('groupMultiselect (isCI = %s)', (isCI) => { }); // first selectable item should be group's child - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['group1value0']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('selecting all members of group does not select group', async () => { const result = prompts.groupMultiselect({ message: 'foo', - input, - output, + input: mocks.input, + output: mocks.output, options: { group1: [{ value: 'group1value0' }, { value: 'group1value1' }], }, @@ -209,76 +196,76 @@ describe.each(['true', 'false'])('groupMultiselect (isCI = %s)', (isCI) => { }); // first selectable item should be group's child - input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'space' }); // select second item - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'space' }); // submit - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['group1value0', 'group1value1']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); test('can submit empty selection when require = false', async () => { const result = prompts.groupMultiselect({ message: 'foo', - input, - output, + input: mocks.input, + output: mocks.output, options: { group1: [{ value: 'group1value0' }, { value: 'group1value1' }], }, required: false, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual([]); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('cursorAt sets initial selection', async () => { const result = prompts.groupMultiselect({ message: 'foo', - input, - output, + input: mocks.input, + output: mocks.output, options: { group1: [{ value: 'group1value0' }, { value: 'group1value1' }], }, cursorAt: 'group1value1', }); - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['group1value1']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('initial values can be set', async () => { const result = prompts.groupMultiselect({ message: 'foo', - input, - output, + input: mocks.input, + output: mocks.output, options: { group1: [{ value: 'group1value0' }, { value: 'group1value1' }], }, initialValues: ['group1value1'], }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['group1value1']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('values can be non-primitive', async () => { @@ -286,8 +273,8 @@ describe.each(['true', 'false'])('groupMultiselect (isCI = %s)', (isCI) => { const value1 = Symbol(); const result = prompts.groupMultiselect({ message: 'foo', - input, - output, + input: mocks.input, + output: mocks.output, options: { group1: [ { value: value0, label: 'value0' }, @@ -296,22 +283,22 @@ describe.each(['true', 'false'])('groupMultiselect (isCI = %s)', (isCI) => { }, }); - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual([value0]); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); describe('groupSpacing', () => { test('renders spaced groups', async () => { const result = prompts.groupMultiselect({ message: 'foo', - input, - output, + input: mocks.input, + output: mocks.output, options: { group1: [{ value: 'group1value0' }], group2: [{ value: 'group2value0' }], @@ -319,20 +306,20 @@ describe.each(['true', 'false'])('groupMultiselect (isCI = %s)', (isCI) => { groupSpacing: 2, }); - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'return' }); await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('negative spacing is ignored', async () => { const result = prompts.groupMultiselect({ message: 'foo', - input, - output, + input: mocks.input, + output: mocks.output, options: { group1: [{ value: 'group1value0' }], group2: [{ value: 'group2value0' }], @@ -340,13 +327,13 @@ describe.each(['true', 'false'])('groupMultiselect (isCI = %s)', (isCI) => { groupSpacing: -2, }); - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'return' }); await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); @@ -358,36 +345,36 @@ describe.each(['true', 'false'])('groupMultiselect (isCI = %s)', (isCI) => { group1: [{ value: 'group1value0' }], group2: [{ value: 'group2value0' }], }, - input, - output, + input: mocks.input, + output: mocks.output, signal: controller.signal, }); controller.abort(); const value = await result; expect(prompts.isCancel(value)).toBe(true); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('withGuide: false removes guide', async () => { const result = prompts.groupMultiselect({ message: 'foo', - input, - output, + input: mocks.input, + output: mocks.output, withGuide: false, options: { group1: [{ value: 'group1value0' }, { value: 'group1value1' }], }, }); - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['group1value0']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('global withGuide: false removes guide', async () => { @@ -395,20 +382,20 @@ describe.each(['true', 'false'])('groupMultiselect (isCI = %s)', (isCI) => { const result = prompts.groupMultiselect({ message: 'foo', - input, - output, + input: mocks.input, + output: mocks.output, options: { group1: [{ value: 'group1value0' }, { value: 'group1value1' }], }, }); - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['group1value0']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); diff --git a/packages/prompts/test/limit-options.test.ts b/packages/prompts/src/limit-options.test.ts similarity index 94% rename from packages/prompts/test/limit-options.test.ts rename to packages/prompts/src/limit-options.test.ts index 91fd8268..9524c40b 100644 --- a/packages/prompts/test/limit-options.test.ts +++ b/packages/prompts/src/limit-options.test.ts @@ -1,16 +1,16 @@ import { styleText } from 'node:util'; import { beforeEach, describe, expect, test } from 'vitest'; -import { type LimitOptionsParams, limitOptions } from '../src/index.js'; -import { MockWritable } from './test-utils.js'; +import { type LimitOptionsParams, limitOptions } from './index.js'; +import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; describe('limitOptions', () => { - let output: MockWritable; + let mocks: Mocks<{ output: true }>; let options: LimitOptionsParams<{ value: string }>; beforeEach(() => { - output = new MockWritable(); + mocks = createMocks({ output: true }); options = { - output, + output: mocks.output, options: [], maxItems: undefined, cursor: 0, @@ -55,7 +55,7 @@ describe('limitOptions', () => { { value: 'Item 9' }, { value: 'Item 10' }, ]; - output.rows = 20; + mocks.output.rows = 20; options.maxItems = 5; options.cursor = 6; const result = limitOptions(options); @@ -106,7 +106,7 @@ describe('limitOptions', () => { { value: 'Item 9' }, { value: 'Item 10' }, ]; - output.rows = 7; + mocks.output.rows = 7; options.maxItems = 10; const result = limitOptions(options); expect(result).toEqual(['Item 1', 'Item 2', styleText('dim', '...')]); @@ -129,7 +129,7 @@ describe('limitOptions', () => { { value: 'Item 9' }, { value: 'Item 10' }, ]; - output.rows = 14; + mocks.output.rows = 14; options.maxItems = 10; const result = limitOptions(options); expect(result).toEqual([ @@ -165,7 +165,7 @@ describe('limitOptions', () => { { value: 'Item 9' }, { value: 'Item 10' }, ]; - output.rows = 14; + mocks.output.rows = 14; options.maxItems = 10; options.cursor = 7; const result = limitOptions(options); @@ -202,7 +202,7 @@ describe('limitOptions', () => { { value: 'Item 9' }, { value: 'Item 10' }, ]; - output.rows = 14; + mocks.output.rows = 14; options.maxItems = 10; options.cursor = 9; const result = limitOptions(options); @@ -261,7 +261,7 @@ describe('limitOptions', () => { { value: 'Item 9' }, { value: 'Item 10' }, ]; - output.rows = 12; + mocks.output.rows = 12; options.rowPadding = 6; // Available rows for options = 12 - 6 = 6 const result = limitOptions(options); @@ -288,7 +288,7 @@ describe('limitOptions', () => { { value: 'Item 9' }, { value: 'Item 10' }, ]; - output.rows = 12; + mocks.output.rows = 12; // Simulate a multiline message that takes 6 lines options.rowPadding = 6; // Move cursor to middle of list diff --git a/packages/prompts/test/log.test.ts b/packages/prompts/src/log.test.ts similarity index 59% rename from packages/prompts/test/log.test.ts rename to packages/prompts/src/log.test.ts index 5bf01e5b..c93965fc 100644 --- a/packages/prompts/test/log.test.ts +++ b/packages/prompts/src/log.test.ts @@ -1,173 +1,159 @@ import { styleText } from 'node:util'; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; -import * as prompts from '../src/index.js'; -import { MockWritable } from './test-utils.js'; +import { beforeEach, describe, expect, test } from 'vitest'; +import * as prompts from './index.js'; +import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; describe.each(['true', 'false'])('log (isCI = %s)', (isCI) => { - let originalCI: string | undefined; - let output: MockWritable; - - beforeAll(() => { - originalCI = process.env.CI; - process.env.CI = isCI; - }); - - afterAll(() => { - process.env.CI = originalCI; - }); + let mocks: Mocks<{ output: true }>; beforeEach(() => { - output = new MockWritable(); - }); - - afterEach(() => { - vi.restoreAllMocks(); + mocks = createMocks({ output: true, env: { CI: isCI } }); }); describe('message', () => { test('renders message', () => { prompts.log.message('message', { - output, + output: mocks.output, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders multiline message', () => { prompts.log.message('line 1\nline 2\nline 3', { - output, + output: mocks.output, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders message from array', () => { prompts.log.message(['line 1', 'line 2', 'line 3'], { - output, + output: mocks.output, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders message with custom symbols and spacing', () => { prompts.log.message('custom\nsymbols', { symbol: styleText('red', '>>'), secondarySymbol: styleText('yellow', '--'), - output, + output: mocks.output, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders message with guide disabled', () => { prompts.log.message('standalone message', { withGuide: false, - output, + output: mocks.output, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders multiline message with guide disabled', () => { prompts.log.message('line 1\nline 2\nline 3', { withGuide: false, - output, + output: mocks.output, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders message with custom spacing', () => { prompts.log.message('spaced message', { spacing: 3, - output, + output: mocks.output, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders empty message correctly', () => { prompts.log.message('', { - output, + output: mocks.output, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders empty message with guide disabled', () => { prompts.log.message('', { withGuide: false, - output, + output: mocks.output, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders empty lines correctly', () => { prompts.log.message('foo\n\nbar', { - output, + output: mocks.output, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders empty lines with guide disabled', () => { prompts.log.message('foo\n\nbar', { withGuide: false, - output, + output: mocks.output, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); describe('info', () => { test('renders info message', () => { prompts.log.info('info message', { - output, + output: mocks.output, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); describe('success', () => { test('renders success message', () => { prompts.log.success('success message', { - output, + output: mocks.output, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); describe('step', () => { test('renders step message', () => { prompts.log.step('step message', { - output, + output: mocks.output, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); describe('warn', () => { test('renders warn message', () => { prompts.log.warn('warn message', { - output, + output: mocks.output, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); describe('error', () => { test('renders error message', () => { prompts.log.error('error message', { - output, + output: mocks.output, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); }); diff --git a/packages/prompts/src/multi-line.test.ts b/packages/prompts/src/multi-line.test.ts new file mode 100644 index 00000000..8a1b0127 --- /dev/null +++ b/packages/prompts/src/multi-line.test.ts @@ -0,0 +1,259 @@ +import { updateSettings } from '@clack/core'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import * as prompts from './index.js'; +import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; + +describe.each(['true', 'false'])('multiline (isCI = %s)', (isCI) => { + let mocks: Mocks<{ input: true; output: true }>; + + beforeEach(() => { + mocks = createMocks({ input: true, output: true, env: { CI: isCI } }); + }); + + afterEach(() => { + updateSettings({ withGuide: true }); + }); + + test('renders message', async () => { + const result = prompts.multiline({ + message: 'foo', + input: mocks.input, + output: mocks.output, + }); + + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); + + await result; + + expect(mocks.output.buffer).toMatchSnapshot(); + }); + + test('renders placeholder if set', async () => { + const result = prompts.multiline({ + message: 'foo', + placeholder: 'bar', + input: mocks.input, + output: mocks.output, + }); + + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(mocks.output.buffer).toMatchSnapshot(); + expect(value).toBe(''); + }); + + test('can cancel', async () => { + const result = prompts.multiline({ + message: 'foo', + input: mocks.input, + output: mocks.output, + }); + + mocks.input.emit('keypress', 'escape', { name: 'escape' }); + + const value = await result; + + expect(prompts.isCancel(value)).toBe(true); + expect(mocks.output.buffer).toMatchSnapshot(); + }); + + test('renders cancelled value if one set', async () => { + const result = prompts.multiline({ + message: 'foo', + input: mocks.input, + output: mocks.output, + }); + + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', 'y', { name: 'y' }); + mocks.input.emit('keypress', '', { name: 'escape' }); + + const value = await result; + + expect(prompts.isCancel(value)).toBe(true); + expect(mocks.output.buffer).toMatchSnapshot(); + }); + + test('renders submitted value', async () => { + const result = prompts.multiline({ + message: 'foo', + input: mocks.input, + output: mocks.output, + }); + + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', 'y', { name: 'y' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('xy'); + expect(mocks.output.buffer).toMatchSnapshot(); + }); + + test('defaultValue sets the value but does not render', async () => { + const result = prompts.multiline({ + message: 'foo', + defaultValue: 'bar', + input: mocks.input, + output: mocks.output, + }); + + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('bar'); + expect(mocks.output.buffer).toMatchSnapshot(); + }); + + test('validation errors render and clear', async () => { + const result = prompts.multiline({ + message: 'foo', + validate: (val) => (val !== 'xy' ? 'should be xy' : undefined), + input: mocks.input, + output: mocks.output, + }); + + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'y', { name: 'y' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('xy'); + expect(mocks.output.buffer).toMatchSnapshot(); + }); + + test('validation errors render and clear (using Error)', async () => { + const result = prompts.multiline({ + message: 'foo', + validate: (val) => (val !== 'xy' ? new Error('should be xy') : undefined), + input: mocks.input, + output: mocks.output, + }); + + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'y', { name: 'y' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('xy'); + expect(mocks.output.buffer).toMatchSnapshot(); + }); + + test('placeholder is not used as value when pressing enter', async () => { + const result = prompts.multiline({ + message: 'foo', + placeholder: ' (submit to use default)', + defaultValue: 'default-value', + input: mocks.input, + output: mocks.output, + }); + + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('default-value'); + expect(mocks.output.buffer).toMatchSnapshot(); + }); + + test('empty string when no value and no default', async () => { + const result = prompts.multiline({ + message: 'foo', + placeholder: ' (submit to use default)', + input: mocks.input, + output: mocks.output, + }); + + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe(''); + expect(mocks.output.buffer).toMatchSnapshot(); + }); + + test('can be aborted by a signal', async () => { + const controller = new AbortController(); + const result = prompts.multiline({ + message: 'foo', + input: mocks.input, + output: mocks.output, + signal: controller.signal, + }); + + controller.abort(); + const value = await result; + expect(prompts.isCancel(value)).toBe(true); + expect(mocks.output.buffer).toMatchSnapshot(); + }); + + test('withGuide: false removes guide', async () => { + const result = prompts.multiline({ + message: 'foo', + withGuide: false, + input: mocks.input, + output: mocks.output, + }); + + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); + + await result; + + expect(mocks.output.buffer).toMatchSnapshot(); + }); + + test('global withGuide: false removes guide', async () => { + updateSettings({ withGuide: false }); + + const result = prompts.multiline({ + message: 'foo', + input: mocks.input, + output: mocks.output, + }); + + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); + + await result; + + expect(mocks.output.buffer).toMatchSnapshot(); + }); + + test('renders submit button', async () => { + const result = prompts.multiline({ + message: 'foo', + input: mocks.input, + output: mocks.output, + showSubmit: true, + }); + + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', 'y', { name: 'y' }); + mocks.input.emit('keypress', '\t', { name: 'tab' }); + mocks.input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('xy'); + expect(mocks.output.buffer).toMatchSnapshot(); + }); +}); diff --git a/packages/prompts/test/multi-select.test.ts b/packages/prompts/src/multi-select.test.ts similarity index 56% rename from packages/prompts/test/multi-select.test.ts rename to packages/prompts/src/multi-select.test.ts index 3f79555b..20b41916 100644 --- a/packages/prompts/test/multi-select.test.ts +++ b/packages/prompts/src/multi-select.test.ts @@ -1,28 +1,15 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; -import * as prompts from '../src/index.js'; -import { MockReadable, MockWritable } from './test-utils.js'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import * as prompts from './index.js'; +import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; describe.each(['true', 'false'])('multiselect (isCI = %s)', (isCI) => { - let originalCI: string | undefined; - let output: MockWritable; - let input: MockReadable; - - beforeAll(() => { - originalCI = process.env.CI; - process.env.CI = isCI; - }); - - afterAll(() => { - process.env.CI = originalCI; - }); + let mocks: Mocks<{ input: true; output: true }>; beforeEach(() => { - output = new MockWritable(); - input = new MockReadable(); + mocks = createMocks({ input: true, output: true, env: { CI: isCI } }); }); afterEach(() => { - vi.restoreAllMocks(); prompts.updateSettings({ withGuide: true }); }); @@ -30,73 +17,73 @@ describe.each(['true', 'false'])('multiselect (isCI = %s)', (isCI) => { const result = prompts.multiselect({ message: 'foo', options: [{ value: 'opt0' }, { value: 'opt1' }], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['opt0']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders multiple selected options', async () => { const result = prompts.multiselect({ message: 'foo', options: [{ value: 'opt0' }, { value: 'opt1' }, { value: 'opt2' }], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['opt0', 'opt1']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('can cancel', async () => { const result = prompts.multiselect({ message: 'foo', options: [{ value: 'opt0' }, { value: 'opt1' }], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'escape', { name: 'escape' }); + mocks.input.emit('keypress', 'escape', { name: 'escape' }); const value = await result; expect(prompts.isCancel(value)).toBe(true); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders validation errors', async () => { const result = prompts.multiselect({ message: 'foo', options: [{ value: 'opt0' }, { value: 'opt1' }], - input, - output, + input: mocks.input, + output: mocks.output, }); // try submit with nothing selected - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); // select and submit - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['opt0']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('can submit without selection when required = false', async () => { @@ -104,16 +91,16 @@ describe.each(['true', 'false'])('multiselect (isCI = %s)', (isCI) => { message: 'foo', options: [{ value: 'opt0' }, { value: 'opt1' }], required: false, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual([]); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('can set cursorAt to preselect an option', async () => { @@ -121,17 +108,17 @@ describe.each(['true', 'false'])('multiselect (isCI = %s)', (isCI) => { message: 'foo', options: [{ value: 'opt0' }, { value: 'opt1' }], cursorAt: 'opt1', - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['opt1']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('can set initial values', async () => { @@ -139,16 +126,16 @@ describe.each(['true', 'false'])('multiselect (isCI = %s)', (isCI) => { message: 'foo', options: [{ value: 'opt0' }, { value: 'opt1' }], initialValues: ['opt1'], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['opt1']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('maxItems renders a sliding window', async () => { @@ -158,20 +145,20 @@ describe.each(['true', 'false'])('multiselect (isCI = %s)', (isCI) => { value: `opt${k}`, })), maxItems: 6, - input, - output, + input: mocks.input, + output: mocks.output, }); for (let i = 0; i < 6; i++) { - input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'down' }); } - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['opt6']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('sliding window loops upwards', async () => { @@ -181,18 +168,18 @@ describe.each(['true', 'false'])('multiselect (isCI = %s)', (isCI) => { value: `opt${k}`, })), maxItems: 6, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'up' }); - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'up' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['opt11']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('sliding window loops downwards', async () => { @@ -202,20 +189,20 @@ describe.each(['true', 'false'])('multiselect (isCI = %s)', (isCI) => { value: `opt${k}`, })), maxItems: 6, - input, - output, + input: mocks.input, + output: mocks.output, }); for (let i = 0; i < 12; i++) { - input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'down' }); } - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['opt0']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('can set custom labels', async () => { @@ -225,17 +212,17 @@ describe.each(['true', 'false'])('multiselect (isCI = %s)', (isCI) => { { value: 'opt0', label: 'Option 0' }, { value: 'opt1', label: 'Option 1' }, ], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['opt0']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('can render option hints', async () => { @@ -245,17 +232,17 @@ describe.each(['true', 'false'])('multiselect (isCI = %s)', (isCI) => { { value: 'opt0', hint: 'Hint 0' }, { value: 'opt1', hint: 'Hint 1' }, ], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['opt0']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('shows hints for all selected options', async () => { @@ -267,38 +254,38 @@ describe.each(['true', 'false'])('multiselect (isCI = %s)', (isCI) => { { value: 'opt2', hint: 'Hint 2' }, ], initialValues: ['opt0', 'opt1'], - input, - output, + input: mocks.input, + output: mocks.output, }); // Check that both selected options show their hints - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['opt0', 'opt1']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders multiple cancelled values', async () => { const result = prompts.multiselect({ message: 'foo', options: [{ value: 'opt0' }, { value: 'opt1' }, { value: 'opt2' }], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', '', { name: 'escape' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'escape' }); const value = await result; expect(prompts.isCancel(value)).toBe(true); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('can be aborted by a signal', async () => { @@ -306,15 +293,15 @@ describe.each(['true', 'false'])('multiselect (isCI = %s)', (isCI) => { const result = prompts.multiselect({ message: 'foo', options: [{ value: 'opt0' }, { value: 'opt1' }], - input, - output, + input: mocks.input, + output: mocks.output, signal: controller.signal, }); controller.abort(); const value = await result; expect(prompts.isCancel(value)).toBe(true); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders disabled options', async () => { @@ -325,40 +312,40 @@ describe.each(['true', 'false'])('multiselect (isCI = %s)', (isCI) => { { value: 'opt1' }, { value: 'opt2', disabled: true, hint: 'Hint 2' }, ], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['opt1']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('wraps long messages', async () => { - output.columns = 40; + mocks.output.columns = 40; const result = prompts.multiselect({ message: 'foo '.repeat(20).trim(), options: [{ value: 'opt0' }, { value: 'opt1' }], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['opt0']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('wraps cancelled state with long options', async () => { - output.columns = 40; + mocks.output.columns = 40; const result = prompts.multiselect({ message: 'foo', @@ -366,21 +353,21 @@ describe.each(['true', 'false'])('multiselect (isCI = %s)', (isCI) => { { value: 'opt0', label: 'Option 0 '.repeat(10).trim() }, { value: 'opt1', label: 'Option 1 '.repeat(10).trim() }, ], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', 'escape', { name: 'escape' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', 'escape', { name: 'escape' }); const value = await result; expect(prompts.isCancel(value)).toBe(true); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('wraps success state with long options', async () => { - output.columns = 40; + mocks.output.columns = 40; const result = prompts.multiselect({ message: 'foo', @@ -388,17 +375,17 @@ describe.each(['true', 'false'])('multiselect (isCI = %s)', (isCI) => { { value: 'opt0', label: 'Option 0 '.repeat(10).trim() }, { value: 'opt1', label: 'Option 1 '.repeat(10).trim() }, ], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['opt0']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('withGuide: false removes guide', async () => { @@ -406,17 +393,17 @@ describe.each(['true', 'false'])('multiselect (isCI = %s)', (isCI) => { message: 'foo', options: [{ value: 'opt0' }, { value: 'opt1' }], withGuide: false, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['opt0']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('global withGuide: false removes guide', async () => { @@ -425,16 +412,16 @@ describe.each(['true', 'false'])('multiselect (isCI = %s)', (isCI) => { const result = prompts.multiselect({ message: 'foo', options: [{ value: 'opt0' }, { value: 'opt1' }], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'space' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'space' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual(['opt0']); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); diff --git a/packages/prompts/test/note.test.ts b/packages/prompts/src/note.test.ts similarity index 55% rename from packages/prompts/test/note.test.ts rename to packages/prompts/src/note.test.ts index 2b037c83..026c58ef 100644 --- a/packages/prompts/test/note.test.ts +++ b/packages/prompts/src/note.test.ts @@ -1,122 +1,106 @@ import { styleText } from 'node:util'; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; -import * as prompts from '../src/index.js'; -import { MockReadable, MockWritable } from './test-utils.js'; +import { beforeEach, describe, expect, test } from 'vitest'; +import * as prompts from './index.js'; +import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; describe.each(['true', 'false'])('note (isCI = %s)', (isCI) => { - let originalCI: string | undefined; - let output: MockWritable; - let input: MockReadable; - - beforeAll(() => { - originalCI = process.env.CI; - process.env.CI = isCI; - }); - - afterAll(() => { - process.env.CI = originalCI; - }); + let mocks: Mocks<{ input: true; output: true }>; beforeEach(() => { - output = new MockWritable(); - input = new MockReadable(); - }); - - afterEach(() => { - vi.restoreAllMocks(); + mocks = createMocks({ input: true, output: true, env: { CI: isCI } }); }); test('renders message with title', () => { prompts.note('message', 'title', { - input, - output, + input: mocks.input, + output: mocks.output, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders as wide as longest line', () => { prompts.note('short\nsomewhat questionably long line', 'title', { - input, - output, + input: mocks.input, + output: mocks.output, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('formatter which adds length works', () => { prompts.note('line 0\nline 1\nline 2', 'title', { format: (line) => `* ${line} *`, - input, - output, + input: mocks.input, + output: mocks.output, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('formatter which adds colors works', () => { prompts.note('line 0\nline 1\nline 2', 'title', { format: (line) => styleText('red', line), - input, - output, + input: mocks.input, + output: mocks.output, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test("don't overflow", () => { const message = `${'test string '.repeat(32)}\n`.repeat(4).trim(); - output.columns = 75; + mocks.output.columns = 75; prompts.note(message, 'title', { - input, - output, + input: mocks.input, + output: mocks.output, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test("don't overflow with formatter", () => { const message = `${'test string '.repeat(32)}\n`.repeat(4).trim(); - output.columns = 75; + mocks.output.columns = 75; prompts.note(message, 'title', { format: (line) => styleText('red', `* ${styleText('cyan', line)} *`), - input, - output, + input: mocks.input, + output: mocks.output, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('handle wide characters', () => { const messages = ['이게 첫 번째 줄이에요', 'これは次の行です']; - output.columns = 10; + mocks.output.columns = 10; prompts.note(messages.join('\n'), '这是标题', { - input, - output, + input: mocks.input, + output: mocks.output, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('handle wide characters with formatter', () => { const messages = ['이게 첫 번째 줄이에요', 'これは次の行です']; - output.columns = 10; + mocks.output.columns = 10; prompts.note(messages.join('\n'), '这是标题', { format: (line) => styleText('red', `* ${styleText('cyan', line)} *`), - input, - output, + input: mocks.input, + output: mocks.output, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('without guide', () => { prompts.note('message', 'title', { - input, - output, + input: mocks.input, + output: mocks.output, withGuide: false, }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); diff --git a/packages/prompts/src/password.test.ts b/packages/prompts/src/password.test.ts new file mode 100644 index 00000000..9aba10a2 --- /dev/null +++ b/packages/prompts/src/password.test.ts @@ -0,0 +1,172 @@ +import { updateSettings } from '@clack/core'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import * as prompts from './index.js'; +import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; + +describe.each(['true', 'false'])('password (isCI = %s)', (isCI) => { + let mocks: Mocks<{ input: true; output: true }>; + + beforeEach(() => { + mocks = createMocks({ input: true, output: true, env: { CI: isCI } }); + }); + + afterEach(() => { + updateSettings({ withGuide: true }); + }); + + test('renders message', async () => { + const result = prompts.password({ + message: 'foo', + input: mocks.input, + output: mocks.output, + }); + + mocks.input.emit('keypress', '', { name: 'return' }); + + await result; + + expect(mocks.output.buffer).toMatchSnapshot(); + }); + + test('renders masked value', async () => { + const result = prompts.password({ + message: 'foo', + input: mocks.input, + output: mocks.output, + }); + + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', 'y', { name: 'y' }); + mocks.input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('xy'); + expect(mocks.output.buffer).toMatchSnapshot(); + }); + + test('renders custom mask', async () => { + const result = prompts.password({ + message: 'foo', + mask: '*', + input: mocks.input, + output: mocks.output, + }); + + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', 'y', { name: 'y' }); + mocks.input.emit('keypress', '', { name: 'return' }); + + await result; + + expect(mocks.output.buffer).toMatchSnapshot(); + }); + + test('renders and clears validation errors', async () => { + const result = prompts.password({ + message: 'foo', + validate: (value) => { + if (!value || value.length < 2) { + return 'Password must be at least 2 characters'; + } + + return undefined; + }, + input: mocks.input, + output: mocks.output, + }); + + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'y', { name: 'y' }); + mocks.input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('xy'); + expect(mocks.output.buffer).toMatchSnapshot(); + }); + + test('renders cancelled value', async () => { + const result = prompts.password({ + message: 'foo', + input: mocks.input, + output: mocks.output, + }); + + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', '', { name: 'escape' }); + + const value = await result; + + expect(prompts.isCancel(value)).toBe(true); + expect(mocks.output.buffer).toMatchSnapshot(); + }); + + test('can be aborted by a signal', async () => { + const controller = new AbortController(); + const result = prompts.password({ + message: 'foo', + input: mocks.input, + output: mocks.output, + signal: controller.signal, + }); + + controller.abort(); + const value = await result; + expect(prompts.isCancel(value)).toBe(true); + expect(mocks.output.buffer).toMatchSnapshot(); + }); + + test('clears input on error when clearOnError is true', async () => { + const result = prompts.password({ + message: 'foo', + input: mocks.input, + output: mocks.output, + validate: (v) => (v === 'yz' ? undefined : 'Error'), + clearOnError: true, + }); + + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'y', { name: 'y' }); + mocks.input.emit('keypress', 'z', { name: 'z' }); + mocks.input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('yz'); + expect(mocks.output.buffer).toMatchSnapshot(); + }); + + test('withGuide: false removes guide', async () => { + const result = prompts.password({ + message: 'foo', + withGuide: false, + input: mocks.input, + output: mocks.output, + }); + + mocks.input.emit('keypress', '', { name: 'return' }); + + await result; + + expect(mocks.output.buffer).toMatchSnapshot(); + }); + + test('global withGuide: false removes guide', async () => { + updateSettings({ withGuide: false }); + + const result = prompts.password({ + message: 'foo', + input: mocks.input, + output: mocks.output, + }); + + mocks.input.emit('keypress', '', { name: 'return' }); + + await result; + + expect(mocks.output.buffer).toMatchSnapshot(); + }); +}); diff --git a/packages/prompts/test/path.test.ts b/packages/prompts/src/path.test.ts similarity index 52% rename from packages/prompts/test/path.test.ts rename to packages/prompts/src/path.test.ts index 5c1fe1f9..3ce94d26 100644 --- a/packages/prompts/test/path.test.ts +++ b/packages/prompts/src/path.test.ts @@ -1,27 +1,15 @@ import { vol } from 'memfs'; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; -import * as prompts from '../src/index.js'; -import { MockReadable, MockWritable } from './test-utils.js'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import * as prompts from './index.js'; +import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; vi.mock('node:fs'); describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { - let originalCI: string | undefined; - let output: MockWritable; - let input: MockReadable; - - beforeAll(() => { - originalCI = process.env.CI; - process.env.CI = isCI; - }); - - afterAll(() => { - process.env.CI = originalCI; - }); + let mocks: Mocks<{ input: true; output: true }>; beforeEach(() => { - output = new MockWritable(); - input = new MockReadable(); + mocks = createMocks({ input: true, output: true, env: { CI: isCI } }); vol.reset(); vol.fromJSON( { @@ -37,23 +25,19 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { ); }); - afterEach(() => { - vi.restoreAllMocks(); - }); - test('renders message', async () => { const result = prompts.path({ message: 'foo', - input, - output, + input: mocks.input, + output: mocks.output, root: '/tmp/', }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); expect(value).toBe('/tmp/bar'); }); @@ -61,72 +45,72 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { const result = prompts.path({ message: 'foo', root: '/tmp/', - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'escape', { name: 'escape' }); + mocks.input.emit('keypress', 'escape', { name: 'escape' }); const value = await result; expect(prompts.isCancel(value)).toBe(true); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders cancelled value if one set', async () => { const result = prompts.path({ message: 'foo', - input, - output, + input: mocks.input, + output: mocks.output, root: '/tmp/', }); - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', 'y', { name: 'y' }); - input.emit('keypress', '', { name: 'escape' }); + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', 'y', { name: 'y' }); + mocks.input.emit('keypress', '', { name: 'escape' }); const value = await result; expect(prompts.isCancel(value)).toBe(true); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders submitted value', async () => { const result = prompts.path({ message: 'foo', root: '/tmp/', - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'b', { name: 'b' }); - input.emit('keypress', 'a', { name: 'a' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'b', { name: 'b' }); + mocks.input.emit('keypress', 'a', { name: 'a' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe('/tmp/bar'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('cannot submit unknown value', async () => { const result = prompts.path({ message: 'foo', root: '/tmp/', - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '_', { name: '_' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'h', ctrl: true }); - input.emit('keypress', 'b', { name: 'b' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '_', { name: '_' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'h', ctrl: true }); + mocks.input.emit('keypress', 'b', { name: 'b' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe('/tmp/bar'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('initialValue sets the value', async () => { @@ -134,16 +118,16 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { message: 'foo', initialValue: '/tmp/bar', root: '/tmp/', - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe('/tmp/bar'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('directory mode only allows selecting directories', async () => { @@ -151,12 +135,12 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { message: 'foo', root: '/tmp/', directory: true, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'f', { name: 'f' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'f', { name: 'f' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; @@ -169,11 +153,11 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { root: '/tmp/', initialValue: '/tmp', directory: true, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; @@ -186,12 +170,12 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { root: '/tmp/', initialValue: '/tmp', directory: true, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '/', { name: '/' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '/', { name: '/' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; @@ -204,12 +188,12 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { root: '/tmp/', initialValue: '/tmp/', directory: true, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'f', { name: 'f' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'f', { name: 'f' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; @@ -220,15 +204,15 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { const result = prompts.path({ message: 'foo', root: '/tmp/', - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'r', { name: 'r' }); - input.emit('keypress', 'o', { name: 'o' }); - input.emit('keypress', 'o', { name: 'o' }); - input.emit('keypress', 't', { name: 't' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'r', { name: 'r' }); + mocks.input.emit('keypress', 'o', { name: 'o' }); + mocks.input.emit('keypress', 'o', { name: 'o' }); + mocks.input.emit('keypress', 't', { name: 't' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; @@ -240,22 +224,22 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { message: 'foo', root: '/tmp/', validate: (val) => (val !== '/tmp/bar' ? 'should be /tmp/bar' : undefined), - input, - output, + input: mocks.input, + output: mocks.output, }); // to match `root.zip` - input.emit('keypress', 'r', { name: 'r' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'r', { name: 'r' }); + mocks.input.emit('keypress', '', { name: 'return' }); // delete what we had - input.emit('keypress', '', { name: 'h', ctrl: true }); - input.emit('keypress', 'b', { name: 'b' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'h', ctrl: true }); + mocks.input.emit('keypress', 'b', { name: 'b' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe('/tmp/bar'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('validation errors render and clear (using Error)', async () => { @@ -263,21 +247,21 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { message: 'foo', root: '/tmp/', validate: (val) => (val !== '/tmp/bar' ? new Error('should be /tmp/bar') : undefined), - input, - output, + input: mocks.input, + output: mocks.output, }); // to match `root.zip` - input.emit('keypress', 'r', { name: 'r' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'r', { name: 'r' }); + mocks.input.emit('keypress', '', { name: 'return' }); // delete what we had - input.emit('keypress', '', { name: 'h', ctrl: true }); - input.emit('keypress', 'b', { name: 'b' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'h', ctrl: true }); + mocks.input.emit('keypress', 'b', { name: 'b' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe('/tmp/bar'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); diff --git a/packages/prompts/test/progress-bar.test.ts b/packages/prompts/src/progress-bar.test.ts similarity index 69% rename from packages/prompts/test/progress-bar.test.ts rename to packages/prompts/src/progress-bar.test.ts index a64061c8..8e72fa02 100644 --- a/packages/prompts/test/progress-bar.test.ts +++ b/packages/prompts/src/progress-bar.test.ts @@ -1,35 +1,24 @@ import process from 'node:process'; import { EventEmitter } from 'node:stream'; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; -import type { ProgressOptions } from '../src/index.js'; -import * as prompts from '../src/index.js'; -import { MockWritable } from './test-utils.js'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import type { ProgressOptions } from './index.js'; +import * as prompts from './index.js'; +import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; describe.each(['true', 'false'])('prompts - progress (isCI = %s)', (isCI) => { - let originalCI: string | undefined; - let output: MockWritable; - - beforeAll(() => { - originalCI = process.env.CI; - process.env.CI = isCI; - }); - - afterAll(() => { - process.env.CI = originalCI; - }); + let mocks: Mocks<{ output: true }>; beforeEach(() => { - output = new MockWritable(); + mocks = createMocks({ output: true, env: { CI: isCI } }); vi.useFakeTimers(); }); afterEach(() => { - vi.restoreAllMocks(); vi.useRealTimers(); }); test('returns progress API', () => { - const api = prompts.progress({ output }); + const api = prompts.progress({ output: mocks.output }); expect(api.stop).toBeTypeOf('function'); expect(api.start).toBeTypeOf('function'); @@ -39,7 +28,7 @@ describe.each(['true', 'false'])('prompts - progress (isCI = %s)', (isCI) => { describe('start', () => { test('renders frames at interval', () => { - const result = prompts.progress({ output }); + const result = prompts.progress({ output: mocks.output }); result.start(); @@ -48,33 +37,33 @@ describe.each(['true', 'false'])('prompts - progress (isCI = %s)', (isCI) => { vi.advanceTimersByTime(80); } - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders message', () => { - const result = prompts.progress({ output }); + const result = prompts.progress({ output: mocks.output }); result.start('foo'); vi.advanceTimersByTime(80); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders timer when indicator is "timer"', () => { - const result = prompts.progress({ output, indicator: 'timer' }); + const result = prompts.progress({ output: mocks.output, indicator: 'timer' }); result.start(); vi.advanceTimersByTime(80); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); describe('stop', () => { test('renders submit symbol and stops progress', () => { - const result = prompts.progress({ output }); + const result = prompts.progress({ output: mocks.output }); result.start(); @@ -84,11 +73,11 @@ describe.each(['true', 'false'])('prompts - progress (isCI = %s)', (isCI) => { vi.advanceTimersByTime(80); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders cancel symbol when calling cancel()', () => { - const result = prompts.progress({ output }); + const result = prompts.progress({ output: mocks.output }); result.start(); @@ -96,11 +85,11 @@ describe.each(['true', 'false'])('prompts - progress (isCI = %s)', (isCI) => { result.cancel(); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders error symbol when calling error()', () => { - const result = prompts.progress({ output }); + const result = prompts.progress({ output: mocks.output }); result.start(); @@ -108,11 +97,11 @@ describe.each(['true', 'false'])('prompts - progress (isCI = %s)', (isCI) => { result.error(); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders message', () => { - const result = prompts.progress({ output }); + const result = prompts.progress({ output: mocks.output }); result.start(); @@ -120,11 +109,11 @@ describe.each(['true', 'false'])('prompts - progress (isCI = %s)', (isCI) => { result.stop('foo'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders message without removing dots', () => { - const result = prompts.progress({ output }); + const result = prompts.progress({ output: mocks.output }); result.start(); @@ -132,11 +121,11 @@ describe.each(['true', 'false'])('prompts - progress (isCI = %s)', (isCI) => { result.stop('foo.'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders message when cancelling', () => { - const result = prompts.progress({ output }); + const result = prompts.progress({ output: mocks.output }); result.start(); @@ -144,11 +133,11 @@ describe.each(['true', 'false'])('prompts - progress (isCI = %s)', (isCI) => { result.cancel('cancelled :-('); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders message when erroring', () => { - const result = prompts.progress({ output }); + const result = prompts.progress({ output: mocks.output }); result.start(); @@ -156,13 +145,13 @@ describe.each(['true', 'false'])('prompts - progress (isCI = %s)', (isCI) => { result.error('FATAL ERROR!'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); describe('message', () => { test('sets message for next frame', () => { - const result = prompts.progress({ output }); + const result = prompts.progress({ output: mocks.output }); result.start(); @@ -172,7 +161,7 @@ describe.each(['true', 'false'])('prompts - progress (isCI = %s)', (isCI) => { vi.advanceTimersByTime(80); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); @@ -198,36 +187,36 @@ describe.each(['true', 'false'])('prompts - progress (isCI = %s)', (isCI) => { }); test('uses default cancel message', () => { - const result = prompts.progress({ output }); + const result = prompts.progress({ output: mocks.output }); result.start('Test operation'); processEmitter.emit('SIGINT'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('uses custom cancel message when provided directly', () => { const result = prompts.progress({ - output, + output: mocks.output, cancelMessage: 'Custom cancel message', }); result.start('Test operation'); processEmitter.emit('SIGINT'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('uses custom error message when provided directly', () => { const result = prompts.progress({ - output, + output: mocks.output, errorMessage: 'Custom error message', }); result.start('Test operation'); processEmitter.emit('exit', 2); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('uses global custom cancel message from settings', () => { @@ -237,12 +226,12 @@ describe.each(['true', 'false'])('prompts - progress (isCI = %s)', (isCI) => { // Set custom message prompts.settings.messages.cancel = 'Global cancel message'; - const result = prompts.progress({ output }); + const result = prompts.progress({ output: mocks.output }); result.start('Test operation'); processEmitter.emit('SIGINT'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); } finally { // Reset to original prompts.settings.messages.cancel = originalCancelMessage; @@ -256,12 +245,12 @@ describe.each(['true', 'false'])('prompts - progress (isCI = %s)', (isCI) => { // Set custom message prompts.settings.messages.error = 'Global error message'; - const result = prompts.progress({ output }); + const result = prompts.progress({ output: mocks.output }); result.start('Test operation'); processEmitter.emit('exit', 2); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); } finally { // Reset to original prompts.settings.messages.error = originalErrorMessage; @@ -277,13 +266,13 @@ describe.each(['true', 'false'])('prompts - progress (isCI = %s)', (isCI) => { prompts.settings.messages.error = 'Global error message'; const result = prompts.progress({ - output, + output: mocks.output, errorMessage: 'Progress error message', }); result.start('Test operation'); processEmitter.emit('exit', 2); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); } finally { // Reset to original values prompts.settings.messages.error = originalErrorMessage; @@ -299,13 +288,13 @@ describe.each(['true', 'false'])('prompts - progress (isCI = %s)', (isCI) => { prompts.settings.messages.cancel = 'Global cancel message'; const result = prompts.progress({ - output, + output: mocks.output, cancelMessage: 'Progress cancel message', }); result.start('Test operation'); processEmitter.emit('SIGINT'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); } finally { // Reset to original values prompts.settings.messages.cancel = originalCancelMessage; @@ -317,14 +306,14 @@ describe.each(['true', 'false'])('prompts - progress (isCI = %s)', (isCI) => { test.each(['block', 'heavy', 'light'] satisfies Array)( 'renders %s progressbar', (style) => { - const result = prompts.progress({ output, style, max: 2, size: 10 }); + const result = prompts.progress({ output: mocks.output, style, max: 2, size: 10 }); result.start(); vi.advanceTimersByTime(160); result.advance(); vi.advanceTimersByTime(160); result.stop(); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); } ); }); diff --git a/packages/prompts/test/select-key.test.ts b/packages/prompts/src/select-key.test.ts similarity index 63% rename from packages/prompts/test/select-key.test.ts rename to packages/prompts/src/select-key.test.ts index 2e143a88..abe92505 100644 --- a/packages/prompts/test/select-key.test.ts +++ b/packages/prompts/src/select-key.test.ts @@ -1,29 +1,16 @@ import { updateSettings } from '@clack/core'; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; -import * as prompts from '../src/index.js'; -import { MockReadable, MockWritable } from './test-utils.js'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import * as prompts from './index.js'; +import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { - let originalCI: string | undefined; - let output: MockWritable; - let input: MockReadable; - - beforeAll(() => { - originalCI = process.env.CI; - process.env.CI = isCI; - }); - - afterAll(() => { - process.env.CI = originalCI; - }); + let mocks: Mocks<{ input: true; output: true }>; beforeEach(() => { - output = new MockWritable(); - input = new MockReadable(); + mocks = createMocks({ input: true, output: true, env: { CI: isCI } }); }); afterEach(() => { - vi.restoreAllMocks(); updateSettings({ withGuide: true }); }); @@ -34,15 +21,15 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { { label: 'Option A', value: 'a' }, { label: 'Option B', value: 'b' }, ], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); expect(value).toBe(undefined); }); @@ -53,15 +40,15 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { { label: 'Option A', value: 'a' }, { label: 'Option B', value: 'b' }, ], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'b', { name: 'b' }); + mocks.input.emit('keypress', 'b', { name: 'b' }); const value = await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); expect(value).toBe('b'); }); @@ -72,15 +59,15 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { { label: 'Option A', value: 'a' }, { label: 'Option B', value: 'b' }, ], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'escape', { name: 'escape' }); + mocks.input.emit('keypress', 'escape', { name: 'escape' }); const value = await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); expect(prompts.isCancel(value)).toBe(true); }); @@ -91,15 +78,15 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { { label: 'Option A', value: 'A' }, { label: 'Option B', value: 'b' }, ], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'a', { name: 'a' }); + mocks.input.emit('keypress', 'a', { name: 'a' }); const value = await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); expect(value).toBe('A'); }); @@ -110,15 +97,15 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { { label: 'Option A', value: 'a' }, { label: 'Option B', value: 'b' }, ], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'a', { name: 'a', shift: true }); + mocks.input.emit('keypress', 'a', { name: 'a', shift: true }); const value = await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); expect(value).toBe('a'); }); @@ -130,16 +117,16 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { { label: 'Option B', value: 'b' }, ], caseSensitive: true, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'a', { name: 'a' }); - input.emit('keypress', '', { name: 'escape' }); + mocks.input.emit('keypress', 'a', { name: 'a' }); + mocks.input.emit('keypress', '', { name: 'escape' }); const value = await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); expect(prompts.isCancel(value)).toBe(true); }); @@ -152,20 +139,20 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { { label: 'Option B', value: 'b' }, ], caseSensitive: true, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'a', { name: 'a', shift: true }); + mocks.input.emit('keypress', 'a', { name: 'a', shift: true }); const value = await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); expect(value).toBe('A'); }); test('long option labels are wrapped correctly', async () => { - output.columns = 40; + mocks.output.columns = 40; const result = prompts.selectKey({ message: 'Select an option:', @@ -176,20 +163,20 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { }, { label: 'Short label', value: 'b' }, ], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'a', { name: 'a' }); + mocks.input.emit('keypress', 'a', { name: 'a' }); const value = await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); expect(value).toBe('a'); }); test('long cancelled labels are wrapped correctly', async () => { - output.columns = 40; + mocks.output.columns = 40; const result = prompts.selectKey({ message: 'Select an option:', @@ -200,15 +187,15 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { }, { label: 'Short label', value: 'b' }, ], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'escape' }); + mocks.input.emit('keypress', '', { name: 'escape' }); await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('withGuide: false removes guide', async () => { @@ -219,15 +206,15 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { { label: 'Option B', value: 'b' }, ], withGuide: false, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'a', { name: 'a' }); + mocks.input.emit('keypress', 'a', { name: 'a' }); await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('global withGuide: false removes guide', async () => { @@ -239,19 +226,19 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { { label: 'Option A', value: 'a' }, { label: 'Option B', value: 'b' }, ], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'a', { name: 'a' }); + mocks.input.emit('keypress', 'a', { name: 'a' }); await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('long submitted labels are wrapped correctly', async () => { - output.columns = 40; + mocks.output.columns = 40; const result = prompts.selectKey({ message: 'Select an option:', @@ -262,14 +249,14 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { }, { label: 'Short label', value: 'b' }, ], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'a', { name: 'a' }); + mocks.input.emit('keypress', 'a', { name: 'a' }); await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); diff --git a/packages/prompts/test/select.test.ts b/packages/prompts/src/select.test.ts similarity index 58% rename from packages/prompts/test/select.test.ts rename to packages/prompts/src/select.test.ts index 447a0e1b..b2a56b49 100644 --- a/packages/prompts/test/select.test.ts +++ b/packages/prompts/src/select.test.ts @@ -1,29 +1,16 @@ import { updateSettings } from '@clack/core'; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; -import * as prompts from '../src/index.js'; -import { MockReadable, MockWritable } from './test-utils.js'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import * as prompts from './index.js'; +import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; describe.each(['true', 'false'])('select (isCI = %s)', (isCI) => { - let originalCI: string | undefined; - let output: MockWritable; - let input: MockReadable; - - beforeAll(() => { - originalCI = process.env.CI; - process.env.CI = isCI; - }); - - afterAll(() => { - process.env.CI = originalCI; - }); + let mocks: Mocks<{ input: true; output: true }>; beforeEach(() => { - output = new MockWritable(); - input = new MockReadable(); + mocks = createMocks({ input: true, output: true, env: { CI: isCI } }); }); afterEach(() => { - vi.restoreAllMocks(); updateSettings({ withGuide: true }); }); @@ -31,67 +18,67 @@ describe.each(['true', 'false'])('select (isCI = %s)', (isCI) => { const result = prompts.select({ message: 'foo', options: [{ value: 'opt0' }, { value: 'opt1' }], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe('opt0'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('down arrow selects next option', async () => { const result = prompts.select({ message: 'foo', options: [{ value: 'opt0' }, { value: 'opt1' }], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe('opt1'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('up arrow selects previous option', async () => { const result = prompts.select({ message: 'foo', options: [{ value: 'opt0' }, { value: 'opt1' }], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'up' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'up' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe('opt0'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('can cancel', async () => { const result = prompts.select({ message: 'foo', options: [{ value: 'opt0' }, { value: 'opt1' }], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'escape', { name: 'escape' }); + mocks.input.emit('keypress', 'escape', { name: 'escape' }); const value = await result; expect(prompts.isCancel(value)).toBe(true); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders option labels', async () => { @@ -101,16 +88,16 @@ describe.each(['true', 'false'])('select (isCI = %s)', (isCI) => { { value: 'opt0', label: 'Option 0' }, { value: 'opt1', label: 'Option 1' }, ], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe('opt0'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders option hints', async () => { @@ -120,16 +107,16 @@ describe.each(['true', 'false'])('select (isCI = %s)', (isCI) => { { value: 'opt0', hint: 'Hint 0' }, { value: 'opt1', hint: 'Hint 1' }, ], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe('opt0'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('can be aborted by a signal', async () => { @@ -137,15 +124,15 @@ describe.each(['true', 'false'])('select (isCI = %s)', (isCI) => { const result = prompts.select({ message: 'foo', options: [{ value: 'opt0' }, { value: 'opt1' }], - input, - output, + input: mocks.input, + output: mocks.output, signal: controller.signal, }); controller.abort(); const value = await result; expect(prompts.isCancel(value)).toBe(true); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders disabled options', async () => { @@ -156,20 +143,20 @@ describe.each(['true', 'false'])('select (isCI = %s)', (isCI) => { { value: 'opt1', label: 'Option 1' }, { value: 'opt2', label: 'Option 2', disabled: true, hint: 'Hint 2' }, ], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe('opt1'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('wraps long results', async () => { - output.columns = 40; + mocks.output.columns = 40; const result = prompts.select({ message: 'foo', @@ -180,19 +167,19 @@ describe.each(['true', 'false'])('select (isCI = %s)', (isCI) => { }, { value: 'opt1', label: 'Option 1' }, ], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('wraps long cancelled message', async () => { - output.columns = 40; + mocks.output.columns = 40; const result = prompts.select({ message: 'foo', @@ -203,33 +190,33 @@ describe.each(['true', 'false'])('select (isCI = %s)', (isCI) => { }, { value: 'opt1', label: 'Option 1' }, ], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'escape', { name: 'escape' }); + mocks.input.emit('keypress', 'escape', { name: 'escape' }); await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('wraps long messages', async () => { - output.columns = 40; + mocks.output.columns = 40; const result = prompts.select({ message: 'foo '.repeat(20).trim(), options: [{ value: 'opt0' }, { value: 'opt1' }], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toEqual('opt0'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders multi-line option labels', async () => { @@ -239,20 +226,20 @@ describe.each(['true', 'false'])('select (isCI = %s)', (isCI) => { { value: 'opt0', label: 'Option 0\nwith multiple lines' }, { value: 'opt1', label: 'Option 1' }, ], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'return' }); await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('handles mixed size re-renders', async () => { - output.rows = 10; + mocks.output.rows = 10; const result = prompts.select({ message: 'Whatever', @@ -266,22 +253,22 @@ describe.each(['true', 'false'])('select (isCI = %s)', (isCI) => { label: `Option ${i}`, })), ], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'up' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'up' }); + mocks.input.emit('keypress', '', { name: 'return' }); await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('correctly limits options when message wraps to multiple lines', async () => { // Simulate a narrow terminal that forces the message to wrap - output.columns = 30; - output.rows = 12; + mocks.output.columns = 30; + mocks.output.rows = 12; const result = prompts.select({ // Long message that will wrap to multiple lines in a 30-column terminal @@ -290,21 +277,21 @@ describe.each(['true', 'false'])('select (isCI = %s)', (isCI) => { value: `opt${i}`, label: `Option ${i}`, })), - input, - output, + input: mocks.input, + output: mocks.output, }); // Scroll down through options to trigger the bug scenario - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe('opt4'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('withGuide: false removes guide', async () => { @@ -312,15 +299,15 @@ describe.each(['true', 'false'])('select (isCI = %s)', (isCI) => { message: 'foo', options: [{ value: 'opt0' }, { value: 'opt1' }], withGuide: false, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('global withGuide: false removes guide', async () => { @@ -329,19 +316,19 @@ describe.each(['true', 'false'])('select (isCI = %s)', (isCI) => { const result = prompts.select({ message: 'foo', options: [{ value: 'opt0' }, { value: 'opt1' }], - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('correctly limits options with explicit multiline message', async () => { - output.rows = 12; + mocks.output.rows = 12; const result = prompts.select({ // Explicit multiline message @@ -350,19 +337,19 @@ describe.each(['true', 'false'])('select (isCI = %s)', (isCI) => { value: `opt${i}`, label: `Option ${i}`, })), - input, - output, + input: mocks.input, + output: mocks.output, }); // Scroll down to test that options don't overflow - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'down' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'down' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe('opt3'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); diff --git a/packages/prompts/test/spinner.test.ts b/packages/prompts/src/spinner.test.ts similarity index 67% rename from packages/prompts/test/spinner.test.ts rename to packages/prompts/src/spinner.test.ts index 18896121..1e787fd7 100644 --- a/packages/prompts/test/spinner.test.ts +++ b/packages/prompts/src/spinner.test.ts @@ -1,36 +1,25 @@ import { EventEmitter } from 'node:stream'; import { styleText } from 'node:util'; import { getColumns, updateSettings } from '@clack/core'; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; -import * as prompts from '../src/index.js'; -import { MockWritable } from './test-utils.js'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import * as prompts from './index.js'; +import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { - let originalCI: string | undefined; - let output: MockWritable; - - beforeAll(() => { - originalCI = process.env.CI; - process.env.CI = isCI; - }); - - afterAll(() => { - process.env.CI = originalCI; - }); + let mocks: Mocks<{ output: true }>; beforeEach(() => { - output = new MockWritable(); + mocks = createMocks({ output: true, env: { CI: isCI } }); vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); - vi.restoreAllMocks(); updateSettings({ withGuide: true }); }); test('returns spinner API', () => { - const api = prompts.spinner({ output }); + const api = prompts.spinner({ output: mocks.output }); expect(api.stop).toBeTypeOf('function'); expect(api.start).toBeTypeOf('function'); @@ -39,7 +28,7 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { describe('start', () => { test('renders frames at interval', () => { - const result = prompts.spinner({ output }); + const result = prompts.spinner({ output: mocks.output }); result.start(); @@ -50,11 +39,11 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { result.stop(); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders message', () => { - const result = prompts.spinner({ output }); + const result = prompts.spinner({ output: mocks.output }); result.start('foo'); @@ -62,11 +51,11 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { result.stop(); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders timer when indicator is "timer"', () => { - const result = prompts.spinner({ output, indicator: 'timer' }); + const result = prompts.spinner({ output: mocks.output, indicator: 'timer' }); result.start(); @@ -74,12 +63,12 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { result.stop(); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('handles wrapping', () => { - const columns = getColumns(output); - const result = prompts.spinner({ output }); + const columns = getColumns(mocks.output); + const result = prompts.spinner({ output: mocks.output }); result.start('x'.repeat(columns + 10)); @@ -87,11 +76,11 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { result.stop('stopped'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('handles multi-line messages', () => { - const result = prompts.spinner({ output }); + const result = prompts.spinner({ output: mocks.output }); result.start('foo\nbar\nbaz'); @@ -99,13 +88,13 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { result.stop(); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); describe('stop', () => { test('renders submit symbol and stops spinner', () => { - const result = prompts.spinner({ output }); + const result = prompts.spinner({ output: mocks.output }); result.start(); @@ -115,11 +104,11 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { vi.advanceTimersByTime(80); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders cancel symbol when calling cancel()', () => { - const result = prompts.spinner({ output }); + const result = prompts.spinner({ output: mocks.output }); result.start(); @@ -127,11 +116,11 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { result.cancel(); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders error symbol when calling error()', () => { - const result = prompts.spinner({ output }); + const result = prompts.spinner({ output: mocks.output }); result.start(); @@ -139,11 +128,11 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { result.error(); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders message', () => { - const result = prompts.spinner({ output }); + const result = prompts.spinner({ output: mocks.output }); result.start(); @@ -151,11 +140,11 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { result.stop('foo'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders message without removing dots', () => { - const result = prompts.spinner({ output }); + const result = prompts.spinner({ output: mocks.output }); result.start(); @@ -163,11 +152,11 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { result.stop('foo.'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders message when cancelling', () => { - const result = prompts.spinner({ output }); + const result = prompts.spinner({ output: mocks.output }); result.start(); @@ -175,11 +164,11 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { result.cancel('too dizzy — spinning cancelled'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders message when erroring', () => { - const result = prompts.spinner({ output }); + const result = prompts.spinner({ output: mocks.output }); result.start(); @@ -187,11 +176,11 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { result.error('error: spun too fast!'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('does not throw if called before start', () => { - const result = prompts.spinner({ output }); + const result = prompts.spinner({ output: mocks.output }); expect(() => result.stop()).not.toThrow(); }); @@ -199,7 +188,7 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { describe('message', () => { test('sets message for next frame', () => { - const result = prompts.spinner({ output }); + const result = prompts.spinner({ output: mocks.output }); result.start(); @@ -211,13 +200,13 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { result.stop(); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); describe('indicator customization', () => { test('custom frames', () => { - const result = prompts.spinner({ output, frames: ['🐴', '🦋', '🐙', '🐶'] }); + const result = prompts.spinner({ output: mocks.output, frames: ['🐴', '🦋', '🐙', '🐶'] }); result.start(); @@ -228,11 +217,11 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { result.stop(); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('custom frames with lots of frame have consistent ellipsis display', () => { - const result = prompts.spinner({ output, frames: Object.keys(Array(10).fill(0)) }); + const result = prompts.spinner({ output: mocks.output, frames: Object.keys(Array(10).fill(0)) }); result.start(); @@ -242,11 +231,11 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { result.stop(); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('custom delay', () => { - const result = prompts.spinner({ output, delay: 200 }); + const result = prompts.spinner({ output: mocks.output, delay: 200 }); result.start(); @@ -257,11 +246,11 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { result.stop(); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('custom frame style', () => { - const result = prompts.spinner({ output, styleFrame: (text) => styleText('red', text) }); + const result = prompts.spinner({ output: mocks.output, styleFrame: (text) => styleText('red', text) }); result.start(); @@ -271,7 +260,7 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { result.stop(); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); @@ -297,36 +286,36 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { }); test('uses default cancel message', () => { - const result = prompts.spinner({ output }); + const result = prompts.spinner({ output: mocks.output }); result.start('Test operation'); processEmitter.emit('SIGINT'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('uses custom cancel message when provided directly', () => { const result = prompts.spinner({ - output, + output: mocks.output, cancelMessage: 'Custom cancel message', }); result.start('Test operation'); processEmitter.emit('SIGINT'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('uses custom error message when provided directly', () => { const result = prompts.spinner({ - output, + output: mocks.output, errorMessage: 'Custom error message', }); result.start('Test operation'); processEmitter.emit('exit', 2); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('uses global custom cancel message from settings', () => { @@ -336,12 +325,12 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { // Set custom message prompts.settings.messages.cancel = 'Global cancel message'; - const result = prompts.spinner({ output }); + const result = prompts.spinner({ output: mocks.output }); result.start('Test operation'); processEmitter.emit('SIGINT'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); } finally { // Reset to original prompts.settings.messages.cancel = originalCancelMessage; @@ -356,12 +345,12 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { // Set custom message prompts.settings.messages.error = 'Global error message'; - const result = prompts.spinner({ output }); + const result = prompts.spinner({ output: mocks.output }); result.start('Test operation'); processEmitter.emit('exit', 2); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); } finally { // Reset to original prompts.settings.messages.error = originalErrorMessage; @@ -377,13 +366,13 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { prompts.settings.messages.error = 'Global error message'; const result = prompts.spinner({ - output, + output: mocks.output, errorMessage: 'Spinner error message', }); result.start('Test operation'); processEmitter.emit('exit', 2); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); } finally { // Reset to original values prompts.settings.messages.error = originalErrorMessage; @@ -399,13 +388,13 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { prompts.settings.messages.cancel = 'Global cancel message'; const result = prompts.spinner({ - output, + output: mocks.output, cancelMessage: 'Spinner cancel message', }); result.start('Test operation'); processEmitter.emit('SIGINT'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); } finally { // Reset to original values prompts.settings.messages.cancel = originalCancelMessage; @@ -416,7 +405,7 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { test('can be aborted by a signal', async () => { const controller = new AbortController(); const result = prompts.spinner({ - output, + output: mocks.output, signal: controller.signal, }); @@ -424,11 +413,11 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { controller.abort(); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('withGuide: false removes guide', () => { - const result = prompts.spinner({ output, withGuide: false }); + const result = prompts.spinner({ output: mocks.output, withGuide: false }); result.start('foo'); @@ -436,13 +425,13 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { result.stop(); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('global withGuide: false removes guide', () => { updateSettings({ withGuide: false }); - const result = prompts.spinner({ output }); + const result = prompts.spinner({ output: mocks.output }); result.start('foo'); @@ -450,12 +439,12 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { result.stop(); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); describe('clear', () => { test('stops and clears the spinner from the output', () => { - const result = prompts.spinner({ output }); + const result = prompts.spinner({ output: mocks.output }); result.start('Loading'); @@ -463,7 +452,7 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { result.clear(); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); }); diff --git a/packages/prompts/test/task-log.test.ts b/packages/prompts/src/task-log.test.ts similarity index 69% rename from packages/prompts/test/task-log.test.ts rename to packages/prompts/src/task-log.test.ts index dbbc31a6..24977091 100644 --- a/packages/prompts/test/task-log.test.ts +++ b/packages/prompts/src/task-log.test.ts @@ -1,71 +1,55 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; -import * as prompts from '../src/index.js'; -import { MockReadable, MockWritable } from './test-utils.js'; +import { beforeEach, describe, expect, test } from 'vitest'; +import * as prompts from './index.js'; +import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; describe.each(['true', 'false'])('taskLog (isCI = %s)', (isCI) => { - let originalCI: string | undefined; - let output: MockWritable; - let input: MockReadable; - - beforeAll(() => { - originalCI = process.env.CI; - process.env.CI = isCI; - }); - - afterAll(() => { - process.env.CI = originalCI; - }); + let mocks: Mocks<{ input: true; output: true }>; beforeEach(() => { - output = new MockWritable(); - output.isTTY = isCI === 'false'; - input = new MockReadable(); - }); - - afterEach(() => { - vi.restoreAllMocks(); + mocks = createMocks({ input: true, output: true, env: { CI: isCI } }); + mocks.output.isTTY = isCI === 'false'; }); test('writes message header', () => { prompts.taskLog({ - input, - output, + input: mocks.input, + output: mocks.output, title: 'foo', }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); describe('message', () => { test('can write line by line', () => { const log = prompts.taskLog({ - input, - output, + input: mocks.input, + output: mocks.output, title: 'foo', }); log.message('line 0'); log.message('line 1'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('can write multiple lines', () => { const log = prompts.taskLog({ - input, - output, + input: mocks.input, + output: mocks.output, title: 'foo', }); log.message('line 0\nline 1'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('enforces limit if set', () => { const log = prompts.taskLog({ - input, - output, + input: mocks.input, + output: mocks.output, title: 'foo', limit: 2, }); @@ -74,13 +58,13 @@ describe.each(['true', 'false'])('taskLog (isCI = %s)', (isCI) => { log.message('line 1'); log.message('line 2'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('raw = true appends message text until newline', async () => { const log = prompts.taskLog({ - input, - output, + input: mocks.input, + output: mocks.output, title: 'foo', }); @@ -88,13 +72,13 @@ describe.each(['true', 'false'])('taskLog (isCI = %s)', (isCI) => { log.message('still line 0', { raw: true }); log.message('\nline 1', { raw: true }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('raw = true works when mixed with non-raw messages', async () => { const log = prompts.taskLog({ - input, - output, + input: mocks.input, + output: mocks.output, title: 'foo', }); @@ -102,13 +86,13 @@ describe.each(['true', 'false'])('taskLog (isCI = %s)', (isCI) => { log.message('still line 0', { raw: true }); log.message('line 1'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('raw = true works when started with non-raw messages', async () => { const log = prompts.taskLog({ - input, - output, + input: mocks.input, + output: mocks.output, title: 'foo', }); @@ -116,13 +100,13 @@ describe.each(['true', 'false'])('taskLog (isCI = %s)', (isCI) => { log.message('line 1', { raw: true }); log.message('still line 1', { raw: true }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('prints empty lines', async () => { const log = prompts.taskLog({ - input, - output, + input: mocks.input, + output: mocks.output, title: 'foo', }); @@ -130,13 +114,13 @@ describe.each(['true', 'false'])('taskLog (isCI = %s)', (isCI) => { log.message(''); log.message('line 3'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('destructive ansi codes are stripped', async () => { const log = prompts.taskLog({ - input, - output, + input: mocks.input, + output: mocks.output, title: 'foo', }); @@ -144,15 +128,15 @@ describe.each(['true', 'false'])('taskLog (isCI = %s)', (isCI) => { log.message('line 2\x1b[2K bad ansi!'); log.message('line 3'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); describe('error', () => { test('renders output with message', () => { const log = prompts.taskLog({ - input, - output, + input: mocks.input, + output: mocks.output, title: 'foo', }); @@ -161,13 +145,13 @@ describe.each(['true', 'false'])('taskLog (isCI = %s)', (isCI) => { log.error('some error!'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('clears output if showLog = false', () => { const log = prompts.taskLog({ - input, - output, + input: mocks.input, + output: mocks.output, title: 'foo', }); @@ -176,15 +160,15 @@ describe.each(['true', 'false'])('taskLog (isCI = %s)', (isCI) => { log.error('some error!', { showLog: false }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); describe('success', () => { test('clears output and renders message', () => { const log = prompts.taskLog({ - input, - output, + input: mocks.input, + output: mocks.output, title: 'foo', }); @@ -193,13 +177,13 @@ describe.each(['true', 'false'])('taskLog (isCI = %s)', (isCI) => { log.success('success!'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders output if showLog = true', () => { const log = prompts.taskLog({ - input, - output, + input: mocks.input, + output: mocks.output, title: 'foo', }); @@ -208,7 +192,7 @@ describe.each(['true', 'false'])('taskLog (isCI = %s)', (isCI) => { log.success('success!', { showLog: true }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); @@ -216,8 +200,8 @@ describe.each(['true', 'false'])('taskLog (isCI = %s)', (isCI) => { describe.each(['error', 'success'] as const)('%s', (method) => { test('retainLog = true outputs full log', () => { const log = prompts.taskLog({ - input, - output, + input: mocks.input, + output: mocks.output, title: 'foo', retainLog: true, }); @@ -228,13 +212,13 @@ describe.each(['true', 'false'])('taskLog (isCI = %s)', (isCI) => { log[method]('woo!', { showLog: true }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('retainLog = true outputs full log with limit', () => { const log = prompts.taskLog({ - input, - output, + input: mocks.input, + output: mocks.output, title: 'foo', retainLog: true, limit: 2, @@ -246,13 +230,13 @@ describe.each(['true', 'false'])('taskLog (isCI = %s)', (isCI) => { log[method]('woo!', { showLog: true }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('retainLog = false outputs full log without limit', () => { const log = prompts.taskLog({ - input, - output, + input: mocks.input, + output: mocks.output, title: 'foo', retainLog: false, }); @@ -263,13 +247,13 @@ describe.each(['true', 'false'])('taskLog (isCI = %s)', (isCI) => { log[method]('woo!', { showLog: true }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('retainLog = false outputs limited log with limit', () => { const log = prompts.taskLog({ - input, - output, + input: mocks.input, + output: mocks.output, title: 'foo', retainLog: false, limit: 2, @@ -281,13 +265,13 @@ describe.each(['true', 'false'])('taskLog (isCI = %s)', (isCI) => { log[method]('woo!', { showLog: true }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('outputs limited log with limit by default', () => { const log = prompts.taskLog({ - input, - output, + input: mocks.input, + output: mocks.output, title: 'foo', limit: 2, }); @@ -298,7 +282,7 @@ describe.each(['true', 'false'])('taskLog (isCI = %s)', (isCI) => { log[method]('woo!', { showLog: true }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); }); @@ -307,8 +291,8 @@ describe.each(['true', 'false'])('taskLog (isCI = %s)', (isCI) => { test('can render multiple groups of equal size', async () => { const log = prompts.taskLog({ title: 'Some log', - input, - output, + input: mocks.input, + output: mocks.output, }); const group0 = log.group('Group 0'); const group1 = log.group('Group 1'); @@ -318,14 +302,14 @@ describe.each(['true', 'false'])('taskLog (isCI = %s)', (isCI) => { group1.message(`Group 1 line ${i}`); } - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('can render multiple groups of different sizes', async () => { const log = prompts.taskLog({ title: 'Some log', - input, - output, + input: mocks.input, + output: mocks.output, }); const group0 = log.group('Group 0'); const group1 = log.group('Group 1'); @@ -337,40 +321,40 @@ describe.each(['true', 'false'])('taskLog (isCI = %s)', (isCI) => { group1.message(`Group 1 line ${i}`); } - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders success state', async () => { const log = prompts.taskLog({ title: 'Some log', - input, - output, + input: mocks.input, + output: mocks.output, }); const group = log.group('Group 0'); group.message('Group 0 line 0'); group.success('Group success!'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders error state', async () => { const log = prompts.taskLog({ title: 'Some log', - input, - output, + input: mocks.input, + output: mocks.output, }); const group = log.group('Group 0'); group.message('Group 0 line 0'); group.error('Group error!'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('applies limit per group', async () => { const log = prompts.taskLog({ title: 'Some log', - input, - output, + input: mocks.input, + output: mocks.output, limit: 2, }); const group0 = log.group('Group 0'); @@ -381,40 +365,40 @@ describe.each(['true', 'false'])('taskLog (isCI = %s)', (isCI) => { group1.message(`Group 1 line ${i}`); } - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders group success state', async () => { const log = prompts.taskLog({ title: 'Some log', - input, - output, + input: mocks.input, + output: mocks.output, }); const group = log.group('Group 0'); group.message('Group 0 line 0'); group.success('Group success!'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders group error state', async () => { const log = prompts.taskLog({ title: 'Some log', - input, - output, + input: mocks.input, + output: mocks.output, }); const group = log.group('Group 0'); group.message('Group 0 line 0'); group.error('Group error!'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('showLog shows all groups in order', async () => { const log = prompts.taskLog({ title: 'Some log', - input, - output, + input: mocks.input, + output: mocks.output, }); const group0 = log.group('Group 0'); const group1 = log.group('Group 1'); @@ -431,20 +415,20 @@ describe.each(['true', 'false'])('taskLog (isCI = %s)', (isCI) => { log.error('overall error', { showLog: true }); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('handles empty groups', async () => { const log = prompts.taskLog({ title: 'Some log', - input, - output, + input: mocks.input, + output: mocks.output, }); const group = log.group('Group 0'); group.success('Group success!'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); }); diff --git a/packages/prompts/test/text.test.ts b/packages/prompts/src/text.test.ts similarity index 51% rename from packages/prompts/test/text.test.ts rename to packages/prompts/src/text.test.ts index 62de9067..d149e121 100644 --- a/packages/prompts/test/text.test.ts +++ b/packages/prompts/src/text.test.ts @@ -1,163 +1,150 @@ import { updateSettings } from '@clack/core'; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; -import * as prompts from '../src/index.js'; -import { MockReadable, MockWritable } from './test-utils.js'; +import { createMocks, type Mocks } from '@bomb.sh/tools/test-utils'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import * as prompts from './index.js'; describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { - let originalCI: string | undefined; - let output: MockWritable; - let input: MockReadable; - - beforeAll(() => { - originalCI = process.env.CI; - process.env.CI = isCI; - }); - - afterAll(() => { - process.env.CI = originalCI; - }); + let mocks: Mocks<{ input: true; output: true }>; beforeEach(() => { - output = new MockWritable(); - input = new MockReadable(); + mocks = createMocks({ input: true, output: true, env: { CI: isCI } }); }); afterEach(() => { - vi.restoreAllMocks(); updateSettings({ withGuide: true }); }); test('renders message', async () => { const result = prompts.text({ message: 'foo', - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders placeholder if set', async () => { const result = prompts.text({ message: 'foo', placeholder: 'bar', - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); expect(value).toBe(''); }); test('can cancel', async () => { const result = prompts.text({ message: 'foo', - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'escape', { name: 'escape' }); + mocks.input.emit('keypress', 'escape', { name: 'escape' }); const value = await result; expect(prompts.isCancel(value)).toBe(true); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders cancelled value if one set', async () => { const result = prompts.text({ message: 'foo', - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', 'y', { name: 'y' }); - input.emit('keypress', '', { name: 'escape' }); + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', 'y', { name: 'y' }); + mocks.input.emit('keypress', '', { name: 'escape' }); const value = await result; expect(prompts.isCancel(value)).toBe(true); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('renders submitted value', async () => { const result = prompts.text({ message: 'foo', - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', 'y', { name: 'y' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', 'y', { name: 'y' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe('xy'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('defaultValue sets the value but does not render', async () => { const result = prompts.text({ message: 'foo', defaultValue: 'bar', - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe('bar'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('validation errors render and clear', async () => { const result = prompts.text({ message: 'foo', validate: (val) => (val !== 'xy' ? 'should be xy' : undefined), - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', 'y', { name: 'y' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'y', { name: 'y' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe('xy'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('validation errors render and clear (using Error)', async () => { const result = prompts.text({ message: 'foo', validate: (val) => (val !== 'xy' ? new Error('should be xy') : undefined), - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', 'y', { name: 'y' }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'x', { name: 'x' }); + mocks.input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', 'y', { name: 'y' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe('xy'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('placeholder is not used as value when pressing enter', async () => { @@ -165,62 +152,62 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { message: 'foo', placeholder: ' (hit Enter to use default)', defaultValue: 'default-value', - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe('default-value'); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('empty string when no value and no default', async () => { const result = prompts.text({ message: 'foo', placeholder: ' (hit Enter to use default)', - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); const value = await result; expect(value).toBe(''); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('can be aborted by a signal', async () => { const controller = new AbortController(); const result = prompts.text({ message: 'foo', - input, - output, + input: mocks.input, + output: mocks.output, signal: controller.signal, }); controller.abort(); const value = await result; expect(prompts.isCancel(value)).toBe(true); - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('withGuide: false removes guide', async () => { const result = prompts.text({ message: 'foo', withGuide: false, - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); test('global withGuide: false removes guide', async () => { @@ -228,14 +215,14 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { const result = prompts.text({ message: 'foo', - input, - output, + input: mocks.input, + output: mocks.output, }); - input.emit('keypress', '', { name: 'return' }); + mocks.input.emit('keypress', '', { name: 'return' }); await result; - expect(output.buffer).toMatchSnapshot(); + expect(mocks.output.buffer).toMatchSnapshot(); }); }); diff --git a/packages/prompts/test/multi-line.test.ts b/packages/prompts/test/multi-line.test.ts deleted file mode 100644 index 69edc66d..00000000 --- a/packages/prompts/test/multi-line.test.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { updateSettings } from '@clack/core'; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; -import * as prompts from '../src/index.js'; -import { MockReadable, MockWritable } from './test-utils.js'; - -describe.each(['true', 'false'])('multiline (isCI = %s)', (isCI) => { - let originalCI: string | undefined; - let output: MockWritable; - let input: MockReadable; - - beforeAll(() => { - originalCI = process.env.CI; - process.env.CI = isCI; - }); - - afterAll(() => { - process.env.CI = originalCI; - }); - - beforeEach(() => { - output = new MockWritable(); - input = new MockReadable(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - updateSettings({ withGuide: true }); - }); - - test('renders message', async () => { - const result = prompts.multiline({ - message: 'foo', - input, - output, - }); - - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'return' }); - - await result; - - expect(output.buffer).toMatchSnapshot(); - }); - - test('renders placeholder if set', async () => { - const result = prompts.multiline({ - message: 'foo', - placeholder: 'bar', - input, - output, - }); - - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'return' }); - - const value = await result; - - expect(output.buffer).toMatchSnapshot(); - expect(value).toBe(''); - }); - - test('can cancel', async () => { - const result = prompts.multiline({ - message: 'foo', - input, - output, - }); - - input.emit('keypress', 'escape', { name: 'escape' }); - - const value = await result; - - expect(prompts.isCancel(value)).toBe(true); - expect(output.buffer).toMatchSnapshot(); - }); - - test('renders cancelled value if one set', async () => { - const result = prompts.multiline({ - message: 'foo', - input, - output, - }); - - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', 'y', { name: 'y' }); - input.emit('keypress', '', { name: 'escape' }); - - const value = await result; - - expect(prompts.isCancel(value)).toBe(true); - expect(output.buffer).toMatchSnapshot(); - }); - - test('renders submitted value', async () => { - const result = prompts.multiline({ - message: 'foo', - input, - output, - }); - - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', 'y', { name: 'y' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'return' }); - - const value = await result; - - expect(value).toBe('xy'); - expect(output.buffer).toMatchSnapshot(); - }); - - test('defaultValue sets the value but does not render', async () => { - const result = prompts.multiline({ - message: 'foo', - defaultValue: 'bar', - input, - output, - }); - - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'return' }); - - const value = await result; - - expect(value).toBe('bar'); - expect(output.buffer).toMatchSnapshot(); - }); - - test('validation errors render and clear', async () => { - const result = prompts.multiline({ - message: 'foo', - validate: (val) => (val !== 'xy' ? 'should be xy' : undefined), - input, - output, - }); - - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', 'y', { name: 'y' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'return' }); - - const value = await result; - - expect(value).toBe('xy'); - expect(output.buffer).toMatchSnapshot(); - }); - - test('validation errors render and clear (using Error)', async () => { - const result = prompts.multiline({ - message: 'foo', - validate: (val) => (val !== 'xy' ? new Error('should be xy') : undefined), - input, - output, - }); - - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', 'y', { name: 'y' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'return' }); - - const value = await result; - - expect(value).toBe('xy'); - expect(output.buffer).toMatchSnapshot(); - }); - - test('placeholder is not used as value when pressing enter', async () => { - const result = prompts.multiline({ - message: 'foo', - placeholder: ' (submit to use default)', - defaultValue: 'default-value', - input, - output, - }); - - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'return' }); - - const value = await result; - - expect(value).toBe('default-value'); - expect(output.buffer).toMatchSnapshot(); - }); - - test('empty string when no value and no default', async () => { - const result = prompts.multiline({ - message: 'foo', - placeholder: ' (submit to use default)', - input, - output, - }); - - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'return' }); - - const value = await result; - - expect(value).toBe(''); - expect(output.buffer).toMatchSnapshot(); - }); - - test('can be aborted by a signal', async () => { - const controller = new AbortController(); - const result = prompts.multiline({ - message: 'foo', - input, - output, - signal: controller.signal, - }); - - controller.abort(); - const value = await result; - expect(prompts.isCancel(value)).toBe(true); - expect(output.buffer).toMatchSnapshot(); - }); - - test('withGuide: false removes guide', async () => { - const result = prompts.multiline({ - message: 'foo', - withGuide: false, - input, - output, - }); - - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'return' }); - - await result; - - expect(output.buffer).toMatchSnapshot(); - }); - - test('global withGuide: false removes guide', async () => { - updateSettings({ withGuide: false }); - - const result = prompts.multiline({ - message: 'foo', - input, - output, - }); - - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', '', { name: 'return' }); - - await result; - - expect(output.buffer).toMatchSnapshot(); - }); - - test('renders submit button', async () => { - const result = prompts.multiline({ - message: 'foo', - input, - output, - showSubmit: true, - }); - - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', 'y', { name: 'y' }); - input.emit('keypress', '\t', { name: 'tab' }); - input.emit('keypress', '', { name: 'return' }); - - const value = await result; - - expect(value).toBe('xy'); - expect(output.buffer).toMatchSnapshot(); - }); -}); diff --git a/packages/prompts/test/password.test.ts b/packages/prompts/test/password.test.ts deleted file mode 100644 index 536b3b41..00000000 --- a/packages/prompts/test/password.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { updateSettings } from '@clack/core'; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; -import * as prompts from '../src/index.js'; -import { MockReadable, MockWritable } from './test-utils.js'; - -describe.each(['true', 'false'])('password (isCI = %s)', (isCI) => { - let originalCI: string | undefined; - let output: MockWritable; - let input: MockReadable; - - beforeAll(() => { - originalCI = process.env.CI; - process.env.CI = isCI; - }); - - afterAll(() => { - process.env.CI = originalCI; - }); - - beforeEach(() => { - output = new MockWritable(); - input = new MockReadable(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - updateSettings({ withGuide: true }); - }); - - test('renders message', async () => { - const result = prompts.password({ - message: 'foo', - input, - output, - }); - - input.emit('keypress', '', { name: 'return' }); - - await result; - - expect(output.buffer).toMatchSnapshot(); - }); - - test('renders masked value', async () => { - const result = prompts.password({ - message: 'foo', - input, - output, - }); - - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', 'y', { name: 'y' }); - input.emit('keypress', '', { name: 'return' }); - - const value = await result; - - expect(value).toBe('xy'); - expect(output.buffer).toMatchSnapshot(); - }); - - test('renders custom mask', async () => { - const result = prompts.password({ - message: 'foo', - mask: '*', - input, - output, - }); - - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', 'y', { name: 'y' }); - input.emit('keypress', '', { name: 'return' }); - - await result; - - expect(output.buffer).toMatchSnapshot(); - }); - - test('renders and clears validation errors', async () => { - const result = prompts.password({ - message: 'foo', - validate: (value) => { - if (!value || value.length < 2) { - return 'Password must be at least 2 characters'; - } - - return undefined; - }, - input, - output, - }); - - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', 'y', { name: 'y' }); - input.emit('keypress', '', { name: 'return' }); - - const value = await result; - - expect(value).toBe('xy'); - expect(output.buffer).toMatchSnapshot(); - }); - - test('renders cancelled value', async () => { - const result = prompts.password({ - message: 'foo', - input, - output, - }); - - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', '', { name: 'escape' }); - - const value = await result; - - expect(prompts.isCancel(value)).toBe(true); - expect(output.buffer).toMatchSnapshot(); - }); - - test('can be aborted by a signal', async () => { - const controller = new AbortController(); - const result = prompts.password({ - message: 'foo', - input, - output, - signal: controller.signal, - }); - - controller.abort(); - const value = await result; - expect(prompts.isCancel(value)).toBe(true); - expect(output.buffer).toMatchSnapshot(); - }); - - test('clears input on error when clearOnError is true', async () => { - const result = prompts.password({ - message: 'foo', - input, - output, - validate: (v) => (v === 'yz' ? undefined : 'Error'), - clearOnError: true, - }); - - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', 'y', { name: 'y' }); - input.emit('keypress', 'z', { name: 'z' }); - input.emit('keypress', '', { name: 'return' }); - - const value = await result; - - expect(value).toBe('yz'); - expect(output.buffer).toMatchSnapshot(); - }); - - test('withGuide: false removes guide', async () => { - const result = prompts.password({ - message: 'foo', - withGuide: false, - input, - output, - }); - - input.emit('keypress', '', { name: 'return' }); - - await result; - - expect(output.buffer).toMatchSnapshot(); - }); - - test('global withGuide: false removes guide', async () => { - updateSettings({ withGuide: false }); - - const result = prompts.password({ - message: 'foo', - input, - output, - }); - - input.emit('keypress', '', { name: 'return' }); - - await result; - - expect(output.buffer).toMatchSnapshot(); - }); -}); diff --git a/packages/prompts/test/test-utils.ts b/packages/prompts/test/test-utils.ts deleted file mode 100644 index 414ce247..00000000 --- a/packages/prompts/test/test-utils.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Readable, Writable } from 'node:stream'; - -export class MockWritable extends Writable { - public buffer: string[] = []; - public isTTY = false; - public columns = 80; - public rows = 20; - - _write( - chunk: any, - _encoding: BufferEncoding, - callback: (error?: Error | null | undefined) => void - ): void { - this.buffer.push(chunk.toString()); - callback(); - } -} - -export class MockReadable extends Readable { - protected _buffer: unknown[] | null = []; - - _read() { - if (this._buffer === null) { - this.push(null); - return; - } - - for (const val of this._buffer) { - this.push(val); - } - - this._buffer = []; - } - - pushValue(val: unknown): void { - this._buffer?.push(val); - } - - close(): void { - this._buffer = null; - } -}