Skip to content

Commit

Permalink
feat(inquirer): add AbortSignal support (#1524)
Browse files Browse the repository at this point in the history
  • Loading branch information
mshima committed Sep 7, 2024
1 parent 80c6c57 commit b68860b
Show file tree
Hide file tree
Showing 4 changed files with 133 additions and 64 deletions.
49 changes: 48 additions & 1 deletion packages/inquirer/inquirer.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<TestQuestions>({
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<TestQuestions>({
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<TestQuestions>({
signal: abortController.signal,
Expand All @@ -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<TestQuestions>();
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', () => {
Expand Down
13 changes: 5 additions & 8 deletions packages/inquirer/src/index.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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` */
Expand Down Expand Up @@ -94,11 +95,7 @@ export function createPromptModule<
answers?: PrefilledAnswers,
): PromptReturnType<PrefilledAnswers & A>;
function promptModule<A extends Answers>(
questions:
| NamedQuestion<A>[]
| Record<keyof A, Question<A>>
| Observable<NamedQuestion<A>>
| NamedQuestion<A>,
questions: PromptSession<A>,
answers?: Partial<A>,
): PromptReturnType<A> {
const runner = new PromptsRunner<A>(promptModule.prompts, opt);
Expand All @@ -107,7 +104,7 @@ export function createPromptModule<
return Object.assign(promptPromise, { ui: runner });
}

promptModule.prompts = { ...defaultPrompts };
promptModule.prompts = { ...builtInPrompts };

/**
* Register a prompt type
Expand All @@ -124,7 +121,7 @@ export function createPromptModule<
* Register the defaults provider prompts
*/
promptModule.restoreDefaultPrompts = function () {
promptModule.prompts = { ...defaultPrompts };
promptModule.prompts = { ...builtInPrompts };
};

return promptModule;
Expand Down
10 changes: 5 additions & 5 deletions packages/inquirer/src/types.mts
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,10 @@ export type CustomQuestion<
[key in Extract<keyof Q, string>]: Readonly<QuestionWithGetters<key, Q[key], A>>;
}[Extract<keyof Q, string>];

export type PromptSession<Q extends AnyQuestion<any>> =
| Q[]
| Record<string, Omit<Q, 'name'>>
| Observable<Q>
| Q;
export type PromptSession<A extends Answers> =
| AnyQuestion<A>[]
| Record<string, Omit<AnyQuestion<A>, 'name'>>
| Observable<AnyQuestion<A>>
| AnyQuestion<A>;

export type StreamOptions = Prettify<Context & { skipTTYChecks?: boolean }>;
125 changes: 75 additions & 50 deletions packages/inquirer/src/ui/prompt.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -154,13 +155,13 @@ function setupReadlineOptions(opt: StreamOptions) {
}

function isQuestionArray<A extends Answers>(
questions: PromptSession<AnyQuestion<A>>,
questions: PromptSession<A>,
): questions is AnyQuestion<A>[] {
return Array.isArray(questions);
}

function isQuestionMap<A extends Answers>(
questions: PromptSession<AnyQuestion<A>>,
questions: PromptSession<A>,
): questions is Record<string, Omit<AnyQuestion<A>, 'name'>> {
return Object.values(questions).every(
(maybeQuestion) =>
Expand Down Expand Up @@ -188,7 +189,7 @@ export default class PromptsRunner<A extends Answers> {
prompts: PromptCollection;
answers: Partial<A> = {};
process: Observable<any> = EMPTY;
onClose?: () => void;
abortController?: AbortController;
opt: StreamOptions;
rl?: InquirerReadline;

Expand All @@ -197,7 +198,7 @@ export default class PromptsRunner<A extends Answers> {
this.prompts = prompts;
}

async run(questions: PromptSession<AnyQuestion<A>>, answers?: Partial<A>): Promise<A> {
async run(questions: PromptSession<A>, answers?: Partial<A>): Promise<A> {
// Keep global reference to the answers
this.answers = typeof answers === 'object' ? { ...answers } : {};

Expand Down Expand Up @@ -267,7 +268,7 @@ export default class PromptsRunner<A extends Answers> {

return of(question);
}),
concatMap((question) => this.fetchAnswer(question)),
concatMap((question) => defer(() => from(this.fetchAnswer(question)))),
);
});
}
Expand All @@ -279,48 +280,77 @@ export default class PromptsRunner<A extends Answers> {
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<A> = isPromptConstructor(prompt)
? (q, { signal } = {}) =>
new Promise<A>((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;
});
}

/**
Expand All @@ -336,12 +366,7 @@ export default class PromptsRunner<A extends Answers> {
* 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<A>): Observable<AnyQuestion<A>> => {
Expand Down

0 comments on commit b68860b

Please sign in to comment.