Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(inquirer): add AbortSignal support #1524

Merged
merged 1 commit into from
Sep 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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