diff --git a/packages/core/__tests__/mocks/index.ts b/packages/core/__tests__/mocks/index.ts new file mode 100644 index 00000000..293f5013 --- /dev/null +++ b/packages/core/__tests__/mocks/index.ts @@ -0,0 +1,2 @@ +export * from './mock-readable.js'; +export * from './mock-writable.js'; diff --git a/packages/core/test/mock-readable.ts b/packages/core/__tests__/mocks/mock-readable.ts similarity index 100% rename from packages/core/test/mock-readable.ts rename to packages/core/__tests__/mocks/mock-readable.ts diff --git a/packages/core/test/mock-writable.ts b/packages/core/__tests__/mocks/mock-writable.ts similarity index 100% rename from packages/core/test/mock-writable.ts rename to packages/core/__tests__/mocks/mock-writable.ts diff --git a/packages/core/__tests__/prompts/prompt.spec.ts b/packages/core/__tests__/prompts/prompt.spec.ts index b2b262fb..351b4f2e 100644 --- a/packages/core/__tests__/prompts/prompt.spec.ts +++ b/packages/core/__tests__/prompts/prompt.spec.ts @@ -1,6 +1,8 @@ import { randomUUID } from 'crypto'; +import { cursor } from 'sisteransi'; import { mockPrompt, setGlobalAliases } from '../../src'; import Prompt, { PromptOptions } from '../../src/prompts/prompt'; +import { MockReadable, MockWritable } from '../mocks'; const outputSpy = jest.spyOn(process.stdout, 'write').mockImplementation(); @@ -17,8 +19,15 @@ const makeSut = (opts?: Omit, 'render'>, trackValue?: bool }; describe('Prompt', () => { + let input: MockReadable; + let output: MockWritable; const mock = mockPrompt(); + beforeEach(() => { + input = new MockReadable(); + output = new MockWritable(); + }); + afterEach(() => { mock.close(); }); @@ -139,15 +148,22 @@ describe('Prompt', () => { }); it('should allow tab completion for placeholders', () => { - const placeholder = randomUUID(); - makeSut({ initialValue: '', placeholder }); + makeSut({ initialValue: '', placeholder: 'bar' }); + + mock.pressKey('\t', { name: 'tab' }); + + expect(mock.value).toBe('bar'); + }); + + it('should not allow tab completion if value is set', () => { + makeSut({ initialValue: 'foo', placeholder: 'bar' }); mock.pressKey('\t', { name: 'tab' }); - expect(mock.value).toBe(placeholder); + expect(mock.value).toBe('foo'); }); - it('should render prompt', () => { + it('should render prompt on default output', () => { mock.setIsTestMode(false); const value = randomUUID(); @@ -158,6 +174,31 @@ describe('Prompt', () => { expect(outputSpy).toHaveBeenCalledWith(value); }); + test('should render prompt on custom output', () => { + mock.setIsTestMode(false); + const value = 'foo'; + + makeSut({ input, output, initialValue: value }); + + expect(output.buffer).toStrictEqual([cursor.hide, 'foo']); + }); + + it('should re-render prompt on resize', () => { + const renderFn = jest.fn().mockImplementation(() => 'foo'); + const instance = new Prompt({ + input, + output, + render: renderFn, + }); + instance.prompt(); + + expect(renderFn).toHaveBeenCalledTimes(1); + + output.emit('resize'); + + expect(renderFn).toHaveBeenCalledTimes(2); + }); + it('should update single line', () => { mock.setIsTestMode(false); const value = randomUUID(); @@ -188,4 +229,50 @@ describe('Prompt', () => { expect(outputSpy).toHaveBeenCalledWith(value); expect(outputSpy).toHaveBeenCalledWith(newValue); }); + + it('should emit cursor events for movement keys', () => { + const keys = ['up', 'down', 'left', 'right']; + const eventSpy = jest.fn(); + const instance = new Prompt({ + input, + output, + render: () => 'foo', + }); + + instance.on('cursor', eventSpy); + + instance.prompt(); + + for (const key of keys) { + input.emit('keypress', key, { name: key }); + expect(eventSpy).toBeCalledWith(key); + } + }); + + it('should emit cursor events for movement key aliases when not tracking', () => { + const keys = [ + ['k', 'up'], + ['j', 'down'], + ['h', 'left'], + ['l', 'right'], + ]; + const eventSpy = jest.fn(); + const instance = new Prompt( + { + input, + output, + render: () => 'foo', + }, + false + ); + + instance.on('cursor', eventSpy); + + instance.prompt(); + + for (const [alias, key] of keys) { + input.emit('keypress', alias, { name: alias }); + expect(eventSpy).toBeCalledWith(key); + } + }); }); diff --git a/packages/core/test/prompts/prompt.test.ts b/packages/core/test/prompts/prompt.test.ts deleted file mode 100644 index e5a6e781..00000000 --- a/packages/core/test/prompts/prompt.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { cursor } from 'sisteransi'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { default as Prompt, isCancel } from '../../src/prompts/prompt.js'; -import { MockReadable } from '../mock-readable.js'; -import { MockWritable } from '../mock-writable.js'; - -describe('Prompt', () => { - let input: MockReadable; - let output: MockWritable; - - beforeEach(() => { - input = new MockReadable(); - output = new MockWritable(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - test('renders render() result', () => { - const instance = new Prompt({ - input, - 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']); - }); - - test('submits on return key', async () => { - const instance = new Prompt({ - input, - output, - render: () => 'foo', - }); - const resultPromise = instance.prompt(); - input.emit('keypress', '', { name: 'return' }); - const result = await resultPromise; - expect(result).to.equal(''); - expect(isCancel(result)).to.equal(false); - expect(instance.state).to.equal('submit'); - expect(output.buffer).to.deep.equal([cursor.hide, 'foo', '\n', cursor.show]); - }); - - test('cancels on ctrl-c', async () => { - const instance = new Prompt({ - input, - output, - render: () => 'foo', - }); - const resultPromise = instance.prompt(); - 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]); - }); - - test('writes initialValue to value', () => { - const eventSpy = vi.fn(); - const instance = new Prompt({ - input, - output, - render: () => 'foo', - initialValue: 'bananas', - }); - instance.on('value', eventSpy); - instance.prompt(); - expect(instance.value).to.equal('bananas'); - expect(eventSpy).toHaveBeenCalled(); - }); - - test('re-renders on resize', () => { - const renderFn = vi.fn().mockImplementation(() => 'foo'); - const instance = new Prompt({ - input, - output, - render: renderFn, - }); - instance.prompt(); - - expect(renderFn).toHaveBeenCalledTimes(1); - - output.emit('resize'); - - expect(renderFn).toHaveBeenCalledTimes(2); - }); - - test('state is active after first render', async () => { - const instance = new Prompt({ - input, - output, - render: () => 'foo', - }); - - expect(instance.state).to.equal('initial'); - - instance.prompt(); - - expect(instance.state).to.equal('active'); - }); - - test('emits truthy confirm on y press', () => { - const eventFn = vi.fn(); - const instance = new Prompt({ - input, - output, - render: () => 'foo', - }); - - instance.on('confirm', eventFn); - - instance.prompt(); - - input.emit('keypress', 'y', { name: 'y' }); - - expect(eventFn).toBeCalledWith(true); - }); - - test('emits falsey confirm on n press', () => { - const eventFn = vi.fn(); - const instance = new Prompt({ - input, - output, - render: () => 'foo', - }); - - instance.on('confirm', eventFn); - - instance.prompt(); - - input.emit('keypress', 'n', { name: 'n' }); - - expect(eventFn).toBeCalledWith(false); - }); - - test('sets value as placeholder on tab if one is set', () => { - const instance = new Prompt({ - input, - output, - render: () => 'foo', - placeholder: 'piwa', - }); - - instance.prompt(); - - input.emit('keypress', '\t', { name: 'tab' }); - - expect(instance.value).to.equal('piwa'); - }); - - test('does not set placeholder value on tab if value already set', () => { - const instance = new Prompt({ - input, - output, - render: () => 'foo', - placeholder: 'piwa', - initialValue: 'trzy', - }); - - instance.prompt(); - - input.emit('keypress', '\t', { name: 'tab' }); - - expect(instance.value).to.equal('trzy'); - }); - - test('emits key event for unknown chars', () => { - const eventSpy = vi.fn(); - const instance = new Prompt({ - input, - output, - render: () => 'foo', - }); - - instance.on('key', eventSpy); - - instance.prompt(); - - input.emit('keypress', 'z', { name: 'z' }); - - expect(eventSpy).toBeCalledWith('z'); - }); - - test('emits cursor events for movement keys', () => { - const keys = ['up', 'down', 'left', 'right']; - const eventSpy = vi.fn(); - const instance = new Prompt({ - input, - output, - render: () => 'foo', - }); - - instance.on('cursor', eventSpy); - - instance.prompt(); - - for (const key of keys) { - input.emit('keypress', key, { name: key }); - expect(eventSpy).toBeCalledWith(key); - } - }); - - test('emits cursor events for movement key aliases when not tracking', () => { - const keys = [ - ['k', 'up'], - ['j', 'down'], - ['h', 'left'], - ['l', 'right'], - ]; - const eventSpy = vi.fn(); - const instance = new Prompt( - { - input, - output, - render: () => 'foo', - }, - false - ); - - instance.on('cursor', eventSpy); - - instance.prompt(); - - for (const [alias, key] of keys) { - input.emit('keypress', alias, { name: alias }); - expect(eventSpy).toBeCalledWith(key); - } - }); -}); diff --git a/packages/core/test/utils.test.ts b/packages/core/test/utils.test.ts deleted file mode 100644 index b071a344..00000000 --- a/packages/core/test/utils.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { Key } from 'node:readline'; -import { cursor } from 'sisteransi'; -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { block } from '../src/utils.js'; -import { MockReadable } from './mock-readable.js'; -import { MockWritable } from './mock-writable.js'; - -describe('utils', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('block', () => { - test('clears output on keypress', () => { - const input = new MockReadable(); - const output = new MockWritable(); - // @ts-ignore - const callback = block({ input, output }); - - const event: Key = { - name: 'x', - }; - const eventData = Buffer.from('bloop'); - input.emit('keypress', eventData, event); - callback(); - expect(output.buffer).to.deep.equal([cursor.hide, cursor.move(-1, 0), cursor.show]); - }); - - test('clears output vertically when return pressed', () => { - const input = new MockReadable(); - const output = new MockWritable(); - // @ts-ignore - const callback = block({ input, output }); - - const event: Key = { - name: 'return', - }; - const eventData = Buffer.from('bloop'); - input.emit('keypress', eventData, event); - callback(); - expect(output.buffer).to.deep.equal([cursor.hide, cursor.move(0, -1), cursor.show]); - }); - - test('ignores additional keypresses after dispose', () => { - const input = new MockReadable(); - const output = new MockWritable(); - // @ts-ignore - const callback = block({ input, output }); - - const event: Key = { - name: 'x', - }; - const eventData = Buffer.from('bloop'); - input.emit('keypress', eventData, event); - callback(); - input.emit('keypress', eventData, event); - expect(output.buffer).to.deep.equal([cursor.hide, cursor.move(-1, 0), cursor.show]); - }); - - test('exits on ctrl-c', () => { - const input = new MockReadable(); - const output = new MockWritable(); - // purposely don't keep the callback since we would exit the process - // @ts-ignore - block({ input, output }); - // @ts-ignore - const spy = vi.spyOn(process, 'exit').mockImplementation(() => { - return; - }); - - const event: Key = { - name: 'c', - }; - const eventData = Buffer.from('\x03'); - input.emit('keypress', eventData, event); - expect(spy).toHaveBeenCalled(); - expect(output.buffer).to.deep.equal([cursor.hide, cursor.show]); - }); - - test('does not clear if overwrite=false', () => { - const input = new MockReadable(); - const output = new MockWritable(); - // @ts-ignore - const callback = block({ input, output, overwrite: false }); - - const event: Key = { - name: 'c', - }; - const eventData = Buffer.from('bloop'); - input.emit('keypress', eventData, event); - callback(); - expect(output.buffer).to.deep.equal([cursor.hide, cursor.show]); - }); - }); -});