diff --git a/packages/checkbox/checkbox.test.mts b/packages/checkbox/checkbox.test.mts index 1cc60cdd5..1e2fd02cb 100644 --- a/packages/checkbox/checkbox.test.mts +++ b/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 = [ @@ -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 () => { diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index b322dbcb2..161d96c3d 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -11,6 +11,7 @@ import { isSpaceKey, isNumberKey, isEnterKey, + ValidationError, Separator, type Theme, } from '@inquirer/core'; @@ -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.', ); } diff --git a/packages/core/core.test.mts b/packages/core/core.test.mts index 01c5eb236..d67ad9658 100644 --- a/packages/core/core.test.mts +++ b/packages/core/core.test.mts @@ -17,6 +17,9 @@ import { isEnterKey, isSpaceKey, Separator, + CancelPromptError, + ValidationError, + HookError, type KeypressEvent, } from './src/index.mjs'; @@ -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); @@ -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', () => { @@ -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 () => { @@ -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 () => { @@ -565,6 +568,9 @@ describe('Error handling', () => { }).toThrowErrorMatchingInlineSnapshot( `[Error: [Inquirer] Hook functions can only be called from within a prompt]`, ); + expect(() => { + useKeypress(() => {}); + }).toThrow(HookError); }); }); diff --git a/packages/core/src/index.mts b/packages/core/src/index.mts index b0434d99c..c1b08513c 100644 --- a/packages/core/src/index.mts +++ b/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'; diff --git a/packages/core/src/lib/create-prompt.mts b/packages/core/src/lib/create-prompt.mts index e9efd962e..8f530f969 100644 --- a/packages/core/src/lib/create-prompt.mts +++ b/packages/core/src/lib/create-prompt.mts @@ -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 = ( config: Prettify, @@ -36,7 +37,9 @@ export function createPrompt(view: ViewFunction) { 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() { @@ -61,7 +64,7 @@ export function createPrompt(view: ViewFunction) { cancel = () => { onExit(); - reject(new Error('Prompt was canceled')); + reject(new CancelPromptError()); }; function done(value: Value) { diff --git a/packages/core/src/lib/errors.mts b/packages/core/src/lib/errors.mts new file mode 100644 index 000000000..c4c6e4cfd --- /dev/null +++ b/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 {} diff --git a/packages/core/src/lib/hook-engine.mts b/packages/core/src/lib/hook-engine.mts index cdb8916c9..d0b1c9bd3 100644 --- a/packages/core/src/lib/hook-engine.mts +++ b/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; @@ -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; } @@ -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; }); diff --git a/packages/select/select.test.mts b/packages/select/select.test.mts index 12fc843e7..95ede1dac 100644 --- a/packages/select/select.test.mts +++ b/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 = [ @@ -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 () => { diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index c346df6dc..551627378 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -12,6 +12,7 @@ import { isDownKey, isNumberKey, Separator, + ValidationError, makeTheme, type Theme, } from '@inquirer/core'; @@ -67,7 +68,7 @@ export default createPrompt( // TODO: Replace with `findLastIndex` when it's available. 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 };