diff --git a/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/confirm-prompt.test.ts b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/confirm-prompt.test.ts index 94bc3fcd1fd..c544a2bb325 100644 --- a/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/confirm-prompt.test.ts +++ b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/confirm-prompt.test.ts @@ -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 }; +const attachUi = (promise: Promise, ui: PromptUiShape): Promise => { + (promise as Promise & { 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'); }); }); diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/confirm-prompt.ts b/packages/amplify-cli/src/extensions/amplify-helpers/confirm-prompt.ts index f65b2c292ce..6facde4eced 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/confirm-prompt.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/confirm-prompt.ts @@ -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 { + 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;