diff options
-rw-r--r-- | doc/api/readline.md | 467 | ||||
-rw-r--r-- | lib/internal/readline/promises.js | 131 | ||||
-rw-r--r-- | lib/readline.js | 4 | ||||
-rw-r--r-- | lib/readline/promises.js | 51 | ||||
-rw-r--r-- | test/parallel/test-readline-promises-csi.mjs | 163 | ||||
-rw-r--r-- | test/parallel/test-readline-promises-interface.js | 1076 | ||||
-rw-r--r-- | test/parallel/test-readline-promises-tab-complete.js | 116 | ||||
-rw-r--r-- | tools/doc/type-parser.mjs | 7 |
8 files changed, 1963 insertions, 52 deletions
diff --git a/doc/api/readline.md b/doc/api/readline.md index 8f056c942e3..b886c5b2bb6 100644 --- a/doc/api/readline.md +++ b/doc/api/readline.md @@ -7,22 +7,48 @@ <!-- source_link=lib/readline.js --> 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 +import * as readline from 'node:readline/promises'; +``` + +```cjs +const readline = require('readline/promises'); +``` + +To use the callback and sync APIs: + +```mjs +import * as readline from 'node:readline'; +``` + +```cjs const readline = require('readline'); ``` The following simple example illustrates the basic use of the `readline` module. -```js +```mjs +import * as readline from 'node:readline/promises'; +import { stdin as input, stdout as output } from 'process'; + +const rl = readline.createInterface({ input, output }); + +const answer = await rl.question('What do you think of Node.js? '); + +console.log(`Thank you for your valuable feedback: ${answer}`); + +rl.close(); +``` + +```cjs const readline = require('readline'); +const { stdin: input, stdout: output } = require('process'); -const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout -}); +const rl = readline.createInterface({ input, output }); rl.question('What do you think of Node.js? ', (answer) => { // TODO: Log the answer in a database @@ -36,16 +62,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` +<a id='readline_class_interface'></a> +## Class: `InterfaceConstructor` <!-- YAML added: v0.1.104 --> * 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 +84,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 <kbd>Ctrl</kbd>+<kbd>D</kbd> to signal end-of-transmission (EOT); * The `input` stream receives <kbd>Ctrl</kbd>+<kbd>C</kbd> 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 +248,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()` <!-- YAML @@ -236,7 +264,7 @@ The `rl.pause()` method pauses the `input` stream, allowing it to be resumed later if necessary. Calling `rl.pause()` does not immediately pause other events (including -`'line'`) from being emitted by the `readline.Interface` instance. +`'line'`) from being emitted by the `InterfaceConstructor` instance. ### `rl.prompt([preserveCursor])` <!-- YAML @@ -246,14 +274,14 @@ added: v0.1.98 * `preserveCursor` {boolean} If `true`, prevents the cursor placement from being reset to `0`. -The `rl.prompt()` method writes the `readline.Interface` instances configured +The `rl.prompt()` method writes the `InterfaceConstructor` instances configured `prompt` to a new line in `output` in order to provide a user with a new location at which to provide input. When called, `rl.prompt()` will resume the `input` stream if it has been paused. -If the `readline.Interface` was created with `output` set to `null` or +If the `InterfaceConstructor` was created with `output` set to `null` or `undefined` the prompt is not written. ### `rl.question(query[, options], callback)` @@ -276,7 +304,7 @@ 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 +If the `InterfaceConstructor` 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 @@ -377,7 +405,7 @@ If `key` is specified, `data` is ignored. When called, `rl.write()` will resume the `input` stream if it has been paused. -If the `readline.Interface` was created with `output` set to `null` or +If the `InterfaceConstructor` was created with `output` set to `null` or `undefined` the `data` and `key` are not written. ```js @@ -406,13 +434,13 @@ changes: Create an `AsyncIterator` object that iterates through each line in the input stream as a string. This method allows asynchronous iteration of -`readline.Interface` objects through `for await...of` loops. +`InterfaceConstructor` objects through `for await...of` loops. Errors in the input stream are not forwarded. If the loop is terminated with `break`, `throw`, or `return`, [`rl.close()`][] will be called. In other words, iterating over a -`readline.Interface` will always consume the input stream fully. +`InterfaceConstructor` will always consume the input stream fully. Performance is not on par with the traditional `'line'` event API. Use `'line'` instead for performance-sensitive applications. @@ -502,7 +530,346 @@ Returns the real position of the cursor in relation to the input prompt + string. Long input (wrapping) strings, as well as multiple line prompts are included in the calculations. -## `readline.clearLine(stream, dir[, callback])` +## Promises API +<!-- YAML +added: REPLACEME +--> + +### Class: `readlinePromises.Interface` +<!-- YAML +added: REPLACEME +--> + +* 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])` +<!-- YAML +added: REPLACEME +--> + +* `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 fulfilled 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); +``` + +### Class: `readlinePromises.Readline` +<!-- YAML +added: REPLACEME +--> + +#### `new readlinePromises.Readline(stream)` +<!-- YAML +added: REPLACEME +--> + +* `stream` {stream.Writable} A [TTY][] stream. + +#### `rl.clearLine(dir)` +<!-- YAML +added: REPLACEME +--> + +* `dir` {integer} + * `-1`: to the left from cursor + * `1`: to the right from cursor + * `0`: the entire line +* Returns: this + +The `rl.clearLine()` method adds to the internal list of pending action an +action that clears current line of the associated `stream` in a specified +direction identified by `dir`. +You need to call `rl.commit()` to see the effect of this method. + +#### `rl.clearScreenDown()` +<!-- YAML +added: REPLACEME +--> + +* Returns: this + +The `rl.clearScreenDown()` method adds to the internal list of pending action an +action that clears the associated stream from the current position of the +cursor down. +You need to call `rl.commit()` to see the effect of this method. + +#### `rl.commit()` +<!-- YAML +added: REPLACEME +--> + +* Returns: {Promise} + +The `rl.commit()` method sends all the pending actions to the associated +`stream` and clears the internal list of pending actions. + +#### `rl.cursorTo(x[, y])` +<!-- YAML +added: REPLACEME +--> + +* `x` {integer} +* `y` {integer} +* Returns: this + +The `rl.cursorTo()` method adds to the internal list of pending action an action +that moves cursor to the specified position in the associated `stream`. +You need to call `rl.commit()` to see the effect of this method. + +#### `rl.moveCursor(dx, dy)` +<!-- YAML +added: REPLACEME +--> + +* `dx` {integer} +* `dy` {integer} +* Returns: this + +The `rl.moveCursor()` method adds to the internal list of pending action an +action that moves the cursor *relative* to its current position in the +associated `stream`. +You need to call `rl.commit()` to see the effect of this method. + +#### `rl.rollback()` +<!-- YAML +added: REPLACEME +--> + +* Returns: this + +The `rl.rollback` methods clears the internal list of pending actions without +sending it to the associated `stream`. + +### `readlinePromises.createInterface(options)` +<!-- YAML +added: REPLACEME +--> + +* `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('readline/promises'); +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]; +} +``` + +## Callback API +<!-- YAML +added: v0.1.104 +--> + +### Class: `readline.Interface` +<!-- YAML +added: v0.1.104 +changes: + - version: REPLACEME + pr-url: https://github.com/nodejs/node/pull/37947 + description: The class `readline.Interface` now inherits from `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)` +<!-- YAML +added: v0.3.3 +--> + +* `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])` <!-- YAML added: v0.7.7 changes: @@ -524,7 +891,7 @@ changes: The `readline.clearLine()` method clears current line of given [TTY][] stream in a specified direction identified by `dir`. -## `readline.clearScreenDown(stream[, callback])` +### `readline.clearScreenDown(stream[, callback])` <!-- YAML added: v0.7.7 changes: @@ -542,7 +909,7 @@ changes: The `readline.clearScreenDown()` method clears the given [TTY][] stream from the current position of the cursor down. -## `readline.createInterface(options)` +### `readline.createInterface(options)` <!-- YAML added: v0.1.98 changes: @@ -646,7 +1013,7 @@ If you want your application to exit without waiting for user input, you can process.stdin.unref(); ``` -### Use of the `completer` function +#### 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: @@ -674,7 +1041,7 @@ function completer(linePartial, callback) { } ``` -## `readline.cursorTo(stream, x[, y][, callback])` +### `readline.cursorTo(stream, x[, y][, callback])` <!-- YAML added: v0.7.7 changes: @@ -694,13 +1061,33 @@ changes: The `readline.cursorTo()` method moves cursor to the specified position in a given [TTY][] `stream`. +### `readline.moveCursor(stream, dx, dy[, callback])` +<!-- YAML +added: v0.7.7 +changes: + - version: v12.7.0 + pr-url: https://github.com/nodejs/node/pull/28674 + description: The stream's write() callback and return value are exposed. +--> + +* `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])` <!-- YAML added: v0.7.7 --> * `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. @@ -720,26 +1107,6 @@ if (process.stdin.isTTY) process.stdin.setRawMode(true); ``` -## `readline.moveCursor(stream, dx, dy[, callback])` -<!-- YAML -added: v0.7.7 -changes: - - version: v12.7.0 - pr-url: https://github.com/nodejs/node/pull/28674 - description: The stream's write() callback and return value are exposed. ---> - -* `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 00000000000..3e20085b281 --- /dev/null +++ b/lib/internal/readline/promises.js @@ -0,0 +1,131 @@ +'use strict'; + +const { + ArrayPrototypeJoin, + ArrayPrototypePush, + Promise, +} = primordials; + +const { CSI } = require('internal/readline/utils'); +const { validateInteger } = require('internal/validators'); +const { isWritable } = require('internal/streams/utils'); +const { codes: { ERR_INVALID_ARG_TYPE } } = require('internal/errors'); + +const { + kClearToLineBeginning, + kClearToLineEnd, + kClearLine, + kClearScreenDown, +} = CSI; + +class Readline { + #stream; + #todo = []; + + constructor(stream) { + if (!isWritable(stream)) + throw new ERR_INVALID_ARG_TYPE('stream', 'Writable', stream); + this.#stream = stream; + } + + /** + * Moves the cursor to the x and y coordinate on the given stream. + * @param {integer} x + * @param {integer} [y] + * @returns {Readline} this + */ + cursorTo(x, y = undefined) { + validateInteger(x, 'x'); + if (y != null) validateInteger(y, 'y'); + + ArrayPrototypePush( + this.#todo, + y == null ? CSI`${x + 1}G` : CSI`${y + 1};${x + 1}H` + ); + + return this; + } + + /** + * Moves the cursor relative to its current location. + * @param {integer} dx + * @param {integer} dy + * @returns {Readline} this + */ + moveCursor(dx, dy) { + if (dx || dy) { + validateInteger(dx, 'dx'); + validateInteger(dy, 'dy'); + + 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`; + } + ArrayPrototypePush(this.#todo, data); + } + return this; + } + + /** + * Clears the current line the cursor is on. + * @param {-1|0|1} dir Direction to clear: + * -1 for left of the cursor + * +1 for right of the cursor + * 0 for the entire line + * @returns {Readline} this + */ + clearLine(dir) { + validateInteger(dir, 'dir', -1, 1); + + ArrayPrototypePush( + this.#todo, + dir < 0 ? kClearToLineBeginning : dir > 0 ? kClearToLineEnd : kClearLine + ); + return this; + } + + /** + * Clears the screen from the current position of the cursor down. + * @returns {Readline} this + */ + clearScreenDown() { + ArrayPrototypePush(this.#todo, kClearScreenDown); + return this; + } + + /** + * Sends all the pending actions to the associated `stream` and clears the + * internal list of pending actions. + * @returns {Promise<void>} Resolves when all pending actions have been + * flushed to the associated `stream`. + */ + commit() { + return new Promise((resolve) => { + this.#stream.write(ArrayPrototypeJoin(this.#todo, ''), resolve); + this.#todo = []; + }); + } + + /** + * Clears the internal list of pending actions without sending it to the + * associated `stream`. + * @returns {Readline} this + */ + rollback() { + this.#todo = []; + return this; + } +} + +module.exports = { + Readline, +}; diff --git a/lib/readline.js b/lib/readline.js index 0b17039ff17..1d9e839f2c2 100644 --- a/lib/readline.js +++ b/lib/readline.js @@ -39,6 +39,7 @@ const { moveCursor, } = require('internal/readline/callbacks'); const emitKeypressEvents = require('internal/readline/emitKeypressEvents'); +const promises = require('readline/promises'); const { AbortError, @@ -462,5 +463,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 00000000000..90658e5a5e9 --- /dev/null +++ b/lib/readline/promises.js @@ -0,0 +1,51 @@ +'use strict'; + +const { + Promise, +} = primordials; + +const { + Readline, +} = 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, + Readline, + createInterface, +}; diff --git a/test/parallel/test-readline-promises-csi.mjs b/test/parallel/test-readline-promises-csi.mjs new file mode 100644 index 00000000000..1ba105fc198 --- /dev/null +++ b/test/parallel/test-readline-promises-csi.mjs @@ -0,0 +1,163 @@ +// Flags: --expose-internals + + +import '../common/index.mjs'; +import assert from 'assert'; +import { Readline } from 'readline/promises'; +import { Writable } from 'stream'; + +import utils from 'internal/readline/utils'; +const { CSI } = utils; + +const INVALID_ARG = { + name: 'TypeError', + code: 'ERR_INVALID_ARG_TYPE', +}; + +class TestWritable extends Writable { + data = ''; + _write(chunk, encoding, callback) { + this.data += chunk.toString(); + callback(); + } +} + +[ + undefined, null, + 0, 1, 1n, 1.1, NaN, Infinity, + true, false, + Symbol(), + '', '1', + [], {}, () => {}, +].forEach((arg) => + assert.throws(() => new Readline(arg), INVALID_ARG) +); + +{ + const writable = new TestWritable(); + const readline = new Readline(writable); + + await readline.clearScreenDown().commit(); + assert.deepStrictEqual(writable.data, CSI.kClearScreenDown); + await readline.clearScreenDown().commit(); + + writable.data = ''; + await readline.clearScreenDown().rollback(); + assert.deepStrictEqual(writable.data, ''); + + writable.data = ''; + await readline.clearLine(-1).commit(); + assert.deepStrictEqual(writable.data, CSI.kClearToLineBeginning); + + writable.data = ''; + await readline.clearLine(1).commit(); + assert.deepStrictEqual(writable.data, CSI.kClearToLineEnd); + + writable.data = ''; + await readline.clearLine(0).commit(); + assert.deepStrictEqual(writable.data, CSI.kClearLine); + + writable.data = ''; + await readline.clearLine(-1).commit(); + assert.deepStrictEqual(writable.data, CSI.kClearToLineBeginning); + + await readline.clearLine(0, null).commit(); + + // Nothing is written when moveCursor 0, 0 + for (const set of + [ + [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'], + ]) { + writable.data = ''; + await readline.moveCursor(set[0], set[1]).commit(); + assert.deepStrictEqual(writable.data, set[2]); + writable.data = ''; + await readline.moveCursor(set[0], set[1]).commit(); + assert.deepStrictEqual(writable.data, set[2]); + } + + + await readline.moveCursor(1, 1, null).commit(); + + writable.data = ''; + [ + undefined, null, + true, false, + Symbol(), + '', '1', + [], {}, () => {}, + ].forEach((arg) => + assert.throws(() => readline.cursorTo(arg), INVALID_ARG) + ); + assert.strictEqual(writable.data, ''); + + writable.data = ''; + assert.throws(() => readline.cursorTo('a', 'b'), INVALID_ARG); + assert.strictEqual(writable.data, ''); + + writable.data = ''; + assert.throws(() => readline.cursorTo('a', 1), INVALID_ARG); + assert.strictEqual(writable.data, ''); + + writable.data = ''; + assert.throws(() => readline.cursorTo(1, 'a'), INVALID_ARG); + assert.strictEqual(writable.data, ''); + + writable.data = ''; + await readline.cursorTo(1).commit(); + assert.strictEqual(writable.data, '\x1b[2G'); + + writable.data = ''; + await readline.cursorTo(1, 2).commit(); + assert.strictEqual(writable.data, '\x1b[3;2H'); + + writable.data = ''; + await readline.cursorTo(1, 2).commit(); + assert.strictEqual(writable.data, '\x1b[3;2H'); + + writable.data = ''; + await readline.cursorTo(1).cursorTo(1, 2).commit(); + assert.strictEqual(writable.data, '\x1b[2G\x1b[3;2H'); + + writable.data = ''; + await readline.cursorTo(1).commit(); + assert.strictEqual(writable.data, '\x1b[2G'); + + // Verify that cursorTo() rejects if x or y is NaN. + [1.1, NaN, Infinity].forEach((arg) => { + assert.throws(() => readline.cursorTo(arg), { + code: 'ERR_OUT_OF_RANGE', + name: 'RangeError', + }); + }); + + [1.1, NaN, Infinity].forEach((arg) => { + assert.throws(() => readline.cursorTo(1, arg), { + code: 'ERR_OUT_OF_RANGE', + name: 'RangeError', + }); + }); + + assert.throws(() => readline.cursorTo(NaN, NaN), { + code: 'ERR_OUT_OF_RANGE', + name: 'RangeError', + }); +} + +{ + const error = new Error(); + const writable = new class extends Writable { + _write() { throw error; } + }(); + const readline = new Readline(writable); + + await assert.rejects(readline.cursorTo(1).commit(), error); +} diff --git a/test/parallel/test-readline-promises-interface.js b/test/parallel/test-readline-promises-interface.js new file mode 100644 index 00000000000..79803f99c19 --- /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 00000000000..45a4be35977 --- /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.mjs b/tools/doc/type-parser.mjs index bcd95360ee6..3cdee188c01 100644 --- a/tools/doc/type-parser.mjs +++ b/tools/doc/type-parser.mjs @@ -197,7 +197,12 @@ const customTypesMap = { 'PerformanceObserverEntryList': 'perf_hooks.html#class-performanceobserverentrylist', - 'readline.Interface': 'readline.html#class-interface', + 'readline.Interface': + 'readline.html#class-readlineinterface', + 'readline.InterfaceConstructor': + 'readline.html#class-interfaceconstructor', + 'readlinePromises.Interface': + 'readline.html#class-readlinepromisesinterface', 'repl.REPLServer': 'repl.html#class-replserver', |