Skip to content

Commit

Permalink
test(core): add initial tests
Browse files Browse the repository at this point in the history
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
43081j committed Jun 4, 2024
1 parent 45ee73b commit e68bee7
Show file tree
Hide file tree
Showing 8 changed files with 1,240 additions and 3 deletions.
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"sisteransi": "^1.0.5"
},
"devDependencies": {
"vitest": "^1.6.0",
"wrap-ansi": "^8.1.0"
}
}
6 changes: 4 additions & 2 deletions packages/core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ export function block({
const clear = (data: Buffer, { name }: Key) => {
const str = String(data);
if (str === '\x03') {
if (hideCursor) output.write(cursor.show);
process.exit(0);
return;
}
if (!overwrite) return;
let dx = name === 'return' ? 0 : -1;
Expand All @@ -36,12 +38,12 @@ export function block({
});
});
};
if (hideCursor) process.stdout.write(cursor.hide);
if (hideCursor) output.write(cursor.hide);
input.once('keypress', clear);

return () => {
input.off('keypress', clear);
if (hideCursor) process.stdout.write(cursor.show);
if (hideCursor) output.write(cursor.show);

// Prevent Windows specific issues: https://github.com/natemoo-re/clack/issues/176
if (input.isTTY && !isWindows) input.setRawMode(false);
Expand Down
26 changes: 26 additions & 0 deletions packages/core/test/mock-readable.ts
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;
}
}
10 changes: 10 additions & 0 deletions packages/core/test/mock-writable.ts
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();
}
}
231 changes: 231 additions & 0 deletions packages/core/test/prompts/prompt.test.ts
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);
}
});
});
89 changes: 89 additions & 0 deletions packages/core/test/utils.test.ts
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]);
});
});
});
Loading

0 comments on commit e68bee7

Please sign in to comment.