Skip to content

Commit 77dee19

Browse files
43081jnatemoo-re
authored andcommitted
test(core): add initial tests
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.
1 parent 081f357 commit 77dee19

File tree

8 files changed

+1240
-3
lines changed

8 files changed

+1240
-3
lines changed

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"sisteransi": "^1.0.5"
5858
},
5959
"devDependencies": {
60+
"vitest": "^1.6.0",
6061
"wrap-ansi": "^8.1.0"
6162
}
6263
}

packages/core/src/utils.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ export function block({
2424
const clear = (data: Buffer, { name }: Key) => {
2525
const str = String(data);
2626
if (str === '\x03') {
27+
if (hideCursor) output.write(cursor.show);
2728
process.exit(0);
29+
return;
2830
}
2931
if (!overwrite) return;
3032
let dx = name === 'return' ? 0 : -1;
@@ -36,12 +38,12 @@ export function block({
3638
});
3739
});
3840
};
39-
if (hideCursor) process.stdout.write(cursor.hide);
41+
if (hideCursor) output.write(cursor.hide);
4042
input.once('keypress', clear);
4143

4244
return () => {
4345
input.off('keypress', clear);
44-
if (hideCursor) process.stdout.write(cursor.show);
46+
if (hideCursor) output.write(cursor.show);
4547

4648
// Prevent Windows specific issues: https://github.com/natemoo-re/clack/issues/176
4749
if (input.isTTY && !isWindows) input.setRawMode(false);
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { Readable } from 'node:stream';
2+
3+
export class MockReadable extends Readable {
4+
protected _buffer: unknown[] | null = [];
5+
6+
_read() {
7+
if (this._buffer === null) {
8+
this.push(null);
9+
return;
10+
}
11+
12+
for (const val of this._buffer) {
13+
this.push(val);
14+
}
15+
16+
this._buffer = [];
17+
}
18+
19+
pushValue(val: unknown): void {
20+
this._buffer.push(val);
21+
}
22+
23+
close(): void {
24+
this._buffer = null;
25+
}
26+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Writable } from 'node:stream';
2+
3+
export class MockWritable extends Writable {
4+
public buffer: string[] = [];
5+
6+
_write(chunk, encoding, callback) {
7+
this.buffer.push(chunk.toString());
8+
callback();
9+
}
10+
}
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import { describe, expect, test, afterEach, beforeEach, vi } from 'vitest';
2+
import { default as Prompt, isCancel } from '../../src/prompts/prompt.js';
3+
import { cursor } from 'sisteransi';
4+
import { MockReadable } from '../mock-readable.js';
5+
import { MockWritable } from '../mock-writable.js';
6+
7+
describe('Prompt', () => {
8+
let input: MockReadable;
9+
let output: MockWritable;
10+
11+
beforeEach(() => {
12+
input = new MockReadable();
13+
output = new MockWritable();
14+
});
15+
16+
afterEach(() => {
17+
vi.restoreAllMocks();
18+
});
19+
20+
test('renders render() result', () => {
21+
const instance = new Prompt({
22+
input,
23+
output,
24+
render: () => 'foo',
25+
});
26+
// leave the promise hanging since we don't want to submit in this test
27+
instance.prompt();
28+
expect(output.buffer).to.deep.equal([cursor.hide, 'foo']);
29+
});
30+
31+
test('submits on return key', async () => {
32+
const instance = new Prompt({
33+
input,
34+
output,
35+
render: () => 'foo',
36+
});
37+
const resultPromise = instance.prompt();
38+
input.emit('keypress', '', { name: 'return' });
39+
const result = await resultPromise;
40+
expect(result).to.equal('');
41+
expect(isCancel(result)).to.equal(false);
42+
expect(instance.state).to.equal('submit');
43+
expect(output.buffer).to.deep.equal([cursor.hide, 'foo', '\n', cursor.show]);
44+
});
45+
46+
test('cancels on ctrl-c', async () => {
47+
const instance = new Prompt({
48+
input,
49+
output,
50+
render: () => 'foo',
51+
});
52+
const resultPromise = instance.prompt();
53+
input.emit('keypress', '\x03', { name: 'c' });
54+
const result = await resultPromise;
55+
expect(isCancel(result)).to.equal(true);
56+
expect(instance.state).to.equal('cancel');
57+
expect(output.buffer).to.deep.equal([cursor.hide, 'foo', '\n', cursor.show]);
58+
});
59+
60+
test('writes initialValue to value', () => {
61+
const eventSpy = vi.fn();
62+
const instance = new Prompt({
63+
input,
64+
output,
65+
render: () => 'foo',
66+
initialValue: 'bananas',
67+
});
68+
instance.on('value', eventSpy);
69+
instance.prompt();
70+
expect(instance.value).to.equal('bananas');
71+
expect(eventSpy).toHaveBeenCalled();
72+
});
73+
74+
test('re-renders on resize', () => {
75+
const renderFn = vi.fn().mockImplementation(() => 'foo');
76+
const instance = new Prompt({
77+
input,
78+
output,
79+
render: renderFn,
80+
});
81+
instance.prompt();
82+
83+
expect(renderFn).toHaveBeenCalledTimes(1);
84+
85+
output.emit('resize');
86+
87+
expect(renderFn).toHaveBeenCalledTimes(2);
88+
});
89+
90+
test('state is active after first render', async () => {
91+
const instance = new Prompt({
92+
input,
93+
output,
94+
render: () => 'foo',
95+
});
96+
97+
expect(instance.state).to.equal('initial');
98+
99+
instance.prompt();
100+
101+
expect(instance.state).to.equal('active');
102+
});
103+
104+
test('emits truthy confirm on y press', () => {
105+
const eventFn = vi.fn();
106+
const instance = new Prompt({
107+
input,
108+
output,
109+
render: () => 'foo',
110+
});
111+
112+
instance.on('confirm', eventFn);
113+
114+
instance.prompt();
115+
116+
input.emit('keypress', 'y', { name: 'y' });
117+
118+
expect(eventFn).toBeCalledWith(true);
119+
});
120+
121+
test('emits falsey confirm on n press', () => {
122+
const eventFn = vi.fn();
123+
const instance = new Prompt({
124+
input,
125+
output,
126+
render: () => 'foo',
127+
});
128+
129+
instance.on('confirm', eventFn);
130+
131+
instance.prompt();
132+
133+
input.emit('keypress', 'n', { name: 'n' });
134+
135+
expect(eventFn).toBeCalledWith(false);
136+
});
137+
138+
test('sets value as placeholder on tab if one is set', () => {
139+
const instance = new Prompt({
140+
input,
141+
output,
142+
render: () => 'foo',
143+
placeholder: 'piwa',
144+
});
145+
146+
instance.prompt();
147+
148+
input.emit('keypress', '\t', { name: 'tab' });
149+
150+
expect(instance.value).to.equal('piwa');
151+
});
152+
153+
test('does not set placeholder value on tab if value already set', () => {
154+
const instance = new Prompt({
155+
input,
156+
output,
157+
render: () => 'foo',
158+
placeholder: 'piwa',
159+
initialValue: 'trzy',
160+
});
161+
162+
instance.prompt();
163+
164+
input.emit('keypress', '\t', { name: 'tab' });
165+
166+
expect(instance.value).to.equal('trzy');
167+
});
168+
169+
test('emits key event for unknown chars', () => {
170+
const eventSpy = vi.fn();
171+
const instance = new Prompt({
172+
input,
173+
output,
174+
render: () => 'foo',
175+
});
176+
177+
instance.on('key', eventSpy);
178+
179+
instance.prompt();
180+
181+
input.emit('keypress', 'z', { name: 'z' });
182+
183+
expect(eventSpy).toBeCalledWith('z');
184+
});
185+
186+
test('emits cursor events for movement keys', () => {
187+
const keys = ['up', 'down', 'left', 'right'];
188+
const eventSpy = vi.fn();
189+
const instance = new Prompt({
190+
input,
191+
output,
192+
render: () => 'foo',
193+
});
194+
195+
instance.on('cursor', eventSpy);
196+
197+
instance.prompt();
198+
199+
for (const key of keys) {
200+
input.emit('keypress', key, { name: key });
201+
expect(eventSpy).toBeCalledWith(key);
202+
}
203+
});
204+
205+
test('emits cursor events for movement key aliases when not tracking', () => {
206+
const keys = [
207+
['k', 'up'],
208+
['j', 'down'],
209+
['h', 'left'],
210+
['l', 'right'],
211+
];
212+
const eventSpy = vi.fn();
213+
const instance = new Prompt(
214+
{
215+
input,
216+
output,
217+
render: () => 'foo',
218+
},
219+
false
220+
);
221+
222+
instance.on('cursor', eventSpy);
223+
224+
instance.prompt();
225+
226+
for (const [alias, key] of keys) {
227+
input.emit('keypress', alias, { name: alias });
228+
expect(eventSpy).toBeCalledWith(key);
229+
}
230+
});
231+
});

packages/core/test/utils.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { describe, expect, test, afterEach, vi } from 'vitest';
2+
import { block } from '../src/utils.js';
3+
import type { Key } from 'node:readline';
4+
import { cursor } from 'sisteransi';
5+
import { MockReadable } from './mock-readable.js';
6+
import { MockWritable } from './mock-writable.js';
7+
8+
describe('utils', () => {
9+
afterEach(() => {
10+
vi.restoreAllMocks();
11+
});
12+
13+
describe('block', () => {
14+
test('clears output on keypress', () => {
15+
const input = new MockReadable();
16+
const output = new MockWritable();
17+
const callback = block({ input, output });
18+
19+
const event: Key = {
20+
name: 'x',
21+
};
22+
const eventData = Buffer.from('bloop');
23+
input.emit('keypress', eventData, event);
24+
callback();
25+
expect(output.buffer).to.deep.equal([cursor.hide, cursor.move(-1, 0), cursor.show]);
26+
});
27+
28+
test('clears output vertically when return pressed', () => {
29+
const input = new MockReadable();
30+
const output = new MockWritable();
31+
const callback = block({ input, output });
32+
33+
const event: Key = {
34+
name: 'return',
35+
};
36+
const eventData = Buffer.from('bloop');
37+
input.emit('keypress', eventData, event);
38+
callback();
39+
expect(output.buffer).to.deep.equal([cursor.hide, cursor.move(0, -1), cursor.show]);
40+
});
41+
42+
test('ignores additional keypresses after dispose', () => {
43+
const input = new MockReadable();
44+
const output = new MockWritable();
45+
const callback = block({ input, output });
46+
47+
const event: Key = {
48+
name: 'x',
49+
};
50+
const eventData = Buffer.from('bloop');
51+
input.emit('keypress', eventData, event);
52+
callback();
53+
input.emit('keypress', eventData, event);
54+
expect(output.buffer).to.deep.equal([cursor.hide, cursor.move(-1, 0), cursor.show]);
55+
});
56+
57+
test('exits on ctrl-c', () => {
58+
const input = new MockReadable();
59+
const output = new MockWritable();
60+
// purposely don't keep the callback since we would exit the process
61+
block({ input, output });
62+
const spy = vi.spyOn(process, 'exit').mockImplementation(() => {
63+
return;
64+
});
65+
66+
const event: Key = {
67+
name: 'c',
68+
};
69+
const eventData = Buffer.from('\x03');
70+
input.emit('keypress', eventData, event);
71+
expect(spy).toHaveBeenCalled();
72+
expect(output.buffer).to.deep.equal([cursor.hide, cursor.show]);
73+
});
74+
75+
test('does not clear if overwrite=false', () => {
76+
const input = new MockReadable();
77+
const output = new MockWritable();
78+
const callback = block({ input, output, overwrite: false });
79+
80+
const event: Key = {
81+
name: 'c',
82+
};
83+
const eventData = Buffer.from('bloop');
84+
input.emit('keypress', eventData, event);
85+
callback();
86+
expect(output.buffer).to.deep.equal([cursor.hide, cursor.show]);
87+
});
88+
});
89+
});

0 commit comments

Comments
 (0)