Skip to content

Commit

Permalink
Feat: Throw custom errors and expose them from @inquirer/core (#1369)
Browse files Browse the repository at this point in the history
Fix #1368
  • Loading branch information
SBoudrias committed Mar 12, 2024
1 parent 1d26129 commit 3aff249
Show file tree
Hide file tree
Showing 9 changed files with 42 additions and 12 deletions.
2 changes: 2 additions & 0 deletions packages/checkbox/checkbox.test.mts
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { render } from '@inquirer/testing';
import { ValidationError } from '@inquirer/core';
import checkbox, { Separator } from './src/index.mjs';

const numberedChoices = [
Expand Down Expand Up @@ -611,6 +612,7 @@ describe('checkbox prompt', () => {
await expect(answer).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: [checkbox prompt] No selectable choices. All choices are disabled.]`,
);
await expect(answer).rejects.toBeInstanceOf(ValidationError);
});

it('shows validation message if user did not select any choice', async () => {
Expand Down
3 changes: 2 additions & 1 deletion packages/checkbox/src/index.mts
Expand Up @@ -11,6 +11,7 @@ import {
isSpaceKey,
isNumberKey,
isEnterKey,
ValidationError,
Separator,
type Theme,
} from '@inquirer/core';
Expand Down Expand Up @@ -112,7 +113,7 @@ export default createPrompt(
const last = items.length - 1 - [...items].reverse().findIndex(isSelectable);

if (first < 0) {
throw new Error(
throw new ValidationError(
'[checkbox prompt] No selectable choices. All choices are disabled.',
);
}
Expand Down
18 changes: 12 additions & 6 deletions packages/core/core.test.mts
Expand Up @@ -17,6 +17,9 @@ import {
isEnterKey,
isSpaceKey,
Separator,
CancelPromptError,
ValidationError,
HookError,
type KeypressEvent,
} from './src/index.mjs';

Expand Down Expand Up @@ -426,9 +429,7 @@ describe('createPrompt()', () => {
answer.cancel();
events.keypress('enter');

await expect(answer).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: Prompt was canceled]`,
);
await expect(answer).rejects.toThrow(CancelPromptError);

const output = getFullOutput();
expect(output).toContain(ansiEscapes.cursorHide);
Expand Down Expand Up @@ -482,9 +483,7 @@ it('allow cancelling the prompt multiple times', async () => {
answer.cancel();
events.keypress('enter');

await expect(answer).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: Prompt was canceled]`,
);
await expect(answer).rejects.toThrow(CancelPromptError);
});

describe('Error handling', () => {
Expand Down Expand Up @@ -549,6 +548,7 @@ describe('Error handling', () => {
await expect(answer).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: useEffect return value must be a cleanup function or nothing.]`,
);
await expect(answer).rejects.toBeInstanceOf(ValidationError);
});

it('useEffect throws outside prompt', async () => {
Expand All @@ -557,6 +557,9 @@ describe('Error handling', () => {
}).toThrowErrorMatchingInlineSnapshot(
`[Error: [Inquirer] Hook functions can only be called from within a prompt]`,
);
expect(() => {
useEffect(() => {}, []);
}).toThrow(HookError);
});

it('useKeypress throws outside prompt', async () => {
Expand All @@ -565,6 +568,9 @@ describe('Error handling', () => {
}).toThrowErrorMatchingInlineSnapshot(
`[Error: [Inquirer] Hook functions can only be called from within a prompt]`,
);
expect(() => {
useKeypress(() => {});
}).toThrow(HookError);
});
});

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.mts
@@ -1,4 +1,5 @@
export * from './lib/key.mjs';
export * from './lib/errors.mjs';
export { usePrefix } from './lib/use-prefix.mjs';
export { useState } from './lib/use-state.mjs';
export { useEffect } from './lib/use-effect.mjs';
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/lib/create-prompt.mts
Expand Up @@ -5,6 +5,7 @@ import { onExit as onSignalExit } from 'signal-exit';
import ScreenManager from './screen-manager.mjs';
import type { InquirerReadline } from './read-line.type.mjs';
import { withHooks, effectScheduler } from './hook-engine.mjs';
import { CancelPromptError, ExitPromptError } from './errors.mjs';

