-
Notifications
You must be signed in to change notification settings - Fork 107
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Adds some tests for the core package and fixes a small bug found during writing them. The bug is basically that we never unhide the cursor on `process.exit` conditions, meaning we leave it hidden in the terminal after our prompt has been cancelled.
- Loading branch information
1 parent
081f357
commit 77dee19
Showing
8 changed files
with
1,240 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -57,6 +57,7 @@ | |
"sisteransi": "^1.0.5" | ||
}, | ||
"devDependencies": { | ||
"vitest": "^1.6.0", | ||
"wrap-ansi": "^8.1.0" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
import { Writable } from 'node:stream'; | ||
|
||
export class MockWritable extends Writable { | ||
public buffer: string[] = []; | ||
|
||
_write(chunk, encoding, callback) { | ||
this.buffer.push(chunk.toString()); | ||
callback(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,231 @@ | ||
import { describe, expect, test, afterEach, beforeEach, vi } from 'vitest'; | ||
import { default as Prompt, isCancel } from '../../src/prompts/prompt.js'; | ||
import { cursor } from 'sisteransi'; | ||
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); | ||
} | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
import { describe, expect, test, afterEach, vi } from 'vitest'; | ||
import { block } from '../src/utils.js'; | ||
import type { Key } from 'node:readline'; | ||
import { cursor } from 'sisteransi'; | ||
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(); | ||
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(); | ||
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(); | ||
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 | ||
block({ input, output }); | ||
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(); | ||
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]); | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.