diff --git a/doc/api/readline.md b/doc/api/readline.md index 8f372a8473e06d..ff4402bed31258 100644 --- a/doc/api/readline.md +++ b/doc/api/readline.md @@ -521,6 +521,478 @@ added: v0.7.7 The `readline.moveCursor()` method moves the cursor *relative* to its current position in a given [TTY][] `stream`. +## Readline Promises API + +> Stability: 1 - Experimental + +The `readline.promises` API provides an alternative set of methods that return +`Promise` objects rather than using callbacks. The API is accessible via +`require('readline').promises`. + +### Class: readline.promises.Interface + + +Instances of the `readline.promises.Interface` class are constructed using the +`readline.promises.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. + +#### Event: 'close' + + +The `'close'` event is emitted when one of the following occur: + +* The `rl.close()` method is called and the `readline.promises.Interface` + instance has relinquished control over the `input` and `output` streams; +* The `input` stream receives its `'end'` event; +* The `input` stream receives `-D` to signal end-of-transmission (EOT); +* The `input` stream receives `-C` to signal `SIGINT` and there is no + `'SIGINT'` event listener registered on the `readline.promises.Interface` + instance. + +The listener function is called without passing any arguments. + +The `readline.promises.Interface` instance is finished once the `'close'` event +is emitted. + +#### Event: 'line' + + +The `'line'` event is emitted whenever the `input` stream receives an +end-of-line input (`\n`, `\r`, or `\r\n`). This usually occurs when the user +presses the ``, or `` keys. + +The listener function is called with a string containing the single line of +received input. + +```js +rl.on('line', (input) => { + console.log(`Received: ${input}`); +}); +``` + +#### Event: 'pause' + + +The `'pause'` event is emitted when one of the following occur: + +* The `input` stream is paused. +* The `input` stream is not paused and receives the `'SIGCONT'` event. (See + events [`'SIGTSTP'`][] and [`'SIGCONT'`][].) + +The listener function is called without passing any arguments. + +```js +rl.on('pause', () => { + console.log('Readline paused.'); +}); +``` + +#### Event: 'resume' + + +The `'resume'` event is emitted whenever the `input` stream is resumed. + +The listener function is called without passing any arguments. + +```js +rl.on('resume', () => { + console.log('Readline resumed.'); +}); +``` + +#### Event: 'SIGCONT' + + +The `'SIGCONT'` event is emitted when a Node.js process previously moved into +the background using `-Z` (i.e. `SIGTSTP`) is then brought back to the +foreground using fg(1p). + +If the `input` stream was paused *before* the `SIGTSTP` request, this event will +not be emitted. + +The listener function is invoked without passing any arguments. + +```js +rl.on('SIGCONT', () => { + // `prompt` will automatically resume the stream + rl.prompt(); +}); +``` + +The `'SIGCONT'` event is _not_ supported on Windows. + +#### Event: 'SIGINT' + + +The `'SIGINT'` event is emitted whenever the `input` stream receives a +`-C` input, known typically as `SIGINT`. If there are no `'SIGINT'` event +listeners registered when the `input` stream receives a `SIGINT`, the `'pause'` +event will be emitted. + +The listener function is invoked without passing any arguments. + +```js +rl.on('SIGINT', async () => { + const answer = await rl.question('Are you sure you want to exit? '); + + if (answer.match(/^y(es)?$/i)) rl.pause(); +}); +``` + +#### Event: 'SIGTSTP' + + +The `'SIGTSTP'` event is emitted when the `input` stream receives a `-Z` +input, typically known as `SIGTSTP`. If there are no `'SIGTSTP'` event listeners +registered when the `input` stream receives a `SIGTSTP`, the Node.js process +will be sent to the background. + +When the program is resumed using fg(1p), the `'pause'` and `'SIGCONT'` events +will be emitted. These can be used to resume the `input` stream. + +The `'pause'` and `'SIGCONT'` events will not be emitted if the `input` was +paused before the process was sent to the background. + +The listener function is invoked without passing any arguments. + +```js +rl.on('SIGTSTP', () => { + // This will override SIGTSTP and prevent the program from going to the + // background. + console.log('Caught SIGTSTP.'); +}); +``` + +The `'SIGTSTP'` event is _not_ supported on Windows. + +#### rl.close() + + +The `rl.close()` method closes the `readline.promises.Interface` 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.promises.Interface` instance. + +#### rl.pause() + + +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.promises.Interface` instance. + +#### rl.prompt([preserveCursor]) + + +* `preserveCursor` {boolean} If `true`, prevents the cursor placement from + being reset to `0`. + +The `rl.prompt()` method writes the `readline.promises.Interface` 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.promises.Interface` was created with `output` set to `null` or +`undefined` the prompt is not written. + +#### rl.question(query) + + +* `query` {string} A statement or query to write to `output`, prepended to the + prompt. +* Returns: {Promise} + +The `rl.question()` method displays the `query` by writing it to the `output`, +waits for user input to be provided on `input`, then resolves the `Promise` with +the provided input. + +When called, `rl.question()` will resume the `input` stream if it has been +paused. + +If the `readline.promises.Interface` was created with `output` set to `null` or +`undefined` the `query` is not written. + +Example usage: + +```js +(async () => { + const answer = await rl.question('What is your favorite food? '); + + console.log(`Oh, so your favorite food is ${answer}`); +})(); +``` + +#### rl.resume() + + +The `rl.resume()` method resumes the `input` stream if it has been paused. + +#### rl.setPrompt(prompt) + + +* `prompt` {string} + +The `rl.setPrompt()` method sets the prompt that will be written to `output` +whenever `rl.prompt()` is called. + +#### rl.write(data[, key]) + + +* `data` {string} +* `key` {Object} + * `ctrl` {boolean} `true` to indicate the `` key. + * `meta` {boolean} `true` to indicate the `` key. + * `shift` {boolean} `true` to indicate the `` key. + * `name` {string} The name of the a key. + +The `rl.write()` method will write either `data` or a key sequence identified +by `key` to the `output`. The `key` argument is supported only if `output` is +a [TTY][] text terminal. + +If `key` is specified, `data` is ignored. + +When called, `rl.write()` will resume the `input` stream if it has been +paused. + +If the `readline.promises.Interface` was created with `output` set to `null` or +`undefined` the `data` and `key` are not written. + +```js +rl.write('Delete this!'); +// Simulate Ctrl+u to delete the line written previously +rl.write(null, { ctrl: true, name: 'u' }); +``` + +The `rl.write()` method will write the data to the +`readline.promises.Interface`'s `input` *as if it were provided by the user*. + +#### rl\[Symbol.asyncIterator\]() + + +* Returns: {AsyncIterator} + +Create an `AsyncIterator` object that iterates through each line in the input +stream as a string. This method allows asynchronous iteration of +`readline.promises.Interface` 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.promises.Interface` 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. + +```js +async function processLineByLine() { + const rl = readline.promises.createInterface({ + // ... + }); + + for await (const line of rl) { + // Each line in the readline input will be successively available here as + // `line`. + } +} +``` + +### readline.promises.clearLine(stream, dir) + + +* `stream` {stream.Writable} +* `dir` {number} + * `-1` - to the left from cursor + * `1` - to the right from cursor + * `0` - the entire line + +The `readline.promises.clearLine()` method clears current line of given [TTY][] +stream in a specified direction identified by `dir`. + +### readline.promises.clearScreenDown(stream) + + +* `stream` {stream.Writable} + +The `readline.promises.clearScreenDown()` method clears the given [TTY][] stream +from the current position of the cursor down. + +### readline.promises.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. + * `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`. + * `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`. + * `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`. + * `escapeCodeTimeout` {number} The duration `readline` 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`. + +The `readline.promises.createInterface()` method creates a new +`readline.promises.Interface` instance. + +```js +const readline = require('readline').promises; +const rl = readline.promises.createInterface({ + input: process.stdin, + output: process.stdout +}); +``` + +Once the `readline.promises.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 a `Promise` that resolves an `Array` with two +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) { + return new Promise((resolve, reject) => { + const completions = '.help .error .exit .quit .q'.split(' '); + const hits = completions.filter((c) => c.startsWith(line)); + // Show all completions if none found. + resolve([hits.length ? hits : completions, line]); + }); +} +``` + +### readline.promises.cursorTo(stream, x, y) + + +* `stream` {stream.Writable} +* `x` {number} +* `y` {number} + +The `readline.promises.cursorTo()` method moves cursor to the specified position +in a given [TTY][] `stream`. + +### readline.promises.emitKeypressEvents(stream[, interface]) + + +* `stream` {stream.Readable} +* `interface` {readline.promises.Interface} + +The `readline.promises.emitKeypressEvents()` method causes the given +[Readable][] stream to begin emitting `'keypress'` events corresponding to +received input. + +Optionally, `interface` specifies a `readline.promises.Interface` instance for +which autocompletion is disabled when copy-pasted input is detected. + +If the `stream` is a [TTY][], then it must be in raw mode. + +This is automatically called by any `readline.promises` instance on its `input` +if the `input` is a terminal. Closing the `readline.promises` instance does not +stop the `input` from emitting `'keypress'` events. + +```js +readline.promises.emitKeypressEvents(process.stdin); +if (process.stdin.isTTY) + process.stdin.setRawMode(true); +``` + +### readline.promises.moveCursor(stream, dx, dy) + + +* `stream` {stream.Writable} +* `dx` {number} +* `dy` {number} + +The `readline.promises.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/interface.js b/lib/internal/readline/interface.js index 907ab4d86a5218..8aa595b3e4df02 100644 --- a/lib/internal/readline/interface.js +++ b/lib/internal/readline/interface.js @@ -30,9 +30,6 @@ function Interface(input, output, completer, terminal) { if (!(this instanceof Interface)) return new Interface(input, output, completer, terminal); - if (StringDecoder === undefined) - StringDecoder = require('string_decoder').StringDecoder; - EventEmitter.call(this); const opts = normalizeInterfaceArguments(input, output, completer, terminal); @@ -47,78 +44,7 @@ function Interface(input, output, completer, terminal) { }; } - this.input = opts.input; - this.output = opts.output; - this.completer = opts.completer; - this.terminal = opts.terminal; - this.historySize = opts.historySize; - this.setPrompt(opts.prompt); - this.crlfDelay = opts.crlfDelay; - this.removeHistoryDuplicates = opts.removeHistoryDuplicates; - this.escapeCodeTimeout = opts.escapeCodeTimeout; - this.isCompletionEnabled = true; - this._sawReturnAt = 0; - this._sawKeyPress = false; - this._previousKey = null; - this[kLineObjectStream] = undefined; - - if (process.env.TERM === 'dumb') - this._ttyWrite = ttyWriteDumb.bind(this); - - input = this.input; - - if (!this.terminal) { - const ondata = onData.bind(this); - const onend = onEnd.bind(this); - - function onInterfaceCloseWithoutTerminal() { - input.removeListener('data', ondata); - input.removeListener('end', onend); - } - - input.on('data', ondata); - input.on('end', onend); - this.once('close', onInterfaceCloseWithoutTerminal); - this._decoder = new StringDecoder('utf8'); - } else { - const onkeypress = onKeyPress.bind(this); - const ontermend = onTermEnd.bind(this); - const output = this.output; - const onresize = output == null ? null : onResize.bind(this); - - function onInterfaceCloseWithTerminal() { - input.removeListener('keypress', onkeypress); - input.removeListener('end', ontermend); - - if (onresize !== null) - output.removeListener('resize', onresize); - } - - emitKeypressEvents(input, this); - - // `input` usually refers to stdin. - input.on('keypress', onkeypress); - input.on('end', ontermend); - - // The current line. - this.line = ''; - - this._setRawMode(true); - this.terminal = true; - - // The cursor position on the line. - this.cursor = 0; - - this.history = []; - this.historyIndex = -1; - - if (onresize !== null) - output.on('resize', onresize); - - this.once('close', onInterfaceCloseWithTerminal); - } - - input.resume(); + initializeInterface(this, opts); } Object.setPrototypeOf(Interface.prototype, EventEmitter.prototype); @@ -1105,4 +1031,90 @@ function normalizeInterfaceArguments(input, output, completer, terminal) { } -module.exports = { createInterface, Interface }; +function initializeInterface(iface, options) { + iface.input = options.input; + iface.output = options.output; + iface.completer = options.completer; + iface.terminal = options.terminal; + iface.historySize = options.historySize; + iface.setPrompt(options.prompt); + iface.crlfDelay = options.crlfDelay; + iface.removeHistoryDuplicates = options.removeHistoryDuplicates; + iface.escapeCodeTimeout = options.escapeCodeTimeout; + iface.isCompletionEnabled = true; + iface._sawReturnAt = 0; + iface._sawKeyPress = false; + iface._previousKey = null; + iface[kLineObjectStream] = undefined; + + if (process.env.TERM === 'dumb') + iface._ttyWrite = ttyWriteDumb.bind(iface); + + const input = iface.input; + + if (!iface.terminal) { + const ondata = onData.bind(iface); + const onend = onEnd.bind(iface); + + function onInterfaceCloseWithoutTerminal() { + input.removeListener('data', ondata); + input.removeListener('end', onend); + } + + input.on('data', ondata); + input.on('end', onend); + iface.once('close', onInterfaceCloseWithoutTerminal); + + if (StringDecoder === undefined) + StringDecoder = require('string_decoder').StringDecoder; + + iface._decoder = new StringDecoder('utf8'); + } else { + const onkeypress = onKeyPress.bind(iface); + const ontermend = onTermEnd.bind(iface); + const output = iface.output; + const onresize = output == null ? null : onResize.bind(iface); + + function onInterfaceCloseWithTerminal() { + input.removeListener('keypress', onkeypress); + input.removeListener('end', ontermend); + + if (onresize !== null) + output.removeListener('resize', onresize); + } + + emitKeypressEvents(input, iface); + + // `input` usually refers to stdin. + input.on('keypress', onkeypress); + input.on('end', ontermend); + + // The current line. + iface.line = ''; + + iface._setRawMode(true); + iface.terminal = true; + + // The cursor position on the line. + iface.cursor = 0; + + iface.history = []; + iface.historyIndex = -1; + + if (onresize !== null) + output.on('resize', onresize); + + iface.once('close', onInterfaceCloseWithTerminal); + } + + input.resume(); +} + + +module.exports = { + createInterface, + initializeInterface, + onTabComplete, + normalizeInterfaceArguments, + Interface +}; diff --git a/lib/internal/readline/promises.js b/lib/internal/readline/promises.js new file mode 100644 index 00000000000000..96041a694ff2ef --- /dev/null +++ b/lib/internal/readline/promises.js @@ -0,0 +1,72 @@ +'use strict'; +const { Object } = primordials; +const EventEmitter = require('events'); +const { + Interface: CallbackInterface, + initializeInterface, + onTabComplete, + normalizeInterfaceArguments +} = require('internal/readline/interface'); + + +class Interface extends EventEmitter { + constructor(input, output, completer, terminal) { + super(); + const opts = normalizeInterfaceArguments( + input, output, completer, terminal); + initializeInterface(this, opts); + } + + question(query) { + let resolve; + const promise = new Promise((res) => { + resolve = res; + }); + + if (!this._questionCallback) { + this._oldPrompt = this._prompt; + this.setPrompt(query); + this._questionCallback = resolve; + } + + this.prompt(); + return promise; + } + + async _tabComplete(lastKeypressWasTab) { + this.pause(); + + try { + const line = this.line.slice(0, this.cursor); + const results = await this.completer(line); + onTabComplete(null, results, this, lastKeypressWasTab); + } catch (err) { + onTabComplete(err, null, this, lastKeypressWasTab); + } + } +} + +// Copy the rest of the callback interface over to this interface. +Object.keys(CallbackInterface.prototype).forEach((keyName) => { + if (Interface.prototype[keyName] === undefined) + Interface.prototype[keyName] = CallbackInterface.prototype[keyName]; +}); + +Object.defineProperty(Interface.prototype, 'columns', { + configurable: true, + enumerable: true, + get() { + return this.output && this.output.columns ? this.output.columns : Infinity; + } +}); + +Interface.prototype[Symbol.asyncIterator] = + CallbackInterface.prototype[Symbol.asyncIterator]; + + +function createInterface(input, output, completer, terminal) { + return new Interface(input, output, completer, terminal); +} + + +module.exports = { createInterface, Interface }; diff --git a/lib/readline.js b/lib/readline.js index a2520bb551afef..3b1cd69c98d01c 100644 --- a/lib/readline.js +++ b/lib/readline.js @@ -27,6 +27,7 @@ 'use strict'; +const { Object } = primordials; const { createInterface, Interface } = require('internal/readline/interface'); const { clearLine, @@ -45,3 +46,34 @@ module.exports = { emitKeypressEvents, moveCursor }; + +let promises; + +Object.defineProperties(module.exports, { + promises: { + configurable: true, + enumerable: false, + get() { + if (promises === undefined) { + const { + Interface, + createInterface + } = require('internal/readline/promises'); + + promises = { + Interface, + clearLine, + clearScreenDown, + createInterface, + cursorTo, + emitKeypressEvents, + moveCursor + }; + + process.emitWarning('The readline.promises API is experimental', + 'ExperimentalWarning'); + } + return promises; + } + } +}); diff --git a/node.gyp b/node.gyp index 735c5f297c67b4..54489ad44e0dd6 100644 --- a/node.gyp +++ b/node.gyp @@ -171,6 +171,7 @@ 'lib/internal/process/task_queues.js', 'lib/internal/querystring.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-interface.js b/test/parallel/test-readline-promises-interface.js new file mode 100644 index 00000000000000..a703ae263a15ef --- /dev/null +++ b/test/parallel/test-readline-promises-interface.js @@ -0,0 +1,1230 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const readline = require('readline').promises; +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; +} + + +{ + // Default crlfDelay is 100ms + const fi = new FakeInput(); + const rli = new readline.Interface({ input: fi, output: fi }); + assert.strictEqual(rli.crlfDelay, 100); + rli.close(); +} + +{ + // Minimum crlfDelay is 100ms + const fi = new FakeInput(); + const rli = new readline.Interface({ input: fi, output: fi, crlfDelay: 0 }); + assert.strictEqual(rli.crlfDelay, 100); + rli.close(); +} + +{ + // Set crlfDelay to float 100.5ms + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + crlfDelay: 100.5 + }); + assert.strictEqual(rli.crlfDelay, 100.5); + rli.close(); +} + +{ + // Set crlfDelay to 5000ms + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + crlfDelay: 5000 + }); + assert.strictEqual(rli.crlfDelay, 5000); + rli.close(); +} + +[ true, false ].forEach(function(terminal) { + // disable history + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: terminal, historySize: 0 } + ); + assert.strictEqual(rli.historySize, 0); + + fi.emit('data', 'asdf\n'); + assert.deepStrictEqual(rli.history, terminal ? [] : undefined); + rli.close(); + } + + // Default history size 30 + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: terminal } + ); + assert.strictEqual(rli.historySize, 30); + + fi.emit('data', 'asdf\n'); + assert.deepStrictEqual(rli.history, terminal ? ['asdf'] : undefined); + rli.close(); + } + + // sending a full line + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: terminal } + ); + let called = false; + rli.on('line', function(line) { + called = true; + assert.strictEqual(line, 'asdf'); + }); + fi.emit('data', 'asdf\n'); + assert.ok(called); + } + + // Sending a blank line + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: terminal } + ); + let called = false; + rli.on('line', function(line) { + called = true; + assert.strictEqual(line, ''); + }); + fi.emit('data', '\n'); + assert.ok(called); + } + + // Sending a single character with no newline + { + const fi = new FakeInput(); + const rli = new readline.Interface(fi, {}); + let called = false; + rli.on('line', function(line) { + called = true; + }); + fi.emit('data', 'a'); + assert.ok(!called); + rli.close(); + } + + // Sending a single character with no newline and then a newline + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: terminal } + ); + let called = false; + rli.on('line', function(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 fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: terminal } + ); + const expectedLines = ['foo', 'bar', 'baz']; + 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); + rli.close(); + } + + // Sending multiple newlines at once that does not end with a new line + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: terminal } + ); + const expectedLines = ['foo', 'bar', 'baz', 'bat']; + let callCount = 0; + rli.on('line', function(line) { + assert.strictEqual(line, expectedLines[callCount]); + callCount++; + }); + fi.emit('data', expectedLines.join('\n')); + assert.strictEqual(callCount, expectedLines.length - 1); + rli.close(); + } + + // Sending multiple newlines at once that does not end with a new(empty) + // line and a `end` event + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: terminal } + ); + const expectedLines = ['foo', 'bar', 'baz', '']; + let callCount = 0; + rli.on('line', function(line) { + assert.strictEqual(line, expectedLines[callCount]); + callCount++; + }); + rli.on('close', function() { + callCount++; + }); + fi.emit('data', expectedLines.join('\n')); + fi.emit('end'); + assert.strictEqual(callCount, expectedLines.length); + 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 fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: true } + ); + const expectedLines = ['foo', 'bar', 'baz', 'bat']; + let callCount = 0; + rli.on('line', function(line) { + assert.strictEqual(line, expectedLines[callCount]); + callCount++; + }); + fi.emit('data', expectedLines.join('\r')); + assert.strictEqual(callCount, expectedLines.length - 1); + rli.close(); + } + + // \r at start of input should output blank line + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: true } + ); + const expectedLines = ['', 'foo' ]; + let callCount = 0; + rli.on('line', function(line) { + assert.strictEqual(line, expectedLines[callCount]); + callCount++; + }); + fi.emit('data', '\rfoo\r'); + assert.strictEqual(callCount, expectedLines.length); + rli.close(); + } + + // Emit two line events when the delay + // between \r and \n exceeds crlfDelay + { + const fi = new FakeInput(); + const delay = 200; + const rli = new readline.Interface({ + input: fi, + output: fi, + terminal: terminal, + crlfDelay: delay + }); + 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(); + }), delay * 2); + } + + // Set crlfDelay to `Infinity` is allowed + { + const fi = new FakeInput(); + const delay = 200; + const crlfDelay = Infinity; + const rli = new readline.Interface({ + input: fi, + output: fi, + terminal: 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); + } + + // \t when there is no completer function should behave like an ordinary + // character + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: true } + ); + let called = false; + rli.on('line', function(line) { + assert.strictEqual(line, '\t'); + assert.strictEqual(called, false); + called = true; + }); + fi.emit('data', '\t'); + fi.emit('data', '\n'); + assert.ok(called); + rli.close(); + } + + // \t does not become part of the input when there is a completer function + { + const fi = new FakeInput(); + const completer = (line) => { + return new Promise(common.mustCall((resolve) => { + resolve([[], line]); + })); + }; + const rli = new readline.Interface({ + input: fi, + output: fi, + terminal: true, + completer + }); + let called = false; + rli.on('line', function(line) { + assert.strictEqual(line, 'foo'); + assert.strictEqual(called, false); + called = true; + }); + for (const character of '\tfo\to\t') { + fi.emit('data', character); + } + fi.emit('data', '\n'); + assert.ok(called); + rli.close(); + } + + // Constructor throws if completer is not a function or undefined + { + const fi = new FakeInput(); + common.expectsError(function() { + readline.createInterface({ + input: fi, + completer: 'string is not valid' + }); + }, { + type: TypeError, + code: 'ERR_INVALID_OPT_VALUE' + }); + + common.expectsError(function() { + readline.createInterface({ + input: fi, + completer: '' + }); + }, { + type: TypeError, + code: 'ERR_INVALID_OPT_VALUE' + }); + + common.expectsError(function() { + readline.createInterface({ + input: fi, + completer: false + }); + }, { + type: TypeError, + code: 'ERR_INVALID_OPT_VALUE' + }); + } + + // Constructor throws if historySize is not a positive number + { + const fi = new FakeInput(); + common.expectsError(function() { + readline.createInterface({ + input: fi, historySize: 'not a number' + }); + }, { + type: RangeError, + code: 'ERR_INVALID_OPT_VALUE' + }); + + common.expectsError(function() { + readline.createInterface({ + input: fi, historySize: -1 + }); + }, { + type: RangeError, + code: 'ERR_INVALID_OPT_VALUE' + }); + + common.expectsError(function() { + readline.createInterface({ + input: fi, historySize: NaN + }); + }, { + type: RangeError, + code: 'ERR_INVALID_OPT_VALUE' + }); + } + + // Duplicate lines are removed from history when + // `options.removeHistoryDuplicates` is `true` + { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + terminal: true, + removeHistoryDuplicates: true + }); + 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' }); // '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'); + fi.emit('keypress', '.', { name: 'n', ctrl: true }); // 'bar' + assert.strictEqual(rli.line, 'bar'); + fi.emit('keypress', '.', { name: 'down' }); // 'bat' + assert.strictEqual(rli.line, 'bat'); + fi.emit('keypress', '.', { name: 'down' }); // '' + assert.strictEqual(rli.line, ''); + rli.close(); + } + + // Duplicate lines are not removed from history when + // `options.removeHistoryDuplicates` is `false` + { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + 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(); + } + + // Sending a multi-byte utf8 char over multiple writes + { + const buf = Buffer.from('☮', 'utf8'); + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: terminal } + ); + let callCount = 0; + rli.on('line', function(line) { + callCount++; + assert.strictEqual(line, buf.toString('utf8')); + }); + [].forEach.call(buf, function(i) { + fi.emit('data', Buffer.from([i])); + }); + assert.strictEqual(callCount, 0); + fi.emit('data', '\n'); + assert.strictEqual(callCount, 1); + rli.close(); + } + + // Regression test for repl freeze, #1968: + // check that nothing fails if 'keypress' event throws. + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: true } + ); + const keys = []; + fi.on('keypress', function(key) { + keys.push(key); + if (key === 'X') { + throw new Error('bad thing happened'); + } + }); + try { + fi.emit('data', 'fooX'); + } catch { } + fi.emit('data', 'bar'); + assert.strictEqual(keys.join(''), 'fooXbar'); + rli.close(); + } + + // Calling the question callback + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: terminal } + ); + + rli.question('foo?').then(common.mustCall((answer) => { + assert.strictEqual(answer, 'bar'); + })).catch(common.mustNotCall()); + + rli.write('bar\n'); + rli.close(); + } + + if (terminal) { + // history is bound + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal, 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 fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: terminal } + ); + const expectedLines = ['foo']; + rli.question(expectedLines[0]).catch(common.mustNotCall()); + const cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, expectedLines[0].length); + rli.close(); + } + + // Sending a multi-line question + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: terminal } + ); + const expectedLines = ['foo', 'bar']; + rli.question(expectedLines.join('\n')).catch(common.mustNotCall()); + const cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, expectedLines.length - 1); + assert.strictEqual(cursorPos.cols, expectedLines.slice(-1)[0].length); + rli.close(); + } + + { + // Beginning and end of line + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.emit('data', 'the quick brown fox'); + fi.emit('keypress', '.', { ctrl: true, name: 'a' }); + let cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 0); + fi.emit('keypress', '.', { ctrl: true, name: 'e' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 19); + rli.close(); + } + + { + // Back and Forward one character + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.emit('data', 'the quick brown fox'); + let cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 19); + + // Back one character + fi.emit('keypress', '.', { ctrl: true, name: 'b' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 18); + // Back one character + fi.emit('keypress', '.', { ctrl: true, name: 'b' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 17); + // Forward one character + fi.emit('keypress', '.', { ctrl: true, name: 'f' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 18); + // Forward one character + fi.emit('keypress', '.', { ctrl: true, name: 'f' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 19); + rli.close(); + } + + // Back and Forward one astral character + { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.emit('data', '💻'); + + // Move left one character/code point + fi.emit('keypress', '.', { name: 'left' }); + let cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 0); + + // Move right one character/code point + fi.emit('keypress', '.', { name: 'right' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + if (common.hasIntl) { + assert.strictEqual(cursorPos.cols, 2); + } else { + assert.strictEqual(cursorPos.cols, 1); + } + + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, '💻'); + })); + fi.emit('data', '\n'); + rli.close(); + } + + // Two astral characters left + { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.emit('data', '💻'); + + // Move left one character/code point + fi.emit('keypress', '.', { name: 'left' }); + let cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 0); + + fi.emit('data', '🐕'); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + + if (common.hasIntl) { + assert.strictEqual(cursorPos.cols, 2); + } else { + assert.strictEqual(cursorPos.cols, 1); + // Fix cursor position without internationalization + fi.emit('keypress', '.', { name: 'left' }); + } + + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, '🐕💻'); + })); + fi.emit('data', '\n'); + rli.close(); + } + + // Two astral characters right + { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.emit('data', '💻'); + + // Move left one character/code point + fi.emit('keypress', '.', { name: 'right' }); + let cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + if (common.hasIntl) { + assert.strictEqual(cursorPos.cols, 2); + } else { + assert.strictEqual(cursorPos.cols, 1); + // Fix cursor position without internationalization + fi.emit('keypress', '.', { name: 'right' }); + } + + fi.emit('data', '🐕'); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + if (common.hasIntl) { + assert.strictEqual(cursorPos.cols, 4); + } else { + assert.strictEqual(cursorPos.cols, 2); + } + + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, '💻🐕'); + })); + fi.emit('data', '\n'); + rli.close(); + } + + { + // `wordLeft` and `wordRight` + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.emit('data', 'the quick brown fox'); + fi.emit('keypress', '.', { ctrl: true, name: 'left' }); + let cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 16); + fi.emit('keypress', '.', { meta: true, name: 'b' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 10); + fi.emit('keypress', '.', { ctrl: true, name: 'right' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 16); + fi.emit('keypress', '.', { meta: true, name: 'f' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 19); + rli.close(); + } + + { + // `deleteWordLeft` + [ + { ctrl: true, name: 'w' }, + { ctrl: true, name: 'backspace' }, + { meta: true, name: 'backspace' } + ] + .forEach((deleteWordLeftKey) => { + let fi = new FakeInput(); + let rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + 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 + fi = new FakeInput(); + rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + 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 fi = new FakeInput(); + let rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + 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 + fi = new FakeInput(); + rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + 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 fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.emit('data', 'the quick brown fox'); + let cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 19); + + // Delete left character + fi.emit('keypress', '.', { ctrl: true, name: 'h' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 18); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'the quick brown fo'); + })); + fi.emit('data', '\n'); + rli.close(); + } + + // deleteLeft astral character + { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.emit('data', '💻'); + let cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + if (common.hasIntl) { + assert.strictEqual(cursorPos.cols, 2); + } else { + assert.strictEqual(cursorPos.cols, 1); + } + // Delete left character + fi.emit('keypress', '.', { ctrl: true, name: 'h' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 0); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, ''); + })); + fi.emit('data', '\n'); + rli.close(); + } + + // deleteRight + { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.emit('data', 'the quick brown fox'); + + // Go to the start of the line + fi.emit('keypress', '.', { ctrl: true, name: 'a' }); + let cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 0); + + // Delete right character + fi.emit('keypress', '.', { ctrl: true, name: 'd' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 0); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'he quick brown fox'); + })); + fi.emit('data', '\n'); + rli.close(); + } + + // deleteRight astral character + { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.emit('data', '💻'); + + // Go to the start of the line + fi.emit('keypress', '.', { ctrl: true, name: 'a' }); + let cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 0); + + // Delete right character + fi.emit('keypress', '.', { ctrl: true, name: 'd' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 0); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, ''); + })); + fi.emit('data', '\n'); + rli.close(); + } + + // deleteLineLeft + { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.emit('data', 'the quick brown fox'); + let cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 19); + + // Delete from current to start of line + fi.emit('keypress', '.', + { ctrl: true, shift: true, name: 'backspace' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 0); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, ''); + })); + fi.emit('data', '\n'); + rli.close(); + } + + // deleteLineRight + { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.emit('data', 'the quick brown fox'); + + // Go to the start of the line + fi.emit('keypress', '.', { ctrl: true, name: 'a' }); + let cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 0); + + // Delete from current to end of line + fi.emit('keypress', '.', { ctrl: true, shift: true, name: 'delete' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 0); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, ''); + })); + fi.emit('data', '\n'); + rli.close(); + } + + // multi-line cursor position + { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.columns = 10; + fi.emit('data', 'multi-line text'); + const cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 1); + assert.strictEqual(cursorPos.cols, 5); + rli.close(); + } + + // Clear the whole screen + { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + const lines = ['line 1', 'line 2', 'line 3']; + fi.emit('data', lines.join('\n')); + fi.emit('keypress', '.', { ctrl: true, name: 'l' }); + const cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 6); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'line 3'); + })); + fi.emit('data', '\n'); + rli.close(); + } + } + + { + const fi = new FakeInput(); + assert.deepStrictEqual(fi.listeners(terminal ? 'keypress' : 'data'), []); + } + + // 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); + } + + // Can create a new readline Interface with a null output argument + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: null, terminal: terminal } + ); + + let called = false; + rli.on('line', function(line) { + called = true; + assert.strictEqual(line, 'asdf'); + }); + fi.emit('data', 'asdf\n'); + assert.ok(called); + + rli.setPrompt('ddd> '); + rli.prompt(); + rli.write('really shouldnt be seeing this'); + rli.question('What do you think of node.js? ') + .then(common.mustNotCall()) + .catch(common.mustNotCall()); + } + + { + const expected = terminal ? + ['\u001b[1G', '\u001b[0J', '$ ', '\u001b[3G'] : + ['$ ']; + + let counter = 0; + const output = new Writable({ + write: common.mustCall((chunk, enc, cb) => { + assert.strictEqual(chunk.toString(), expected[counter++]); + cb(); + rl.close(); + }, expected.length) + }); + + const rl = readline.createInterface({ + input: new Readable({ read: common.mustCall() }), + output: output, + prompt: '$ ', + terminal: terminal + }); + + rl.prompt(); + + assert.strictEqual(rl._prompt, '$ '); + } +}); + +// 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; + +[ true, false ].forEach(function(terminal) { + // 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 fi = new FakeInput(); + const rli = new readline.Interface( + { + input: fi, + output: fi, + terminal: terminal, + crlfDelay + } + ); + const expectedLines = ['foo', 'bar', 'baz', 'bat']; + let callCount = 0; + rli.on('line', function(line) { + assert.strictEqual(line, expectedLines[callCount]); + callCount++; + }); + fi.emit('data', expectedLines.join('\r\n')); + assert.strictEqual(callCount, expectedLines.length - 1); + rli.close(); + } + + // \r\n should emit one line event when split across multiple writes. + { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + terminal: terminal, + crlfDelay + }); + const expectedLines = ['foo', 'bar', 'baz', 'bat']; + let callCount = 0; + rli.on('line', function(line) { + assert.strictEqual(line, expectedLines[callCount]); + callCount++; + }); + expectedLines.forEach(function(line) { + fi.emit('data', `${line}\r`); + fi.emit('data', '\n'); + }); + assert.strictEqual(callCount, expectedLines.length); + rli.close(); + } + + // Emit one line event when the delay between \r and \n is + // over the default crlfDelay but within the setting value. + { + const fi = new FakeInput(); + const delay = 125; + const rli = new readline.Interface({ + input: fi, + output: fi, + terminal: 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: input, + output: output, + terminal: true, + }); + rl.line = `a${' '.repeat(1e6)}a`; + rl.cursor = rl.line.length; +} diff --git a/test/parallel/test-readline-promises.js b/test/parallel/test-readline-promises.js new file mode 100644 index 00000000000000..5da540e286639a --- /dev/null +++ b/test/parallel/test-readline-promises.js @@ -0,0 +1,189 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const readline = require('readline'); +const { PassThrough } = require('stream'); + +common.expectWarning('ExperimentalWarning', + 'The readline.promises API is experimental'); + +const promises = readline.promises; + +{ + // Verify the shape of the promises API. + assert.strictEqual(promises.clearLine, readline.clearLine); + assert.strictEqual(promises.clearScreenDown, readline.clearScreenDown); + assert.strictEqual(promises.cursorTo, readline.cursorTo); + assert.strictEqual(promises.emitKeypressEvents, readline.emitKeypressEvents); + assert.strictEqual(promises.moveCursor, readline.moveCursor); + assert.strictEqual(typeof promises.createInterface, 'function'); + assert.notStrictEqual(promises.createInterface, readline.createInterface); + assert.strictEqual(typeof promises.Interface, 'function'); + assert.notStrictEqual(promises.Interface, readline.Interface); + + assert.strictEqual(promises.Interface.prototype.close, + readline.Interface.prototype.close); + assert.strictEqual(promises.Interface.prototype.pause, + readline.Interface.prototype.pause); + assert.strictEqual(promises.Interface.prototype.prompt, + readline.Interface.prototype.prompt); + assert.strictEqual(promises.Interface.prototype.resume, + readline.Interface.prototype.resume); + assert.strictEqual(promises.Interface.prototype.setPrompt, + readline.Interface.prototype.setPrompt); + assert.strictEqual(promises.Interface.prototype.write, + readline.Interface.prototype.write); + assert.strictEqual(promises.Interface.prototype[Symbol.asyncIterator], + readline.Interface.prototype[Symbol.asyncIterator]); + assert.strictEqual(typeof promises.Interface.prototype.question, 'function'); + assert.notStrictEqual(promises.Interface.prototype.question, + readline.Interface.prototype.question); + assert.strictEqual(typeof promises.Interface.prototype._tabComplete, + 'function'); + assert.notStrictEqual(promises.Interface.prototype._tabComplete, + readline.Interface.prototype._tabComplete); +} + +{ + const input = new PassThrough(); + const rl = promises.createInterface({ + terminal: true, + input: input + }); + + rl.on('line', common.mustCall((data) => { + assert.strictEqual(data, 'abc'); + })); + + input.end('abc'); +} + +{ + const input = new PassThrough(); + const rl = promises.createInterface({ + terminal: true, + input: input + }); + + rl.on('line', common.mustNotCall('must not be called before newline')); + + input.write('abc'); +} + +{ + const input = new PassThrough(); + const rl = promises.createInterface({ + terminal: true, + input: input + }); + + rl.on('line', common.mustCall((data) => { + assert.strictEqual(data, 'abc'); + })); + + input.write('abc\n'); +} + +{ + const input = new PassThrough(); + const rl = promises.createInterface({ + terminal: true, + input: input + }); + + rl.write('foo'); + assert.strictEqual(rl.cursor, 3); + + const key = { + xterm: { + home: ['\x1b[H', { ctrl: true, name: 'a' }], + end: ['\x1b[F', { ctrl: true, name: 'e' }], + }, + gnome: { + home: ['\x1bOH', { ctrl: true, name: 'a' }], + end: ['\x1bOF', { ctrl: true, name: 'e' }] + }, + rxvt: { + home: ['\x1b[7', { ctrl: true, name: 'a' }], + end: ['\x1b[8', { ctrl: true, name: 'e' }] + }, + putty: { + home: ['\x1b[1~', { ctrl: true, name: 'a' }], + end: ['\x1b[>~', { ctrl: true, name: 'e' }] + } + }; + + [key.xterm, key.gnome, key.rxvt, key.putty].forEach(function(key) { + rl.write.apply(rl, key.home); + assert.strictEqual(rl.cursor, 0); + rl.write.apply(rl, key.end); + assert.strictEqual(rl.cursor, 3); + }); + +} + +{ + const input = new PassThrough(); + const rl = promises.createInterface({ + terminal: true, + input: input + }); + + const key = { + xterm: { + home: ['\x1b[H', { ctrl: true, name: 'a' }], + metab: ['\x1bb', { meta: true, name: 'b' }], + metaf: ['\x1bf', { meta: true, name: 'f' }], + } + }; + + rl.write('foo bar.hop/zoo'); + rl.write.apply(rl, key.xterm.home); + [ + { cursor: 4, key: key.xterm.metaf }, + { cursor: 7, key: key.xterm.metaf }, + { cursor: 8, key: key.xterm.metaf }, + { cursor: 11, key: key.xterm.metaf }, + { cursor: 12, key: key.xterm.metaf }, + { cursor: 15, key: key.xterm.metaf }, + { cursor: 12, key: key.xterm.metab }, + { cursor: 11, key: key.xterm.metab }, + { cursor: 8, key: key.xterm.metab }, + { cursor: 7, key: key.xterm.metab }, + { cursor: 4, key: key.xterm.metab }, + { cursor: 0, key: key.xterm.metab }, + ].forEach(function(action) { + rl.write.apply(rl, action.key); + assert.strictEqual(rl.cursor, action.cursor); + }); +} + +{ + const input = new PassThrough(); + const rl = promises.createInterface({ + terminal: true, + input: input + }); + + const key = { + xterm: { + home: ['\x1b[H', { ctrl: true, name: 'a' }], + metad: ['\x1bd', { meta: true, name: 'd' }] + } + }; + + rl.write('foo bar.hop/zoo'); + rl.write.apply(rl, key.xterm.home); + [ + 'bar.hop/zoo', + '.hop/zoo', + 'hop/zoo', + '/zoo', + 'zoo', + '' + ].forEach(function(expectedLine) { + rl.write.apply(rl, key.xterm.metad); + assert.strictEqual(rl.cursor, 0); + assert.strictEqual(rl.line, expectedLine); + }); +} diff --git a/tools/doc/type-parser.js b/tools/doc/type-parser.js index 22307bdf6e4d3c..b9323e7cc04dd4 100644 --- a/tools/doc/type-parser.js +++ b/tools/doc/type-parser.js @@ -115,6 +115,8 @@ const customTypesMap = { 'perf_hooks.html#perf_hooks_class_performanceobserverentrylist', 'readline.Interface': 'readline.html#readline_class_interface', + 'readline.promises.Interface': + 'readline.html#readline_class_readline_promises_interface', 'repl.REPLServer': 'repl.html#repl_class_replserver',