From 25328ea0bb61decdff01ccfdeec0bab46180864a Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Tue, 27 Apr 2021 16:14:39 +0200 Subject: [PATCH] readline: introduce promise-based API --- doc/api/readline.md | 450 ++++++- lib/internal/readline/promises.js | 103 ++ lib/readline.js | 4 +- lib/readline/promises.js | 57 + node.gyp | 2 + test/parallel/test-readline-promises-csi.js | 137 +++ .../test-readline-promises-interface.js | 1076 +++++++++++++++++ .../test-readline-promises-tab-complete.js | 116 ++ tools/doc/type-parser.js | 6 +- 9 files changed, 1903 insertions(+), 48 deletions(-) create mode 100644 lib/internal/readline/promises.js create mode 100644 lib/readline/promises.js create mode 100644 test/parallel/test-readline-promises-csi.js create mode 100644 test/parallel/test-readline-promises-interface.js create mode 100644 test/parallel/test-readline-promises-tab-complete.js diff --git a/doc/api/readline.md b/doc/api/readline.md index 58c7209b7619f1..48a1c7dd18c713 100644 --- a/doc/api/readline.md +++ b/doc/api/readline.md @@ -7,16 +7,51 @@ The `readline` module provides an interface for reading data from a [Readable][] -stream (such as [`process.stdin`][]) one line at a time. It can be accessed -using: +stream (such as [`process.stdin`][]) one line at a time. -```js +To use the promise-based APIs: + +```mjs +// Using ESM Module syntax: +import * as readline from 'readline/promises'; +``` + +```cjs +// Using CommonJS syntax: +const readline = require('readline/promises'); +``` + +To use the callback and sync APIs: + +```mjs +// Using ESM Module syntax: +import * as readline from 'readline'; +``` + +```cjs +// Using CommonJS syntax: const readline = require('readline'); ``` The following simple example illustrates the basic use of the `readline` module. -```js +```mjs +import * as readline from 'readline/promises'; + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout +}); + +const answer = await rl.question('What do you think of Node.js? '); + +// TODO: Log the answer in a database +console.log(`Thank you for your valuable feedback: ${answer}`); + +rl.close(); +``` + +```cjs const readline = require('readline'); const rl = readline.createInterface({ @@ -36,16 +71,18 @@ Once this code is invoked, the Node.js application will not terminate until the `readline.Interface` is closed because the interface waits for data to be received on the `input` stream. -## Class: `Interface` + +## Class: `InterfaceConstructor` * Extends: {EventEmitter} -Instances of the `readline.Interface` class are constructed using the -`readline.createInterface()` method. Every instance is associated with a -single `input` [Readable][] stream and a single `output` [Writable][] stream. +Instances of the `InterfaceConstructor` class are constructed using the +`readlinePromises.createInterface()` or `readline.createInterface()` method. +Every instance is associated with a single `input` [Readable][] stream and a +single `output` [Writable][] stream. The `output` stream is used to print prompts for user input that arrives on, and is read from, the `input` stream. @@ -56,18 +93,18 @@ added: v0.1.98 The `'close'` event is emitted when one of the following occur: -* The `rl.close()` method is called and the `readline.Interface` instance has +* The `rl.close()` method is called and the `InterfaceConstructor` instance has relinquished control over the `input` and `output` streams; * The `input` stream receives its `'end'` event; * The `input` stream receives Ctrl+D to signal end-of-transmission (EOT); * The `input` stream receives Ctrl+C to signal `SIGINT` and there is no `'SIGINT'` event listener registered on the - `readline.Interface` instance. + `InterfaceConstructor` instance. The listener function is called without passing any arguments. -The `readline.Interface` instance is finished once the `'close'` event is +The `InterfaceConstructor` instance is finished once the `'close'` event is emitted. ### Event: `'line'` @@ -220,12 +257,12 @@ The `'SIGTSTP'` event is _not_ supported on Windows. added: v0.1.98 --> -The `rl.close()` method closes the `readline.Interface` instance and +The `rl.close()` method closes the `InterfaceConstructor` instance and relinquishes control over the `input` and `output` streams. When called, the `'close'` event will be emitted. Calling `rl.close()` does not immediately stop other events (including `'line'`) -from being emitted by the `readline.Interface` instance. +from being emitted by the `InterfaceConstructor` instance. ### `rl.pause()` + +### Class: `readlinePromises.Interface` + + +* Extends: {readline.InterfaceConstructor} + +Instances of the `readlinePromises.Interface` class are constructed using the +`readlinePromises.createInterface()` method. Every instance is associated with a +single `input` [Readable][] stream and a single `output` [Writable][] stream. +The `output` stream is used to print prompts for user input that arrives on, +and is read from, the `input` stream. + +#### `rl.question(query[, options])` + + +* `query` {string} A statement or query to write to `output`, prepended to the + prompt. +* `options` {Object} + * `signal` {AbortSignal} Optionally allows the `question()` to be canceled + using an `AbortController`. +* Returns: {Promise} A promise that is resolved with the user's + input in response to the `query`. + +The `rl.question()` method displays the `query` by writing it to the `output`, +waits for user input to be provided on `input`, then invokes the `callback` +function passing the provided input as the first argument. + +When called, `rl.question()` will resume the `input` stream if it has been +paused. + +If the `readlinePromises.Interface` was created with `output` set to `null` or +`undefined` the `query` is not written. + +Example usage: + +```mjs +const answer = await rl.question('What is your favorite food? '); +console.log(`Oh, so your favorite food is ${answer}`); +``` + +Using an `AbortController` to cancel a question. + +```mjs +const ac = new AbortController(); +const signal = ac.signal; + +const answer = await rl.question('What is your favorite food? ', { signal }); +console.log(`Oh, so your favorite food is ${answer}`); + +signal.addEventListener('abort', () => { + console.log('The food question timed out'); +}, { once: true }); + +setTimeout(() => ac.abort(), 10000); +``` + +### `readlinePromises.clearLine(stream, dir)` + + +* `stream` {stream.Writable} +* `dir` {number} + * `-1`: to the left from cursor + * `1`: to the right from cursor + * `0`: the entire line +* Returns: {Promise} `false` if `stream` wishes for the calling code to wait for + the `'drain'` event to be emitted before continuing to write additional data; + otherwise `true`. + +The `readlinePromises.clearLine()` method clears current line of given [TTY][] +stream in a specified direction identified by `dir`. + +### `readlinePromises.clearScreenDown(stream)` + + +* `stream` {stream.Writable} +* `callback` {Function} Invoked once the operation completes. +* Returns: {Promise} `false` if `stream` wishes for the calling code to wait for + the `'drain'` event to be emitted before continuing to write additional data; + otherwise `true`. + +The `readlinePromises.clearScreenDown()` method clears the given [TTY][] stream +from the current position of the cursor down. + +### `readlinePromises.createInterface(options)` + + +* `options` {Object} + * `input` {stream.Readable} The [Readable][] stream to listen to. This option + is *required*. + * `output` {stream.Writable} The [Writable][] stream to write readline data + to. + * `completer` {Function} An optional function used for Tab autocompletion. + * `terminal` {boolean} `true` if the `input` and `output` streams should be + treated like a TTY, and have ANSI/VT100 escape codes written to it. + **Default:** checking `isTTY` on the `output` stream upon instantiation. + * `history` {string[]} Initial list of history lines. This option makes sense + only if `terminal` is set to `true` by the user or by an internal `output` + check, otherwise the history caching mechanism is not initialized at all. + **Default:** `[]`. + * `historySize` {number} Maximum number of history lines retained. To disable + the history set this value to `0`. This option makes sense only if + `terminal` is set to `true` by the user or by an internal `output` check, + otherwise the history caching mechanism is not initialized at all. + **Default:** `30`. + * `removeHistoryDuplicates` {boolean} If `true`, when a new input line added + to the history list duplicates an older one, this removes the older line + from the list. **Default:** `false`. + * `prompt` {string} The prompt string to use. **Default:** `'> '`. + * `crlfDelay` {number} If the delay between `\r` and `\n` exceeds + `crlfDelay` milliseconds, both `\r` and `\n` will be treated as separate + end-of-line input. `crlfDelay` will be coerced to a number no less than + `100`. It can be set to `Infinity`, in which case `\r` followed by `\n` + will always be considered a single newline (which may be reasonable for + [reading files][] with `\r\n` line delimiter). **Default:** `100`. + * `escapeCodeTimeout` {number} The duration `readlinePromises` will wait for a + character (when reading an ambiguous key sequence in milliseconds one that + can both form a complete key sequence using the input read so far and can + take additional input to complete a longer key sequence). + **Default:** `500`. + * `tabSize` {integer} The number of spaces a tab is equal to (minimum 1). + **Default:** `8`. +* Returns: {readlinePromises.Interface} + +The `readlinePromises.createInterface()` method creates a new `readlinePromises.Interface` +instance. + +```js +const readlinePromises = require('readlinePromises'); +const rl = readlinePromises.createInterface({ + input: process.stdin, + output: process.stdout +}); +``` + +Once the `readlinePromises.Interface` instance is created, the most common case +is to listen for the `'line'` event: + +```js +rl.on('line', (line) => { + console.log(`Received: ${line}`); +}); +``` + +If `terminal` is `true` for this instance then the `output` stream will get +the best compatibility if it defines an `output.columns` property and emits +a `'resize'` event on the `output` if or when the columns ever change +([`process.stdout`][] does this automatically when it is a TTY). + +#### Use of the `completer` function + +The `completer` function takes the current line entered by the user +as an argument, and returns an `Array` with 2 entries: + +* An `Array` with matching entries for the completion. +* The substring that was used for the matching. + +For instance: `[[substr1, substr2, ...], originalsubstring]`. + +```js +function completer(line) { + const completions = '.help .error .exit .quit .q'.split(' '); + const hits = completions.filter((c) => c.startsWith(line)); + // Show all completions if none found + return [hits.length ? hits : completions, line]; +} +``` + +The `completer` function can also returns a {Promise}, or be asynchronous: + +```js +async function completer(linePartial) { + await someAsyncWork(); + return [['123'], linePartial]; +} +``` + +### `readlinePromises.cursorTo(stream, x[, y][, callback])` + + +* `stream` {stream.Writable} +* `x` {number} +* `y` {number} +* Returns: {Promise} `false` if `stream` wishes for the calling code to wait for + the `'drain'` event to be emitted before continuing to write additional data; + otherwise `true`. + +The `readlinePromises.cursorTo()` method moves cursor to the specified position +in a given [TTY][] `stream`. + +### `readlinePromises.moveCursor(stream, dx, dy)` + + +* `stream` {stream.Writable} +* `dx` {number} +* `dy` {number} +* Returns: {Promise} `false` if `stream` wishes for the calling code to wait for + the `'drain'` event to be emitted before continuing to write additional data; + otherwise `true`. + +The `readlinePromises.moveCursor()` method moves the cursor *relative* to its +current position in a given [TTY][] `stream`. + +## Callback API + + +### Class: `readline.Interface` + + +* Extends: {readline.InterfaceConstructor} + +Instances of the `readline.Interface` class are constructed using the +`readline.createInterface()` method. Every instance is associated with a +single `input` [Readable][] stream and a single `output` [Writable][] stream. +The `output` stream is used to print prompts for user input that arrives on, +and is read from, the `input` stream. + +#### `rl.question(query[, options], callback)` + + +* `query` {string} A statement or query to write to `output`, prepended to the + prompt. +* `options` {Object} + * `signal` {AbortSignal} Optionally allows the `question()` to be canceled + using an `AbortController`. +* `callback` {Function} A callback function that is invoked with the user's + input in response to the `query`. + +The `rl.question()` method displays the `query` by writing it to the `output`, +waits for user input to be provided on `input`, then invokes the `callback` +function passing the provided input as the first argument. + +When called, `rl.question()` will resume the `input` stream if it has been +paused. + +If the `readline.Interface` was created with `output` set to `null` or +`undefined` the `query` is not written. + +The `callback` function passed to `rl.question()` does not follow the typical +pattern of accepting an `Error` object or `null` as the first argument. +The `callback` is called with the provided answer as the only argument. + +Example usage: + +```js +rl.question('What is your favorite food? ', (answer) => { + console.log(`Oh, so your favorite food is ${answer}`); +}); +``` + +Using an `AbortController` to cancel a question. + +```js +const ac = new AbortController(); +const signal = ac.signal; + +rl.question('What is your favorite food? ', { signal }, (answer) => { + console.log(`Oh, so your favorite food is ${answer}`); +}); + +signal.addEventListener('abort', () => { + console.log('The food question timed out'); +}, { once: true }); + +setTimeout(() => ac.abort(), 10000); +``` + +If this method is invoked as it's util.promisify()ed version, it returns a +Promise that fulfills with the answer. If the question is canceled using +an `AbortController` it will reject with an `AbortError`. + +```js +const util = require('util'); +const question = util.promisify(rl.question).bind(rl); + +async function questionExample() { + try { + const answer = await question('What is you favorite food? '); + console.log(`Oh, so your favorite food is ${answer}`); + } catch (err) { + console.error('Question rejected', err); + } +} +questionExample(); +``` + +### `readline.clearLine(stream, dir[, callback])` + +* `stream` {stream.Writable} +* `dx` {number} +* `dy` {number} +* `callback` {Function} Invoked once the operation completes. +* Returns: {boolean} `false` if `stream` wishes for the calling code to wait for + the `'drain'` event to be emitted before continuing to write additional data; + otherwise `true`. + +The `readline.moveCursor()` method moves the cursor *relative* to its current +position in a given [TTY][] `stream`. + ## `readline.emitKeypressEvents(stream[, interface])` * `stream` {stream.Readable} -* `interface` {readline.Interface} +* `interface` {readline.InterfaceConstructor} The `readline.emitKeypressEvents()` method causes the given [Readable][] stream to begin emitting `'keypress'` events corresponding to received input. @@ -718,26 +1096,6 @@ if (process.stdin.isTTY) process.stdin.setRawMode(true); ``` -## `readline.moveCursor(stream, dx, dy[, callback])` - - -* `stream` {stream.Writable} -* `dx` {number} -* `dy` {number} -* `callback` {Function} Invoked once the operation completes. -* Returns: {boolean} `false` if `stream` wishes for the calling code to wait for - the `'drain'` event to be emitted before continuing to write additional data; - otherwise `true`. - -The `readline.moveCursor()` method moves the cursor *relative* to its current -position in a given [TTY][] `stream`. - ## Example: Tiny CLI The following example illustrates the use of `readline.Interface` class to diff --git a/lib/internal/readline/promises.js b/lib/internal/readline/promises.js new file mode 100644 index 00000000000000..bdc12ef6ec4505 --- /dev/null +++ b/lib/internal/readline/promises.js @@ -0,0 +1,103 @@ +'use strict'; + +const { + NumberIsNaN, + Promise, + PromiseReject, + PromiseResolve, +} = primordials; + +const { + codes: { + ERR_INVALID_ARG_VALUE, + ERR_INVALID_CURSOR_POS, + }, +} = require('internal/errors'); + +const { + CSI, +} = require('internal/readline/utils'); + +const { + kClearToLineBeginning, + kClearToLineEnd, + kClearLine, + kClearScreenDown, +} = CSI; + + +/** + * Moves the cursor to the x and y coordinate on the given stream. + */ +function cursorTo(stream, x, y = undefined) { + if (NumberIsNaN(x)) return PromiseReject(new ERR_INVALID_ARG_VALUE('x', x)); + if (NumberIsNaN(y)) return PromiseReject(new ERR_INVALID_ARG_VALUE('y', y)); + + if (stream == null || (typeof x !== 'number' && typeof y !== 'number')) { + return PromiseResolve(); + } + + if (typeof x !== 'number') return PromiseReject(new ERR_INVALID_CURSOR_POS()); + + const data = typeof y !== 'number' ? CSI`${x + 1}G` : CSI`${y + 1};${x + 1}H`; + return new Promise((done) => stream.write(data, done)); +} + +/** + * Moves the cursor relative to its current location. + */ +function moveCursor(stream, dx, dy) { + if (stream == null || !(dx || dy)) { + return PromiseResolve(); + } + + let data = ''; + + if (dx < 0) { + data += CSI`${-dx}D`; + } else if (dx > 0) { + data += CSI`${dx}C`; + } + + if (dy < 0) { + data += CSI`${-dy}A`; + } else if (dy > 0) { + data += CSI`${dy}B`; + } + + return new Promise((done) => stream.write(data, done)); +} + +/** + * Clears the current line the cursor is on: + * -1 for left of the cursor + * +1 for right of the cursor + * 0 for the entire line + */ +function clearLine(stream, dir) { + if (stream == null) { + return PromiseResolve(); + } + + const type = + dir < 0 ? kClearToLineBeginning : dir > 0 ? kClearToLineEnd : kClearLine; + return new Promise((done) => stream.write(type, done)); +} + +/** + * Clears the screen from the current position of the cursor down. + */ +function clearScreenDown(stream) { + if (stream == null) { + return PromiseResolve(); + } + + return new Promise((done) => stream.write(kClearScreenDown, done)); +} + +module.exports = { + clearLine, + clearScreenDown, + cursorTo, + moveCursor, +}; diff --git a/lib/readline.js b/lib/readline.js index 89d0633b8dcb75..eb65ff6d24fa32 100644 --- a/lib/readline.js +++ b/lib/readline.js @@ -45,6 +45,7 @@ const { moveCursor, } = require('internal/readline/callbacks'); const emitKeypressEvents = require('internal/readline/emitKeypressEvents'); +const promises = require('readline/promises'); const { promisify, @@ -435,5 +436,6 @@ module.exports = { createInterface, cursorTo, emitKeypressEvents, - moveCursor + moveCursor, + promises, }; diff --git a/lib/readline/promises.js b/lib/readline/promises.js new file mode 100644 index 00000000000000..17a4eaad74c34c --- /dev/null +++ b/lib/readline/promises.js @@ -0,0 +1,57 @@ +'use strict'; + +const { + Promise, +} = primordials; + +const { + clearLine, + clearScreenDown, + cursorTo, + moveCursor, +} = require('internal/readline/promises'); + +const { + Interface: _Interface, + kQuestionCancel, +} = require('internal/readline/interface'); + +const { + AbortError, +} = require('internal/errors'); + +class Interface extends _Interface { + // eslint-disable-next-line no-useless-constructor + constructor(input, output, completer, terminal) { + super(input, output, completer, terminal); + } + question(query, options = {}) { + return new Promise((resolve, reject) => { + if (options.signal) { + if (options.signal.aborted) { + return reject(new AbortError()); + } + + options.signal.addEventListener('abort', () => { + this[kQuestionCancel](); + reject(new AbortError()); + }, { once: true }); + } + + super.question(query, resolve); + }); + } +} + +function createInterface(input, output, completer, terminal) { + return new Interface(input, output, completer, terminal); +} + +module.exports = { + Interface, + clearLine, + clearScreenDown, + createInterface, + cursorTo, + moveCursor, +}; diff --git a/node.gyp b/node.gyp index b07142e7b3baa0..ab97e21d19ad06 100644 --- a/node.gyp +++ b/node.gyp @@ -77,6 +77,7 @@ 'lib/punycode.js', 'lib/querystring.js', 'lib/readline.js', + 'lib/readline/promises.js', 'lib/repl.js', 'lib/stream.js', 'lib/stream/promises.js', @@ -221,6 +222,7 @@ 'lib/internal/readline/callbacks.js', 'lib/internal/readline/emitKeypressEvents.js', 'lib/internal/readline/interface.js', + 'lib/internal/readline/promises.js', 'lib/internal/readline/utils.js', 'lib/internal/repl.js', 'lib/internal/repl/await.js', diff --git a/test/parallel/test-readline-promises-csi.js b/test/parallel/test-readline-promises-csi.js new file mode 100644 index 00000000000000..96c3cf7b745e47 --- /dev/null +++ b/test/parallel/test-readline-promises-csi.js @@ -0,0 +1,137 @@ +// Flags: --expose-internals +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const readline = require('readline/promises'); +const { Writable } = require('stream'); +const { CSI } = require('internal/readline/utils'); + +class TestWritable extends Writable { + constructor() { + super(); + this.data = ''; + } + _write(chunk, encoding, callback) { + this.data += chunk.toString(); + callback(); + } +} + +const writable = new TestWritable(); + +readline.clearScreenDown(writable).then(common.mustCall()); +assert.deepStrictEqual(writable.data, CSI.kClearScreenDown); +readline.clearScreenDown(writable).then(common.mustCall()); + +readline.clearScreenDown(writable, null); + +// Verify that clearScreenDown() does not throw on null or undefined stream. +readline.clearScreenDown(null).then(common.mustCall()); +readline.clearScreenDown(undefined).then(common.mustCall()); + +writable.data = ''; +readline.clearLine(writable, -1).then(common.mustCall()); +assert.deepStrictEqual(writable.data, CSI.kClearToLineBeginning); + +writable.data = ''; +readline.clearLine(writable, 1).then(common.mustCall()); +assert.deepStrictEqual(writable.data, CSI.kClearToLineEnd); + +writable.data = ''; +readline.clearLine(writable, 0).then(common.mustCall()); +assert.deepStrictEqual(writable.data, CSI.kClearLine); + +writable.data = ''; +readline.clearLine(writable, -1).then(common.mustCall()); +assert.deepStrictEqual(writable.data, CSI.kClearToLineBeginning); + +readline.clearLine(writable, 0, null).then(common.mustCall()); + +// Verify that clearLine() does not throw on null or undefined stream. +readline.clearLine(null, 0).then(common.mustCall()); +readline.clearLine(undefined, 0).then(common.mustCall()); +readline.clearLine(null, 0).then(common.mustCall()); +readline.clearLine(undefined, 0).then(common.mustCall()); + +// Nothing is written when moveCursor 0, 0 +[ + [0, 0, ''], + [1, 0, '\x1b[1C'], + [-1, 0, '\x1b[1D'], + [0, 1, '\x1b[1B'], + [0, -1, '\x1b[1A'], + [1, 1, '\x1b[1C\x1b[1B'], + [-1, 1, '\x1b[1D\x1b[1B'], + [-1, -1, '\x1b[1D\x1b[1A'], + [1, -1, '\x1b[1C\x1b[1A'], +].forEach((set) => { + writable.data = ''; + readline.moveCursor(writable, set[0], set[1]).then(common.mustCall()); + assert.deepStrictEqual(writable.data, set[2]); + writable.data = ''; + readline.moveCursor(writable, set[0], set[1]).then(common.mustCall()); + assert.deepStrictEqual(writable.data, set[2]); +}); + +readline.moveCursor(writable, 1, 1, null).then(common.mustCall()); + +// Verify that moveCursor() does not reject on null or undefined stream. +readline.moveCursor(null, 1, 1).then(common.mustCall()); +readline.moveCursor(undefined, 1, 1).then(common.mustCall()); +readline.moveCursor(null, 1, 1).then(common.mustCall()); +readline.moveCursor(undefined, 1, 1).then(common.mustCall()); + +// Undefined or null as stream should not throw. +readline.cursorTo(null).then(common.mustCall()); +readline.cursorTo().then(common.mustCall()); +readline.cursorTo(null, 1, 1).then(common.mustCall()); +readline.cursorTo(undefined, 1, 1).then(common.mustCall()); + +writable.data = ''; +readline.cursorTo(writable, 'a').then(common.mustCall()); +assert.strictEqual(writable.data, ''); + +writable.data = ''; +readline.cursorTo(writable, 'a', 'b').then(common.mustCall()); +assert.strictEqual(writable.data, ''); + +writable.data = ''; +assert.rejects( + () => readline.cursorTo(writable, 'a', 1), + { + name: 'TypeError', + code: 'ERR_INVALID_CURSOR_POS', + message: 'Cannot set cursor row without setting its column' + }).then(common.mustCall()); +assert.strictEqual(writable.data, ''); + +writable.data = ''; +readline.cursorTo(writable, 1, 'a').then(common.mustCall()); +assert.strictEqual(writable.data, '\x1b[2G'); + +writable.data = ''; +readline.cursorTo(writable, 1).then(common.mustCall()); +assert.strictEqual(writable.data, '\x1b[2G'); + +writable.data = ''; +readline.cursorTo(writable, 1, 2).then(common.mustCall()); +assert.strictEqual(writable.data, '\x1b[3;2H'); + +writable.data = ''; +readline.cursorTo(writable, 1, 2).then(common.mustCall()); +assert.strictEqual(writable.data, '\x1b[3;2H'); + +writable.data = ''; +readline.cursorTo(writable, 1).then(common.mustCall()); +assert.strictEqual(writable.data, '\x1b[2G'); + +// Verify that cursorTo() rejects if x or y is NaN. +assert.rejects(() => readline.cursorTo(writable, NaN), + { code: 'ERR_INVALID_ARG_VALUE' }).then(common.mustCall()); + +assert.rejects(() => readline.cursorTo(writable, 1, NaN), + { code: 'ERR_INVALID_ARG_VALUE' }).then(common.mustCall()); + +assert.rejects(() => readline.cursorTo(writable, NaN, NaN), + { code: 'ERR_INVALID_ARG_VALUE' }).then(common.mustCall()); diff --git a/test/parallel/test-readline-promises-interface.js b/test/parallel/test-readline-promises-interface.js new file mode 100644 index 00000000000000..79803f99c19450 --- /dev/null +++ b/test/parallel/test-readline-promises-interface.js @@ -0,0 +1,1076 @@ +// Flags: --expose-internals +'use strict'; +const common = require('../common'); +common.skipIfDumbTerminal(); + +const assert = require('assert'); +const readline = require('readline/promises'); +const { + getStringWidth, + stripVTControlCharacters +} = require('internal/util/inspect'); +const EventEmitter = require('events').EventEmitter; +const { Writable, Readable } = require('stream'); + +class FakeInput extends EventEmitter { + resume() {} + pause() {} + write() {} + end() {} +} + +function isWarned(emitter) { + for (const name in emitter) { + const listeners = emitter[name]; + if (listeners.warned) return true; + } + return false; +} + +function getInterface(options) { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + ...options, + }); + return [rli, fi]; +} + +function assertCursorRowsAndCols(rli, rows, cols) { + const cursorPos = rli.getCursorPos(); + assert.strictEqual(cursorPos.rows, rows); + assert.strictEqual(cursorPos.cols, cols); +} + +[ + undefined, + 50, + 0, + 100.5, + 5000, +].forEach((crlfDelay) => { + const [rli] = getInterface({ crlfDelay }); + assert.strictEqual(rli.crlfDelay, Math.max(crlfDelay || 100, 100)); + rli.close(); +}); + +{ + const input = new FakeInput(); + + // Constructor throws if completer is not a function or undefined + assert.throws(() => { + readline.createInterface({ + input, + completer: 'string is not valid' + }); + }, { + name: 'TypeError', + code: 'ERR_INVALID_ARG_VALUE' + }); + + assert.throws(() => { + readline.createInterface({ + input, + completer: '' + }); + }, { + name: 'TypeError', + code: 'ERR_INVALID_ARG_VALUE' + }); + + assert.throws(() => { + readline.createInterface({ + input, + completer: false + }); + }, { + name: 'TypeError', + code: 'ERR_INVALID_ARG_VALUE' + }); + + // Constructor throws if history is not an array + ['not an array', 123, 123n, {}, true, Symbol(), null].forEach((history) => { + assert.throws(() => { + readline.createInterface({ + input, + history, + }); + }, { + name: 'TypeError', + code: 'ERR_INVALID_ARG_TYPE' + }); + }); + + // Constructor throws if historySize is not a positive number + ['not a number', -1, NaN, {}, true, Symbol(), null].forEach((historySize) => { + assert.throws(() => { + readline.createInterface({ + input, + historySize, + }); + }, { + name: 'RangeError', + code: 'ERR_INVALID_ARG_VALUE' + }); + }); + + // Check for invalid tab sizes. + assert.throws( + () => new readline.Interface({ + input, + tabSize: 0 + }), + { + message: 'The value of "tabSize" is out of range. ' + + 'It must be >= 1 && < 4294967296. Received 0', + code: 'ERR_OUT_OF_RANGE' + } + ); + + assert.throws( + () => new readline.Interface({ + input, + tabSize: '4' + }), + { code: 'ERR_INVALID_ARG_TYPE' } + ); + + assert.throws( + () => new readline.Interface({ + input, + tabSize: 4.5 + }), + { + code: 'ERR_OUT_OF_RANGE', + message: 'The value of "tabSize" is out of range. ' + + 'It must be an integer. Received 4.5' + } + ); +} + +// Sending a single character with no newline +{ + const fi = new FakeInput(); + const rli = new readline.Interface(fi, {}); + rli.on('line', common.mustNotCall()); + fi.emit('data', 'a'); + rli.close(); +} + +// Sending multiple newlines at once that does not end with a new line and a +// `end` event(last line is). \r should behave like \n when alone. +{ + const [rli, fi] = getInterface({ terminal: true }); + const expectedLines = ['foo', 'bar', 'baz', 'bat']; + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, expectedLines.shift()); + }, expectedLines.length - 1)); + fi.emit('data', expectedLines.join('\r')); + rli.close(); +} + +// \r at start of input should output blank line +{ + const [rli, fi] = getInterface({ terminal: true }); + const expectedLines = ['', 'foo' ]; + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, expectedLines.shift()); + }, expectedLines.length)); + fi.emit('data', '\rfoo\r'); + rli.close(); +} + +// \t does not become part of the input when there is a completer function +{ + const completer = (line) => [[], line]; + const [rli, fi] = getInterface({ terminal: true, completer }); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'foo'); + })); + for (const character of '\tfo\to\t') { + fi.emit('data', character); + } + fi.emit('data', '\n'); + rli.close(); +} + +// \t when there is no completer function should behave like an ordinary +// character +{ + const [rli, fi] = getInterface({ terminal: true }); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, '\t'); + })); + fi.emit('data', '\t'); + fi.emit('data', '\n'); + rli.close(); +} + +// Adding history lines should emit the history event with +// the history array +{ + const [rli, fi] = getInterface({ terminal: true }); + const expectedLines = ['foo', 'bar', 'baz', 'bat']; + rli.on('history', common.mustCall((history) => { + const expectedHistory = expectedLines.slice(0, history.length).reverse(); + assert.deepStrictEqual(history, expectedHistory); + }, expectedLines.length)); + for (const line of expectedLines) { + fi.emit('data', `${line}\n`); + } + rli.close(); +} + +// Altering the history array in the listener should not alter +// the line being processed +{ + const [rli, fi] = getInterface({ terminal: true }); + const expectedLine = 'foo'; + rli.on('history', common.mustCall((history) => { + assert.strictEqual(history[0], expectedLine); + history.shift(); + })); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, expectedLine); + assert.strictEqual(rli.history.length, 0); + })); + fi.emit('data', `${expectedLine}\n`); + rli.close(); +} + +// Duplicate lines are removed from history when +// `options.removeHistoryDuplicates` is `true` +{ + const [rli, fi] = getInterface({ + terminal: true, + removeHistoryDuplicates: true + }); + const expectedLines = ['foo', 'bar', 'baz', 'bar', 'bat', 'bat']; + // ['foo', 'baz', 'bar', bat']; + let callCount = 0; + rli.on('line', function(line) { + assert.strictEqual(line, expectedLines[callCount]); + callCount++; + }); + fi.emit('data', `${expectedLines.join('\n')}\n`); + assert.strictEqual(callCount, expectedLines.length); + fi.emit('keypress', '.', { name: 'up' }); // 'bat' + assert.strictEqual(rli.line, expectedLines[--callCount]); + fi.emit('keypress', '.', { name: 'up' }); // 'bar' + assert.notStrictEqual(rli.line, expectedLines[--callCount]); + assert.strictEqual(rli.line, expectedLines[--callCount]); + fi.emit('keypress', '.', { name: 'up' }); // 'baz' + assert.strictEqual(rli.line, expectedLines[--callCount]); + fi.emit('keypress', '.', { name: 'up' }); // 'foo' + assert.notStrictEqual(rli.line, expectedLines[--callCount]); + assert.strictEqual(rli.line, expectedLines[--callCount]); + assert.strictEqual(callCount, 0); + fi.emit('keypress', '.', { name: 'down' }); // 'baz' + assert.strictEqual(rli.line, 'baz'); + assert.strictEqual(rli.historyIndex, 2); + fi.emit('keypress', '.', { name: 'n', ctrl: true }); // 'bar' + assert.strictEqual(rli.line, 'bar'); + assert.strictEqual(rli.historyIndex, 1); + fi.emit('keypress', '.', { name: 'n', ctrl: true }); + assert.strictEqual(rli.line, 'bat'); + assert.strictEqual(rli.historyIndex, 0); + // Activate the substring history search. + fi.emit('keypress', '.', { name: 'down' }); // 'bat' + assert.strictEqual(rli.line, 'bat'); + assert.strictEqual(rli.historyIndex, -1); + // Deactivate substring history search. + fi.emit('keypress', '.', { name: 'backspace' }); // 'ba' + assert.strictEqual(rli.historyIndex, -1); + assert.strictEqual(rli.line, 'ba'); + // Activate the substring history search. + fi.emit('keypress', '.', { name: 'down' }); // 'ba' + assert.strictEqual(rli.historyIndex, -1); + assert.strictEqual(rli.line, 'ba'); + fi.emit('keypress', '.', { name: 'down' }); // 'ba' + assert.strictEqual(rli.historyIndex, -1); + assert.strictEqual(rli.line, 'ba'); + fi.emit('keypress', '.', { name: 'up' }); // 'bat' + assert.strictEqual(rli.historyIndex, 0); + assert.strictEqual(rli.line, 'bat'); + fi.emit('keypress', '.', { name: 'up' }); // 'bar' + assert.strictEqual(rli.historyIndex, 1); + assert.strictEqual(rli.line, 'bar'); + fi.emit('keypress', '.', { name: 'up' }); // 'baz' + assert.strictEqual(rli.historyIndex, 2); + assert.strictEqual(rli.line, 'baz'); + fi.emit('keypress', '.', { name: 'up' }); // 'ba' + assert.strictEqual(rli.historyIndex, 4); + assert.strictEqual(rli.line, 'ba'); + fi.emit('keypress', '.', { name: 'up' }); // 'ba' + assert.strictEqual(rli.historyIndex, 4); + assert.strictEqual(rli.line, 'ba'); + // Deactivate substring history search and reset history index. + fi.emit('keypress', '.', { name: 'right' }); // 'ba' + assert.strictEqual(rli.historyIndex, -1); + assert.strictEqual(rli.line, 'ba'); + // Substring history search activated. + fi.emit('keypress', '.', { name: 'up' }); // 'ba' + assert.strictEqual(rli.historyIndex, 0); + assert.strictEqual(rli.line, 'bat'); + rli.close(); +} + +// Duplicate lines are not removed from history when +// `options.removeHistoryDuplicates` is `false` +{ + const [rli, fi] = getInterface({ + terminal: true, + removeHistoryDuplicates: false + }); + const expectedLines = ['foo', 'bar', 'baz', 'bar', 'bat', 'bat']; + let callCount = 0; + rli.on('line', function(line) { + assert.strictEqual(line, expectedLines[callCount]); + callCount++; + }); + fi.emit('data', `${expectedLines.join('\n')}\n`); + assert.strictEqual(callCount, expectedLines.length); + fi.emit('keypress', '.', { name: 'up' }); // 'bat' + assert.strictEqual(rli.line, expectedLines[--callCount]); + fi.emit('keypress', '.', { name: 'up' }); // 'bar' + assert.notStrictEqual(rli.line, expectedLines[--callCount]); + assert.strictEqual(rli.line, expectedLines[--callCount]); + fi.emit('keypress', '.', { name: 'up' }); // 'baz' + assert.strictEqual(rli.line, expectedLines[--callCount]); + fi.emit('keypress', '.', { name: 'up' }); // 'bar' + assert.strictEqual(rli.line, expectedLines[--callCount]); + fi.emit('keypress', '.', { name: 'up' }); // 'foo' + assert.strictEqual(rli.line, expectedLines[--callCount]); + assert.strictEqual(callCount, 0); + rli.close(); +} + +// Regression test for repl freeze, #1968: +// check that nothing fails if 'keypress' event throws. +{ + const [rli, fi] = getInterface({ terminal: true }); + const keys = []; + const err = new Error('bad thing happened'); + fi.on('keypress', function(key) { + keys.push(key); + if (key === 'X') { + throw err; + } + }); + assert.throws( + () => fi.emit('data', 'fooX'), + (e) => { + assert.strictEqual(e, err); + return true; + } + ); + fi.emit('data', 'bar'); + assert.strictEqual(keys.join(''), 'fooXbar'); + rli.close(); +} + +// History is bound +{ + const [rli, fi] = getInterface({ terminal: true, historySize: 2 }); + const lines = ['line 1', 'line 2', 'line 3']; + fi.emit('data', lines.join('\n') + '\n'); + assert.strictEqual(rli.history.length, 2); + assert.strictEqual(rli.history[0], 'line 3'); + assert.strictEqual(rli.history[1], 'line 2'); +} + +// Question +{ + const [rli] = getInterface({ terminal: true }); + const expectedLines = ['foo']; + rli.question(expectedLines[0]).then(() => rli.close()); + assertCursorRowsAndCols(rli, 0, expectedLines[0].length); + rli.close(); +} + +// Sending a multi-line question +{ + const [rli] = getInterface({ terminal: true }); + const expectedLines = ['foo', 'bar']; + rli.question(expectedLines.join('\n')).then(() => rli.close()); + assertCursorRowsAndCols( + rli, expectedLines.length - 1, expectedLines.slice(-1)[0].length); + rli.close(); +} + +{ + // Beginning and end of line + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', 'the quick brown fox'); + fi.emit('keypress', '.', { ctrl: true, name: 'a' }); + assertCursorRowsAndCols(rli, 0, 0); + fi.emit('keypress', '.', { ctrl: true, name: 'e' }); + assertCursorRowsAndCols(rli, 0, 19); + rli.close(); +} + +{ + // Back and Forward one character + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', 'the quick brown fox'); + assertCursorRowsAndCols(rli, 0, 19); + + // Back one character + fi.emit('keypress', '.', { ctrl: true, name: 'b' }); + assertCursorRowsAndCols(rli, 0, 18); + // Back one character + fi.emit('keypress', '.', { ctrl: true, name: 'b' }); + assertCursorRowsAndCols(rli, 0, 17); + // Forward one character + fi.emit('keypress', '.', { ctrl: true, name: 'f' }); + assertCursorRowsAndCols(rli, 0, 18); + // Forward one character + fi.emit('keypress', '.', { ctrl: true, name: 'f' }); + assertCursorRowsAndCols(rli, 0, 19); + rli.close(); +} + +// Back and Forward one astral character +{ + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', '💻'); + + // Move left one character/code point + fi.emit('keypress', '.', { name: 'left' }); + assertCursorRowsAndCols(rli, 0, 0); + + // Move right one character/code point + fi.emit('keypress', '.', { name: 'right' }); + assertCursorRowsAndCols(rli, 0, 2); + + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, '💻'); + })); + fi.emit('data', '\n'); + rli.close(); +} + +// Two astral characters left +{ + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', '💻'); + + // Move left one character/code point + fi.emit('keypress', '.', { name: 'left' }); + assertCursorRowsAndCols(rli, 0, 0); + + fi.emit('data', '🐕'); + assertCursorRowsAndCols(rli, 0, 2); + + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, '🐕💻'); + })); + fi.emit('data', '\n'); + rli.close(); +} + +// Two astral characters right +{ + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', '💻'); + + // Move left one character/code point + fi.emit('keypress', '.', { name: 'right' }); + assertCursorRowsAndCols(rli, 0, 2); + + fi.emit('data', '🐕'); + assertCursorRowsAndCols(rli, 0, 4); + + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, '💻🐕'); + })); + fi.emit('data', '\n'); + rli.close(); +} + +{ + // `wordLeft` and `wordRight` + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', 'the quick brown fox'); + fi.emit('keypress', '.', { ctrl: true, name: 'left' }); + assertCursorRowsAndCols(rli, 0, 16); + fi.emit('keypress', '.', { meta: true, name: 'b' }); + assertCursorRowsAndCols(rli, 0, 10); + fi.emit('keypress', '.', { ctrl: true, name: 'right' }); + assertCursorRowsAndCols(rli, 0, 16); + fi.emit('keypress', '.', { meta: true, name: 'f' }); + assertCursorRowsAndCols(rli, 0, 19); + rli.close(); +} + +// `deleteWordLeft` +[ + { ctrl: true, name: 'w' }, + { ctrl: true, name: 'backspace' }, + { meta: true, name: 'backspace' }, +].forEach((deleteWordLeftKey) => { + let [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', 'the quick brown fox'); + fi.emit('keypress', '.', { ctrl: true, name: 'left' }); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'the quick fox'); + })); + fi.emit('keypress', '.', deleteWordLeftKey); + fi.emit('data', '\n'); + rli.close(); + + // No effect if pressed at beginning of line + [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', 'the quick brown fox'); + fi.emit('keypress', '.', { ctrl: true, name: 'a' }); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'the quick brown fox'); + })); + fi.emit('keypress', '.', deleteWordLeftKey); + fi.emit('data', '\n'); + rli.close(); +}); + +// `deleteWordRight` +[ + { ctrl: true, name: 'delete' }, + { meta: true, name: 'delete' }, + { meta: true, name: 'd' }, +].forEach((deleteWordRightKey) => { + let [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', 'the quick brown fox'); + fi.emit('keypress', '.', { ctrl: true, name: 'left' }); + fi.emit('keypress', '.', { ctrl: true, name: 'left' }); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'the quick fox'); + })); + fi.emit('keypress', '.', deleteWordRightKey); + fi.emit('data', '\n'); + rli.close(); + + // No effect if pressed at end of line + [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', 'the quick brown fox'); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'the quick brown fox'); + })); + fi.emit('keypress', '.', deleteWordRightKey); + fi.emit('data', '\n'); + rli.close(); +}); + +// deleteLeft +{ + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', 'the quick brown fox'); + assertCursorRowsAndCols(rli, 0, 19); + + // Delete left character + fi.emit('keypress', '.', { ctrl: true, name: 'h' }); + assertCursorRowsAndCols(rli, 0, 18); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'the quick brown fo'); + })); + fi.emit('data', '\n'); + rli.close(); +} + +// deleteLeft astral character +{ + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', '💻'); + assertCursorRowsAndCols(rli, 0, 2); + // Delete left character + fi.emit('keypress', '.', { ctrl: true, name: 'h' }); + assertCursorRowsAndCols(rli, 0, 0); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, ''); + })); + fi.emit('data', '\n'); + rli.close(); +} + +// deleteRight +{ + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', 'the quick brown fox'); + + // Go to the start of the line + fi.emit('keypress', '.', { ctrl: true, name: 'a' }); + assertCursorRowsAndCols(rli, 0, 0); + + // Delete right character + fi.emit('keypress', '.', { ctrl: true, name: 'd' }); + assertCursorRowsAndCols(rli, 0, 0); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'he quick brown fox'); + })); + fi.emit('data', '\n'); + rli.close(); +} + +// deleteRight astral character +{ + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', '💻'); + + // Go to the start of the line + fi.emit('keypress', '.', { ctrl: true, name: 'a' }); + assertCursorRowsAndCols(rli, 0, 0); + + // Delete right character + fi.emit('keypress', '.', { ctrl: true, name: 'd' }); + assertCursorRowsAndCols(rli, 0, 0); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, ''); + })); + fi.emit('data', '\n'); + rli.close(); +} + +// deleteLineLeft +{ + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', 'the quick brown fox'); + assertCursorRowsAndCols(rli, 0, 19); + + // Delete from current to start of line + fi.emit('keypress', '.', { ctrl: true, shift: true, name: 'backspace' }); + assertCursorRowsAndCols(rli, 0, 0); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, ''); + })); + fi.emit('data', '\n'); + rli.close(); +} + +// deleteLineRight +{ + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', 'the quick brown fox'); + + // Go to the start of the line + fi.emit('keypress', '.', { ctrl: true, name: 'a' }); + assertCursorRowsAndCols(rli, 0, 0); + + // Delete from current to end of line + fi.emit('keypress', '.', { ctrl: true, shift: true, name: 'delete' }); + assertCursorRowsAndCols(rli, 0, 0); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, ''); + })); + fi.emit('data', '\n'); + rli.close(); +} + +// Close readline interface +{ + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('keypress', '.', { ctrl: true, name: 'c' }); + assert(rli.closed); +} + +// Multi-line input cursor position +{ + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.columns = 10; + fi.emit('data', 'multi-line text'); + assertCursorRowsAndCols(rli, 1, 5); + rli.close(); +} + +// Multi-line input cursor position and long tabs +{ + const [rli, fi] = getInterface({ tabSize: 16, terminal: true, prompt: '' }); + fi.columns = 10; + fi.emit('data', 'multi-line\ttext \t'); + assert.strictEqual(rli.cursor, 17); + assertCursorRowsAndCols(rli, 3, 2); + rli.close(); +} + +// Check for the default tab size. +{ + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', 'the quick\tbrown\tfox'); + assert.strictEqual(rli.cursor, 19); + // The first tab is 7 spaces long, the second one 3 spaces. + assertCursorRowsAndCols(rli, 0, 27); +} + +// Multi-line prompt cursor position +{ + const [rli, fi] = getInterface({ + terminal: true, + prompt: '\nfilledline\nwraping text\n> ' + }); + fi.columns = 10; + fi.emit('data', 't'); + assertCursorRowsAndCols(rli, 4, 3); + rli.close(); +} + +// Clear the whole screen +{ + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + const lines = ['line 1', 'line 2', 'line 3']; + fi.emit('data', lines.join('\n')); + fi.emit('keypress', '.', { ctrl: true, name: 'l' }); + assertCursorRowsAndCols(rli, 0, 6); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'line 3'); + })); + fi.emit('data', '\n'); + rli.close(); +} + +// Wide characters should be treated as two columns. +assert.strictEqual(getStringWidth('a'), 1); +assert.strictEqual(getStringWidth('あ'), 2); +assert.strictEqual(getStringWidth('谢'), 2); +assert.strictEqual(getStringWidth('고'), 2); +assert.strictEqual(getStringWidth(String.fromCodePoint(0x1f251)), 2); +assert.strictEqual(getStringWidth('abcde'), 5); +assert.strictEqual(getStringWidth('古池や'), 6); +assert.strictEqual(getStringWidth('ノード.js'), 9); +assert.strictEqual(getStringWidth('你好'), 4); +assert.strictEqual(getStringWidth('안녕하세요'), 10); +assert.strictEqual(getStringWidth('A\ud83c\ude00BC'), 5); +assert.strictEqual(getStringWidth('👨‍👩‍👦‍👦'), 8); +assert.strictEqual(getStringWidth('🐕𐐷あ💻😀'), 9); +// TODO(BridgeAR): This should have a width of 4. +assert.strictEqual(getStringWidth('⓬⓪'), 2); +assert.strictEqual(getStringWidth('\u0301\u200D\u200E'), 0); + +// Check if vt control chars are stripped +assert.strictEqual(stripVTControlCharacters('\u001b[31m> \u001b[39m'), '> '); +assert.strictEqual( + stripVTControlCharacters('\u001b[31m> \u001b[39m> '), + '> > ' +); +assert.strictEqual(stripVTControlCharacters('\u001b[31m\u001b[39m'), ''); +assert.strictEqual(stripVTControlCharacters('> '), '> '); +assert.strictEqual(getStringWidth('\u001b[31m> \u001b[39m'), 2); +assert.strictEqual(getStringWidth('\u001b[31m> \u001b[39m> '), 4); +assert.strictEqual(getStringWidth('\u001b[31m\u001b[39m'), 0); +assert.strictEqual(getStringWidth('> '), 2); + +// Check EventEmitter memory leak +for (let i = 0; i < 12; i++) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + rl.close(); + assert.strictEqual(isWarned(process.stdin._events), false); + assert.strictEqual(isWarned(process.stdout._events), false); +} + +[true, false].forEach(function(terminal) { + // Disable history + { + const [rli, fi] = getInterface({ terminal, historySize: 0 }); + assert.strictEqual(rli.historySize, 0); + + fi.emit('data', 'asdf\n'); + assert.deepStrictEqual(rli.history, []); + rli.close(); + } + + // Default history size 30 + { + const [rli, fi] = getInterface({ terminal }); + assert.strictEqual(rli.historySize, 30); + + fi.emit('data', 'asdf\n'); + assert.deepStrictEqual(rli.history, terminal ? ['asdf'] : []); + rli.close(); + } + + // Sending a full line + { + const [rli, fi] = getInterface({ terminal }); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'asdf'); + })); + fi.emit('data', 'asdf\n'); + } + + // Sending a blank line + { + const [rli, fi] = getInterface({ terminal }); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, ''); + })); + fi.emit('data', '\n'); + } + + // Sending a single character with no newline and then a newline + { + const [rli, fi] = getInterface({ terminal }); + let called = false; + rli.on('line', (line) => { + called = true; + assert.strictEqual(line, 'a'); + }); + fi.emit('data', 'a'); + assert.ok(!called); + fi.emit('data', '\n'); + assert.ok(called); + rli.close(); + } + + // Sending multiple newlines at once + { + const [rli, fi] = getInterface({ terminal }); + const expectedLines = ['foo', 'bar', 'baz']; + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, expectedLines.shift()); + }, expectedLines.length)); + fi.emit('data', `${expectedLines.join('\n')}\n`); + rli.close(); + } + + // Sending multiple newlines at once that does not end with a new line + { + const [rli, fi] = getInterface({ terminal }); + const expectedLines = ['foo', 'bar', 'baz', 'bat']; + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, expectedLines.shift()); + }, expectedLines.length - 1)); + fi.emit('data', expectedLines.join('\n')); + rli.close(); + } + + // Sending multiple newlines at once that does not end with a new(empty) + // line and a `end` event + { + const [rli, fi] = getInterface({ terminal }); + const expectedLines = ['foo', 'bar', 'baz', '']; + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, expectedLines.shift()); + }, expectedLines.length - 1)); + rli.on('close', common.mustCall()); + fi.emit('data', expectedLines.join('\n')); + fi.emit('end'); + rli.close(); + } + + // Sending a multi-byte utf8 char over multiple writes + { + const buf = Buffer.from('☮', 'utf8'); + const [rli, fi] = getInterface({ terminal }); + let callCount = 0; + rli.on('line', function(line) { + callCount++; + assert.strictEqual(line, buf.toString('utf8')); + }); + for (const i of buf) { + fi.emit('data', Buffer.from([i])); + } + assert.strictEqual(callCount, 0); + fi.emit('data', '\n'); + assert.strictEqual(callCount, 1); + rli.close(); + } + + // Calling readline without `new` + { + const [rli, fi] = getInterface({ terminal }); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'asdf'); + })); + fi.emit('data', 'asdf\n'); + rli.close(); + } + + // Calling the question callback + { + const [rli] = getInterface({ terminal }); + rli.question('foo?').then(common.mustCall((answer) => { + assert.strictEqual(answer, 'bar'); + })); + rli.write('bar\n'); + rli.close(); + } + + // Aborting a question + { + const ac = new AbortController(); + const signal = ac.signal; + const [rli] = getInterface({ terminal }); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'bar'); + })); + assert.rejects(rli.question('hello?', { signal }), { name: 'AbortError' }) + .then(common.mustCall()); + ac.abort(); + rli.write('bar\n'); + rli.close(); + } + + // Can create a new readline Interface with a null output argument + { + const [rli, fi] = getInterface({ output: null, terminal }); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'asdf'); + })); + fi.emit('data', 'asdf\n'); + + rli.setPrompt('ddd> '); + rli.prompt(); + rli.write("really shouldn't be seeing this"); + rli.question('What do you think of node.js? ', function(answer) { + console.log('Thank you for your valuable feedback:', answer); + rli.close(); + }); + } + + // Calling the getPrompt method + { + const expectedPrompts = ['$ ', '> ']; + const [rli] = getInterface({ terminal }); + for (const prompt of expectedPrompts) { + rli.setPrompt(prompt); + assert.strictEqual(rli.getPrompt(), prompt); + } + } + + { + const expected = terminal ? + ['\u001b[1G', '\u001b[0J', '$ ', '\u001b[3G'] : + ['$ ']; + + const output = new Writable({ + write: common.mustCall((chunk, enc, cb) => { + assert.strictEqual(chunk.toString(), expected.shift()); + cb(); + rl.close(); + }, expected.length) + }); + + const rl = readline.createInterface({ + input: new Readable({ read: common.mustCall() }), + output, + prompt: '$ ', + terminal + }); + + rl.prompt(); + + assert.strictEqual(rl.getPrompt(), '$ '); + } + + { + const fi = new FakeInput(); + assert.deepStrictEqual(fi.listeners(terminal ? 'keypress' : 'data'), []); + } + + // Emit two line events when the delay + // between \r and \n exceeds crlfDelay + { + const crlfDelay = 200; + const [rli, fi] = getInterface({ terminal, crlfDelay }); + let callCount = 0; + rli.on('line', function(line) { + callCount++; + }); + fi.emit('data', '\r'); + setTimeout(common.mustCall(() => { + fi.emit('data', '\n'); + assert.strictEqual(callCount, 2); + rli.close(); + }), crlfDelay + 10); + } + + // For the purposes of the following tests, we do not care about the exact + // value of crlfDelay, only that the behaviour conforms to what's expected. + // Setting it to Infinity allows the test to succeed even under extreme + // CPU stress. + const crlfDelay = Infinity; + + // Set crlfDelay to `Infinity` is allowed + { + const delay = 200; + const [rli, fi] = getInterface({ terminal, crlfDelay }); + let callCount = 0; + rli.on('line', function(line) { + callCount++; + }); + fi.emit('data', '\r'); + setTimeout(common.mustCall(() => { + fi.emit('data', '\n'); + assert.strictEqual(callCount, 1); + rli.close(); + }), delay); + } + + // Sending multiple newlines at once that does not end with a new line + // and a `end` event(last line is) + + // \r\n should emit one line event, not two + { + const [rli, fi] = getInterface({ terminal, crlfDelay }); + const expectedLines = ['foo', 'bar', 'baz', 'bat']; + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, expectedLines.shift()); + }, expectedLines.length - 1)); + fi.emit('data', expectedLines.join('\r\n')); + rli.close(); + } + + // \r\n should emit one line event when split across multiple writes. + { + const [rli, fi] = getInterface({ terminal, crlfDelay }); + const expectedLines = ['foo', 'bar', 'baz', 'bat']; + let callCount = 0; + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, expectedLines[callCount]); + callCount++; + }, expectedLines.length)); + expectedLines.forEach((line) => { + fi.emit('data', `${line}\r`); + fi.emit('data', '\n'); + }); + rli.close(); + } + + // Emit one line event when the delay between \r and \n is + // over the default crlfDelay but within the setting value. + { + const delay = 125; + const [rli, fi] = getInterface({ terminal, crlfDelay }); + let callCount = 0; + rli.on('line', () => callCount++); + fi.emit('data', '\r'); + setTimeout(common.mustCall(() => { + fi.emit('data', '\n'); + assert.strictEqual(callCount, 1); + rli.close(); + }), delay); + } +}); + +// Ensure that the _wordLeft method works even for large input +{ + const input = new Readable({ + read() { + this.push('\x1B[1;5D'); // CTRL + Left + this.push(null); + }, + }); + const output = new Writable({ + write: common.mustCall((data, encoding, cb) => { + assert.strictEqual(rl.cursor, rl.line.length - 1); + cb(); + }), + }); + const rl = new readline.createInterface({ + input, + output, + terminal: true, + }); + rl.line = `a${' '.repeat(1e6)}a`; + rl.cursor = rl.line.length; +} diff --git a/test/parallel/test-readline-promises-tab-complete.js b/test/parallel/test-readline-promises-tab-complete.js new file mode 100644 index 00000000000000..45a4be359776b8 --- /dev/null +++ b/test/parallel/test-readline-promises-tab-complete.js @@ -0,0 +1,116 @@ +'use strict'; + +// Flags: --expose-internals + +const common = require('../common'); +const readline = require('readline/promises'); +const assert = require('assert'); +const { EventEmitter } = require('events'); +const { getStringWidth } = require('internal/util/inspect'); + +common.skipIfDumbTerminal(); + +// This test verifies that the tab completion supports unicode and the writes +// are limited to the minimum. +[ + 'あ', + '𐐷', + '🐕', +].forEach((char) => { + [true, false].forEach((lineBreak) => { + [ + (line) => [ + ['First group', '', + `${char}${'a'.repeat(10)}`, + `${char}${'b'.repeat(10)}`, + char.repeat(11), + ], + line, + ], + + async (line) => [ + ['First group', '', + `${char}${'a'.repeat(10)}`, + `${char}${'b'.repeat(10)}`, + char.repeat(11), + ], + line, + ], + ].forEach((completer) => { + + let output = ''; + const width = getStringWidth(char) - 1; + + class FakeInput extends EventEmitter { + columns = ((width + 1) * 10 + (lineBreak ? 0 : 10)) * 3 + + write = common.mustCall((data) => { + output += data; + }, 6) + + resume() {} + pause() {} + end() {} + } + + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + terminal: true, + completer: common.mustCallAtLeast(completer), + }); + + const last = '\r\nFirst group\r\n\r\n' + + `${char}${'a'.repeat(10)}${' '.repeat(2 + width * 10)}` + + `${char}${'b'.repeat(10)}` + + (lineBreak ? '\r\n' : ' '.repeat(2 + width * 10)) + + `${char.repeat(11)}\r\n` + + `\r\n\u001b[1G\u001b[0J> ${char}\u001b[${4 + width}G`; + + const expectations = [char, '', last]; + + rli.on('line', common.mustNotCall()); + for (const character of `${char}\t\t`) { + fi.emit('data', character); + queueMicrotask(() => { + assert.strictEqual(output, expectations.shift()); + output = ''; + }); + } + rli.close(); + }); + }); +}); + +{ + let output = ''; + class FakeInput extends EventEmitter { + columns = 80 + + write = common.mustCall((data) => { + output += data; + }, 1) + + resume() {} + pause() {} + end() {} + } + + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + terminal: true, + completer: + common.mustCallAtLeast(() => Promise.reject(new Error('message'))), + }); + + rli.on('line', common.mustNotCall()); + fi.emit('data', '\t'); + queueMicrotask(() => { + assert.match(output, /^Tab completion error: Error: message/); + output = ''; + }); + rli.close(); +} diff --git a/tools/doc/type-parser.js b/tools/doc/type-parser.js index f2b953358d03ce..0ab71905ecce53 100644 --- a/tools/doc/type-parser.js +++ b/tools/doc/type-parser.js @@ -195,7 +195,11 @@ const customTypesMap = { 'PerformanceObserverEntryList': 'perf_hooks.html#perf_hooks_class_performanceobserverentrylist', - 'readline.Interface': 'readline.html#readline_class_interface', + 'readline.Interface': 'readline.html#readline_class_readline_interface', + 'readlinePromises.Interface': + 'readline.html#readline_class_readlinepromises_interface', + 'readline.InterfaceConstructor': + 'readline.html#readline_class_interfaceconstructor', 'repl.REPLServer': 'repl.html#repl_class_replserver',