type ViewFunction<Value, Config> = (
config: Prettify<Config>,
Expand Down Expand Up @@ -36,7 +37,9 @@ export function createPrompt<Value, Config>(view: ViewFunction<Value, Config>) {

const removeExitListener = onSignalExit((code, signal) => {
onExit();
reject(new Error(`User force closed the prompt with ${code} ${signal}`));
reject(
new ExitPromptError(`User force closed the prompt with ${code} ${signal}`),
);
});

function onExit() {
Expand All @@ -61,7 +64,7 @@ export function createPrompt<Value, Config>(view: ViewFunction<Value, Config>) {

cancel = () => {
onExit();
reject(new Error('Prompt was canceled'));
reject(new CancelPromptError());
};

function done(value: Value) {
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/lib/errors.mts
@@ -0,0 +1,9 @@
export class CancelPromptError extends Error {
override message = 'Prompt was canceled';
}

export class ExitPromptError extends Error {}

export class HookError extends Error {}

export class ValidationError extends Error {}
9 changes: 7 additions & 2 deletions packages/core/src/lib/hook-engine.mts
@@ -1,5 +1,6 @@
import { AsyncLocalStorage, AsyncResource } from 'node:async_hooks';
import type { InquirerReadline } from './read-line.type.mjs';
import { HookError, ValidationError } from './errors.mjs';

type HookStore = {
rl: InquirerReadline;
Expand Down Expand Up @@ -36,7 +37,9 @@ export function withHooks(rl: InquirerReadline, cb: (store: HookStore) => void)
function getStore() {
const store = hookStorage.getStore();
if (!store) {
throw new Error('[Inquirer] Hook functions can only be called from within a prompt');
throw new HookError(
'[Inquirer] Hook functions can only be called from within a prompt',
);
}
return store;
}
Expand Down Expand Up @@ -117,7 +120,9 @@ export const effectScheduler = {

const cleanFn = cb(readline());
if (cleanFn != null && typeof cleanFn !== 'function') {
throw new Error('useEffect return value must be a cleanup function or nothing.');
throw new ValidationError(
'useEffect return value must be a cleanup function or nothing.',
);
}
store.hooksCleanup[index] = cleanFn;
});
Expand Down
2 changes: 2 additions & 0 deletions packages/select/select.test.mts
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import { render } from '@inquirer/testing';
import { ValidationError } from '@inquirer/core';
import select, { Separator } from './src/index.mjs';

const numberedChoices = [
Expand Down Expand Up @@ -378,6 +379,7 @@ describe('select prompt', () => {
await expect(answer).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: [select prompt] No selectable choices. All choices are disabled.]`,
);
await expect(answer).rejects.toBeInstanceOf(ValidationError);
});

it('skip separator by arrow keys', async () => {
Expand Down
3 changes: 2 additions & 1 deletion packages/select/src/index.mts
Expand Up @@ -12,6 +12,7 @@ import {
isDownKey,
isNumberKey,
Separator,
ValidationError,
makeTheme,
type Theme,
} from '@inquirer/core';
Expand Down Expand Up @@ -67,7 +68,7 @@ export default createPrompt(
// TODO: Replace with `findLastIndex` when it's available.

Check warning on line 68 in packages/select/src/index.mts

View workflow job for this annotation

GitHub Actions / Linting

Unexpected 'todo' comment: 'TODO: Replace with `findLastIndex` when...'
const last = items.length - 1 - [...items].reverse().findIndex(isSelectable);
if (first < 0)
throw new Error(
throw new ValidationError(
'[select prompt] No selectable choices. All choices are disabled.',
);
return { first, last };
Expand Down

0 comments on commit 3aff249

Please sign in to comment.