Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -1,21 +1,95 @@
import * as inquirer from 'inquirer';
import { confirmPrompt } from '../../../extensions/amplify-helpers/confirm-prompt';

jest.mock('inquirer', () => {
return {
prompt: (input) => {
return new Promise((resolve) => resolve({ yesno: input }));
},
};
});
jest.mock('inquirer', () => ({
prompt: jest.fn(),
}));

const promptMock = inquirer.prompt as unknown as jest.Mock;

// Shape of the value inquirer.prompt() returns: a promise with the prompt UI
// (which carries the captured answers) monkey-patched onto it.
type PromptUiShape = { answers: Record<string, unknown> };
const attachUi = (promise: Promise<unknown>, ui: PromptUiShape): Promise<unknown> => {
(promise as Promise<unknown> & { ui: PromptUiShape }).ui = ui;
return promise;
};

// Mirrors the error Node >=24 throws from readline.Interface#pause() once the
// interface is closed (Node <=22 made the call a no-op).
const nodeReadlineAfterCloseError = (): Error => {
const err = new Error('readline was closed') as NodeJS.ErrnoException;
err.code = 'ERR_USE_AFTER_CLOSE';
return err;
};

describe('confirmPrompt', () => {
it('returns an object', async () => {
const result = await confirmPrompt('test', true);
expect(result).toStrictEqual({
name: 'yesno',
message: 'test',
type: 'confirm',
default: true,
afterEach(() => {
jest.resetAllMocks();
});

it('asks inquirer a confirm question and returns the answer', async () => {
promptMock.mockReturnValue(attachUi(Promise.resolve({ yesno: false }), { answers: { yesno: false } }));

await expect(confirmPrompt('Proceed?', true)).resolves.toBe(false);
expect(promptMock).toHaveBeenCalledWith({ name: 'yesno', message: 'Proceed?', type: 'confirm', default: true });
});

it('recovers a "yes" answer when inquirer teardown throws ERR_USE_AFTER_CLOSE on Node >=24', async () => {
// inquirer captures the answer on ui.answers, then baseUI.close() -> rl.pause()
// throws because stdin has already closed, rejecting the prompt promise.
const ui: PromptUiShape = { answers: {} };
const rejectsAfterCapturing = Promise.resolve().then(() => {
ui.answers.yesno = true;
throw nodeReadlineAfterCloseError();
});
promptMock.mockReturnValue(attachUi(rejectsAfterCapturing, ui));

await expect(confirmPrompt('Edit now?', false)).resolves.toBe(true);
});

it('recovers a "no" answer when inquirer teardown throws ERR_USE_AFTER_CLOSE on Node >=24', async () => {
const ui: PromptUiShape = { answers: {} };
const rejectsAfterCapturing = Promise.resolve().then(() => {
ui.answers.yesno = false;
throw nodeReadlineAfterCloseError();
});
promptMock.mockReturnValue(attachUi(rejectsAfterCapturing, ui));

await expect(confirmPrompt('Edit now?', true)).resolves.toBe(false);
});

it('falls back to the default answer when the crash happens before any answer is captured', async () => {
const uiTrueDefault: PromptUiShape = { answers: {} };
promptMock.mockReturnValueOnce(
attachUi(
Promise.resolve().then(() => {
throw nodeReadlineAfterCloseError();
}),
uiTrueDefault,
),
);
await expect(confirmPrompt('Edit now?', true)).resolves.toBe(true);

const uiFalseDefault: PromptUiShape = { answers: {} };
promptMock.mockReturnValueOnce(
attachUi(
Promise.resolve().then(() => {
throw nodeReadlineAfterCloseError();
}),
uiFalseDefault,
),
);
await expect(confirmPrompt('Edit now?', false)).resolves.toBe(false);
});

it('rethrows errors that are not the Node readline-after-close crash', async () => {
const ui: PromptUiShape = { answers: {} };
const rejectsWithUnrelatedError = Promise.resolve().then(() => {
throw new Error('something else went wrong');
});
promptMock.mockReturnValue(attachUi(rejectsWithUnrelatedError, ui));

await expect(confirmPrompt('Proceed?')).rejects.toThrow('something else went wrong');
});
});
Original file line number Diff line number Diff line change
@@ -1,14 +1,45 @@
import * as inquirer from 'inquirer';

// Node v24 made readline.Interface#pause() throw with this code when the
// interface is already closed; Node <=22 treated the call as a no-op.
const READLINE_USE_AFTER_CLOSE = 'ERR_USE_AFTER_CLOSE';

/**
* Prompts the user with a yes/no question and resolves to their boolean answer.
*
* On Node >=24 inquirer@7's prompt teardown crashes the CLI: `baseUI.close()`
* calls `readline.Interface#pause()` unconditionally, and Node >=24 throws
* `ERR_USE_AFTER_CLOSE` from that call once stdin has closed (for example EOF on
* piped input) as the prompt finishes. The answer is captured on the prompt UI
* before teardown runs, so this helper recovers it and returns it, restoring the
* pre-Node-24 behavior where the teardown was a silent no-op for every caller.
*
* @param message - the question to display to the user
* @param defaultValue - the answer used when the user accepts the default
* @returns the user's boolean response
* @deprecated Use confirmContinue from amplify-prompts instead
*/
export async function confirmPrompt(message: string, defaultValue = true) {
const ans = await inquirer.prompt({
export async function confirmPrompt(message: string, defaultValue = true): Promise<boolean> {
const promptPromise = inquirer.prompt({
name: 'yesno',
message,
type: 'confirm',
default: defaultValue,
});
return ans.yesno;
try {
const ans = await promptPromise;
return ans.yesno;
} catch (err) {
if (!isReadlineUseAfterCloseError(err)) {
throw err;
}
// The crash happens during teardown, after the answer was captured on the
// prompt UI. Recover it so the caller sees the user's real choice; fall back
// to the default if the prompt closed before an answer was recorded.
const capturedAnswer = promptPromise.ui?.answers?.yesno;
return typeof capturedAnswer === 'boolean' ? capturedAnswer : defaultValue;
}
}

const isReadlineUseAfterCloseError = (err: unknown): boolean =>
typeof err === 'object' && err !== null && (err as { code?: unknown }).code === READLINE_USE_AFTER_CLOSE;
Loading