From 7d521b2350e488bf5c1570f8f82bf7a582eea0d8 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Mon, 12 Feb 2024 13:40:25 -0700 Subject: [PATCH 1/2] fix: allow long text in ux.prompt --- src/cli-ux/prompt.ts | 38 +++++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/src/cli-ux/prompt.ts b/src/cli-ux/prompt.ts index 84f25dc21..495593523 100644 --- a/src/cli-ux/prompt.ts +++ b/src/cli-ux/prompt.ts @@ -1,4 +1,5 @@ import chalk from 'chalk' +import readline from 'node:readline' import * as Errors from '../errors' import {config} from './config' @@ -26,29 +27,36 @@ interface IPromptConfig { function normal(options: IPromptConfig, retries = 100): Promise { if (retries < 0) throw new Error('no input') + const ac = new AbortController() + const {signal} = ac + return new Promise((resolve, reject) => { - let timer: NodeJS.Timeout - if (options.timeout) { - timer = setTimeout(() => { - process.stdin.pause() - reject(new Error('Prompt timeout')) - }, options.timeout) - timer.unref() - } + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) - process.stdin.setEncoding('utf8') - process.stderr.write(options.prompt) - process.stdin.resume() - process.stdin.once('data', (b) => { - if (timer) clearTimeout(timer) - process.stdin.pause() - const data: string = (typeof b === 'string' ? b : b.toString()).trim() + rl.question(options.prompt, {signal}, (answer) => { + rl.close() + const data = answer.trim() if (!options.default && options.required && data === '') { resolve(normal(options, retries - 1)) } else { resolve(data || (options.default as string)) } }) + + if (options.timeout) { + signal.addEventListener( + 'abort', + () => { + reject(new Error('Prompt timeout')) + }, + {once: true}, + ) + + setTimeout(() => ac.abort(), options.timeout) + } }) } From 05c718650f0de49a80cf7f3567ec4366d89c6034 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Mon, 12 Feb 2024 14:20:15 -0700 Subject: [PATCH 2/2] test: unfancy prompt tests --- test/cli-ux/prompt.test.ts | 116 +++++++++++++++++-------------------- 1 file changed, 54 insertions(+), 62 deletions(-) diff --git a/test/cli-ux/prompt.test.ts b/test/cli-ux/prompt.test.ts index 7f34e83d8..387be7dca 100644 --- a/test/cli-ux/prompt.test.ts +++ b/test/cli-ux/prompt.test.ts @@ -1,73 +1,65 @@ -import * as chai from 'chai' - -const {expect} = chai +import {expect} from 'chai' +import readline from 'node:readline' +import {SinonSandbox, createSandbox} from 'sinon' import {ux} from '../../src/cli-ux' -import {fancy} from './fancy' describe('prompt', () => { - fancy - .stdout() - .stderr() - .end('requires input', async () => { - const promptPromise = ux.prompt('Require input?') - process.stdin.emit('data', '') - process.stdin.emit('data', 'answer') - const answer = await promptPromise - await ux.done() - expect(answer).to.equal('answer') - }) + let sandbox: SinonSandbox - fancy - .stdout() - .stderr() - .stdin('y') - .end('confirm', async () => { - const promptPromise = ux.confirm('yes/no?') - const answer = await promptPromise - await ux.done() - expect(answer).to.equal(true) + function stubReadline(answers: string[]) { + let callCount = 0 + sandbox.stub(readline, 'createInterface').returns({ + // @ts-expect-error because we're stubbing + async question(_message, opts, cb) { + callCount += 1 + cb(answers[callCount - 1]) + }, + close() {}, }) + } - fancy - .stdout() - .stderr() - .stdin('n') - .end('confirm', async () => { - const promptPromise = ux.confirm('yes/no?') - const answer = await promptPromise - await ux.done() - expect(answer).to.equal(false) - }) + beforeEach(() => { + sandbox = createSandbox() + }) - fancy - .stdout() - .stderr() - .stdin('x') - .end('gets anykey', async () => { - const promptPromise = ux.anykey() - const answer = await promptPromise - await ux.done() - expect(answer).to.equal('x') - }) + afterEach(() => { + sandbox.restore() + }) - fancy - .stdout() - .stderr() - .end('does not require input', async () => { - const promptPromise = ux.prompt('Require input?', { - required: false, - }) - process.stdin.emit('data', '') - const answer = await promptPromise - await ux.done() - expect(answer).to.equal('') - }) + it('should require input', async () => { + stubReadline(['', '', 'answer']) + const answer = await ux.prompt('Require input?') + expect(answer).to.equal('answer') + }) - fancy - .stdout() - .stderr() - .it('timeouts with no input', async () => { - await expect(ux.prompt('Require input?', {timeout: 1})).to.eventually.be.rejectedWith('Prompt timeout') - }) + it('should not require input', async () => { + stubReadline(['']) + const answer = await ux.prompt('Require input?', {required: false}) + expect(answer).to.equal('') + }) + + it('should use default input', async () => { + stubReadline(['']) + const answer = await ux.prompt('Require input?', {default: 'default'}) + expect(answer).to.equal('default') + }) + + it('should confirm with y', async () => { + stubReadline(['y']) + const answer = await ux.confirm('yes/no?') + expect(answer).to.equal(true) + }) + + it('should confirm with n', async () => { + stubReadline(['n']) + const answer = await ux.confirm('yes/no?') + expect(answer).to.equal(false) + }) + + it('should get anykey', async () => { + stubReadline(['x']) + const answer = await ux.anykey() + expect(answer).to.equal('x') + }) })