diff --git a/packages/inquirer/inquirer.test.mts b/packages/inquirer/inquirer.test.mts index 391dbc61f..41470b4ba 100644 --- a/packages/inquirer/inquirer.test.mts +++ b/packages/inquirer/inquirer.test.mts @@ -57,6 +57,19 @@ class StubFailingPrompt { close() {} } +class StubEventualyFailingPrompt { + timeout?: NodeJS.Timeout; + + run() { + this.timeout = setTimeout(() => {}, 1000); + return Promise.reject(new Error('This test prompt always reject')); + } + + close() { + clearTimeout(this.timeout); + } +} + beforeEach(() => { inquirer.restoreDefaultPrompts(); inquirer.registerPrompt('stub', StubPrompt); @@ -760,7 +773,29 @@ describe('inquirer.prompt(...)', () => { }); describe('AbortSignal support', () => { - it('modern prompts can be aborted through PromptModule constructor', async () => { + it('throws on aborted signal', async () => { + const localPrompt = inquirer.createPromptModule({ + signal: AbortSignal.abort(), + }); + localPrompt.registerPrompt('stub', StubEventualyFailingPrompt); + + const promise = localPrompt({ type: 'stub', name: 'q1', message: 'message' }); + await expect(promise).rejects.toThrow(AbortPromptError); + }); + + it('legacy prompts can be aborted by module signal', async () => { + const abortController = new AbortController(); + const localPrompt = inquirer.createPromptModule({ + signal: abortController.signal, + }); + localPrompt.registerPrompt('stub', StubEventualyFailingPrompt); + + const promise = localPrompt({ type: 'stub', name: 'q1', message: 'message' }); + abortController.abort(); + await expect(promise).rejects.toThrow(AbortPromptError); + }); + + it('modern prompts can be aborted by module signal', async () => { const abortController = new AbortController(); const localPrompt = inquirer.createPromptModule({ signal: abortController.signal, @@ -774,6 +809,18 @@ describe('AbortSignal support', () => { abortController.abort(); await expect(promise).rejects.toThrow(AbortPromptError); }); + + it('modern prompts can be aborted using ui.close()', async () => { + const localPrompt = inquirer.createPromptModule(); + localPrompt.registerPrompt( + 'stub', + createPrompt(() => 'dummy prompt'), + ); + + const promise = localPrompt({ type: 'stub', name: 'q1', message: 'message' }); + promise.ui.close(); + await expect(promise).rejects.toThrow(AbortPromptError); + }); }); describe('Non-TTY checks', () => { diff --git a/packages/inquirer/src/index.mts b/packages/inquirer/src/index.mts index 6c414dd1a..4da48ff1b 100644 --- a/packages/inquirer/src/index.mts +++ b/packages/inquirer/src/index.mts @@ -29,12 +29,13 @@ import type { BuiltInQuestion, StreamOptions, QuestionMap, + PromptSession, } from './types.mjs'; import { Observable } from 'rxjs'; export type { QuestionMap } from './types.mjs'; -const defaultPrompts: PromptCollection = { +const builtInPrompts: PromptCollection = { input, select, /** @deprecated `list` is now named `select` */ @@ -94,11 +95,7 @@ export function createPromptModule< answers?: PrefilledAnswers, ): PromptReturnType; function promptModule( - questions: - | NamedQuestion[] - | Record> - | Observable> - | NamedQuestion, + questions: PromptSession, answers?: Partial, ): PromptReturnType { const runner = new PromptsRunner(promptModule.prompts, opt); @@ -107,7 +104,7 @@ export function createPromptModule< return Object.assign(promptPromise, { ui: runner }); } - promptModule.prompts = { ...defaultPrompts }; + promptModule.prompts = { ...builtInPrompts }; /** * Register a prompt type @@ -124,7 +121,7 @@ export function createPromptModule< * Register the defaults provider prompts */ promptModule.restoreDefaultPrompts = function () { - promptModule.prompts = { ...defaultPrompts }; + promptModule.prompts = { ...builtInPrompts }; }; return promptModule; diff --git a/packages/inquirer/src/types.mts b/packages/inquirer/src/types.mts index 1a3d49da9..4607518d9 100644 --- a/packages/inquirer/src/types.mts +++ b/packages/inquirer/src/types.mts @@ -92,10 +92,10 @@ export type CustomQuestion< [key in Extract]: Readonly>; }[Extract]; -export type PromptSession> = - | Q[] - | Record> - | Observable - | Q; +export type PromptSession = + | AnyQuestion[] + | Record, 'name'>> + | Observable> + | AnyQuestion; export type StreamOptions = Prettify; diff --git a/packages/inquirer/src/ui/prompt.mts b/packages/inquirer/src/ui/prompt.mts index b0bf282db..a6873854d 100644 --- a/packages/inquirer/src/ui/prompt.mts +++ b/packages/inquirer/src/ui/prompt.mts @@ -14,6 +14,7 @@ import { } from 'rxjs'; import runAsync from 'run-async'; import MuteStream from 'mute-stream'; +import { AbortPromptError } from '@inquirer/core'; import type { InquirerReadline } from '@inquirer/type'; import ansiEscapes from 'ansi-escapes'; import type { Answers, AnyQuestion, PromptSession, StreamOptions } from '../types.mjs'; @@ -154,13 +155,13 @@ function setupReadlineOptions(opt: StreamOptions) { } function isQuestionArray( - questions: PromptSession>, + questions: PromptSession, ): questions is AnyQuestion[] { return Array.isArray(questions); } function isQuestionMap( - questions: PromptSession>, + questions: PromptSession, ): questions is Record, 'name'>> { return Object.values(questions).every( (maybeQuestion) => @@ -188,7 +189,7 @@ export default class PromptsRunner { prompts: PromptCollection; answers: Partial = {}; process: Observable = EMPTY; - onClose?: () => void; + abortController?: AbortController; opt: StreamOptions; rl?: InquirerReadline; @@ -197,7 +198,7 @@ export default class PromptsRunner { this.prompts = prompts; } - async run(questions: PromptSession>, answers?: Partial): Promise { + async run(questions: PromptSession, answers?: Partial): Promise { // Keep global reference to the answers this.answers = typeof answers === 'object' ? { ...answers } : {}; @@ -267,7 +268,7 @@ export default class PromptsRunner { return of(question); }), - concatMap((question) => this.fetchAnswer(question)), + concatMap((question) => defer(() => from(this.fetchAnswer(question)))), ); }); } @@ -279,48 +280,77 @@ export default class PromptsRunner { throw new Error(`Prompt for type ${question.type} not found`); } - return isPromptConstructor(prompt) - ? defer(() => { - const rl = readline.createInterface( - setupReadlineOptions(this.opt), - ) as InquirerReadline; - rl.resume(); - - const onClose = () => { - rl.removeListener('SIGINT', this.onForceClose); - rl.setPrompt(''); - rl.output.unmute(); - rl.output.write(ansiEscapes.cursorShow); - rl.output.end(); - rl.close(); - }; - this.onClose = onClose; - this.rl = rl; - - // Make sure new prompt start on a newline when closing - process.on('exit', this.onForceClose); - rl.on('SIGINT', this.onForceClose); - - const activePrompt = new prompt(question, rl, this.answers); - - return from( - activePrompt.run().then((answer: unknown) => { + let cleanupSignal: (() => void) | undefined; + + const promptFn: PromptFn = isPromptConstructor(prompt) + ? (q, { signal } = {}) => + new Promise((resolve, reject) => { + const rl = readline.createInterface( + setupReadlineOptions(this.opt), + ) as InquirerReadline; + rl.resume(); + + const onClose = () => { + process.removeListener('exit', this.onForceClose); + rl.removeListener('SIGINT', this.onForceClose); + rl.setPrompt(''); + rl.output.unmute(); + rl.output.write(ansiEscapes.cursorShow); + rl.output.end(); + rl.close(); + }; + this.rl = rl; + + // Make sure new prompt start on a newline when closing + process.on('exit', this.onForceClose); + rl.on('SIGINT', this.onForceClose); + + const activePrompt = new prompt(q, rl, this.answers); + + const cleanup = () => { onClose(); - this.onClose = undefined; this.rl = undefined; + cleanupSignal?.(); + }; + + if (signal) { + const abort = () => { + reject(new AbortPromptError({ cause: signal.reason })); + cleanup(); + }; + if (signal.aborted) { + abort(); + return; + } + signal.addEventListener('abort', abort); + cleanupSignal = () => { + signal.removeEventListener('abort', abort); + cleanupSignal = undefined; + }; + } + activePrompt.run().then(resolve, reject).finally(cleanup); + }) + : prompt; + + const { signal: moduleSignal } = this.opt; + this.abortController = new AbortController(); + if (moduleSignal?.aborted) { + this.abortController.abort(moduleSignal.reason); + } else if (moduleSignal) { + const abort = (reason: unknown) => this.abortController?.abort(reason); + moduleSignal.addEventListener('abort', abort); + cleanupSignal = () => { + moduleSignal.removeEventListener('abort', abort); + }; + } - return { name: question.name, answer }; - }), - ); - }) - : defer(() => - from( - prompt(question, this.opt).then((answer: unknown) => ({ - name: question.name, - answer, - })), - ), - ); + const { signal } = this.abortController; + return promptFn(question, { ...this.opt, signal }) + .then((answer: unknown) => ({ name: question.name, answer })) + .finally(() => { + cleanupSignal?.(); + this.abortController = undefined; + }); } /** @@ -336,12 +366,7 @@ export default class PromptsRunner { * Close the interface and cleanup listeners */ close = () => { - // Remove events listeners - process.removeListener('exit', this.onForceClose); - - if (typeof this.onClose === 'function') { - this.onClose(); - } + this.abortController?.abort(); }; setDefaultType = (question: AnyQuestion): Observable> => {