diff --git a/src/js/internal/readline/interface.ts b/src/js/internal/readline/interface.ts index b13fbda4c1a9..6a31ec23ee8d 100644 --- a/src/js/internal/readline/interface.ts +++ b/src/js/internal/readline/interface.ts @@ -1,4 +1,5 @@ // Ported from Node.js v26.3.0 lib/internal/readline/interface.js for Bun's node:repl. +// The history options record in InterfaceConstructor is from Node.js v26.7.0 (nodejs/node c25b8e3331). // Attribution: derived from Node.js, MIT licensed (Node.js contributors). // prettier-ignore const primordials = require("internal/repl/node-primordials"); @@ -156,6 +157,7 @@ declare global { | ReadlineCallbackCompleter; interface ReadlineHistoryOptions { + __proto__?: null; size?: number; history?: string[]; removeHistoryDuplicates?: boolean; @@ -164,7 +166,7 @@ declare global { onHistoryFileLoaded?(err: Error | null, context: object): void; } - interface ReadlineInput extends Readable, ReadlineHistoryOptions { + interface ReadlineInput extends Readable { input?: undefined; isRaw?: boolean; setRawMode?(mode: boolean): unknown; @@ -231,6 +233,7 @@ function InterfaceConstructor( let crlfDelay: number | undefined; let prompt = "> "; let signal: AbortSignal | undefined; + let historyOptions: HistoryOptions | undefined; if (input?.input) { // An options object was given @@ -266,12 +269,16 @@ function InterfaceConstructor( crlfDelay = input.crlfDelay; input = input.input; - input.size = historySize; - input.history = history; - input.removeHistoryDuplicates = removeHistoryDuplicates; + historyOptions = { + __proto__: null, + size: historySize, + history, + removeHistoryDuplicates, + }; } - this.setupHistoryManager(input); + // Without an options object, the stream itself is the history options, as in Node: https://github.com/nodejs/node/blob/v26.12.1/lib/internal/readline/interface.js#L226 + this.setupHistoryManager(historyOptions ?? (input as HistoryOptions)); if (completer !== undefined && typeof completer !== "function") { throw new ERR_INVALID_ARG_VALUE("completer", completer); diff --git a/test/js/node/readline/history-options-off-input-fixture.cjs b/test/js/node/readline/history-options-off-input-fixture.cjs new file mode 100644 index 000000000000..16916ebb0608 --- /dev/null +++ b/test/js/node/readline/history-options-off-input-fixture.cjs @@ -0,0 +1,138 @@ +// Prints one row for each check of what an interface does to the stream it reads from. +// readline.node.test.ts expects the same rows from Bun and from Node.js v26.7.0 or later. +const fs = require("node:fs"); +const readline = require("node:readline"); +const readlinePromises = require("node:readline/promises"); +const repl = require("node:repl"); +const { PassThrough } = require("node:stream"); + +const historyKeys = ["size", "history", "removeHistoryDuplicates", "filePath", "onHistoryFileLoaded"]; +const rows = []; +const row = (name, value) => rows.push(`${name}: ${value}`); +const attempt = fn => { + try { + return fn(); + } catch (error) { + return `threw ${error.code ?? error.name}`; + } +}; +const ownHistoryKeys = stream => historyKeys.filter(key => Object.hasOwn(stream, key)).join() || "none"; +const historyOf = rl => `historySize ${rl.historySize}, history ${JSON.stringify(rl.history)}`; + +(async () => { + const entryPoints = { + "readline.createInterface({ input })": input => readline.createInterface({ input }), + "readline.createInterface({ input, output, terminal: true })": input => + readline.createInterface({ input, output: new PassThrough(), terminal: true }), + "new readline.Interface({ input })": input => new readline.Interface({ input }), + "readline/promises createInterface({ input })": input => readlinePromises.createInterface({ input }), + "repl.start({ input, output })": input => repl.start({ input, output: new PassThrough() }), + "repl.start({ input, output, terminal: true })": input => + repl.start({ input, output: new PassThrough(), terminal: true }), + }; + for (const [name, open] of Object.entries(entryPoints)) { + const input = new PassThrough(); + open(input).close(); + row(`history options on the stream after ${name}`, ownHistoryKeys(input)); + } + readline.createInterface({ input: process.stdin }).close(); + row( + "history options on process.stdin after readline.createInterface({ input: process.stdin })", + ownHistoryKeys(process.stdin), + ); + { + const handle = await fs.promises.open(__filename); + const lines = handle.readLines(); + for await (const _ of lines); + row("history options on the stream after FileHandle.readLines()", ownHistoryKeys(lines.input)); + } + + { + const input = new PassThrough(); + input.size = "mine"; + readline.createInterface({ input }).close(); + row("own size of the stream after createInterface({ input })", input.size); + } + { + const read = []; + const input = new PassThrough(); + for (const key of historyKeys) Object.defineProperty(input, key, { get: () => void read.push(key) }); + row( + "history options that createInterface({ input }) reads from a stream with a getter for each", + attempt(() => (readline.createInterface({ input }).close(), read.join() || "none")), + ); + } + row( + "createInterface({ input, terminal: false }) on a stream that is not extensible", + attempt(() => { + readline.createInterface({ input: Object.preventExtensions(new PassThrough()), terminal: false }).close(); + return "made"; + }), + ); + + { + const input = new PassThrough(); + const history = ["x"]; + const first = readline.createInterface({ input, historySize: 3, history }); + row("the history of the interface is the array that was given", first.history === history); + first.close(); + const later = readline.createInterface(input); + row("createInterface(input) after createInterface({ input, historySize: 3, history: ['x'] })", historyOf(later)); + later.close(); + } + { + const input = new PassThrough(); + const output = new PassThrough(); + readline.createInterface({ input, output, terminal: true, historySize: 3 }).close(); + const later = readline.createInterface(input, output, undefined, true); + row( + "createInterface(input, output, undefined, true) after createInterface({ input, output, terminal: true, historySize: 3 })", + historyOf(later), + ); + later.close(); + } + { + const input = new PassThrough(); + row( + "createInterface({ input, historySize: -1 })", + attempt(() => readline.createInterface({ input, historySize: -1 })), + ); + row( + "createInterface(input) after that", + attempt(() => { + const later = readline.createInterface(input); + later.close(); + return historyOf(later); + }), + ); + } + + const givenToSetupHistoryManager = open => { + let given; + class Interface extends readline.Interface { + setupHistoryManager(options) { + given = options; + super.setupHistoryManager(options); + } + } + const input = new PassThrough(); + open(Interface, input).close(); + if (given === input) return "the stream"; + const prototype = Object.getPrototypeOf(given) === null ? "a null-prototype object" : "an object"; + return `${prototype} with ${Object.keys(given).join()}`; + }; + row( + "setupHistoryManager receives from new Interface({ input })", + givenToSetupHistoryManager((Interface, input) => new Interface({ input })), + ); + row( + "setupHistoryManager receives from new Interface(input)", + givenToSetupHistoryManager((Interface, input) => new Interface(input)), + ); + + console.log(rows.join("\n")); +})().catch(error => { + console.log(rows.join("\n")); + console.log("FAILED", error); + process.exitCode = 1; +}); diff --git a/test/js/node/readline/readline.node.test.ts b/test/js/node/readline/readline.node.test.ts index d9f3312dbc4b..a3ac03bb7b34 100644 --- a/test/js/node/readline/readline.node.test.ts +++ b/test/js/node/readline/readline.node.test.ts @@ -1,6 +1,8 @@ // @ts-nocheck +import { bunEnv, bunExe, isDebug, nodeExeAtLeast } from "harness"; import { createTest } from "node-harness"; import { EventEmitter } from "node:events"; +import { join } from "node:path"; import readline from "node:readline"; import { PassThrough, Readable, Writable } from "node:stream"; const { beforeEach, describe, it, createDoneDotAll, createCallCheckCtx, assert } = createTest(import.meta.path); @@ -1942,6 +1944,57 @@ describe("readline.createInterface()", () => { expect(result).toEqual(lines); }); + const historyOptionsOffInput = [ + "history options on the stream after readline.createInterface({ input }): none", + "history options on the stream after readline.createInterface({ input, output, terminal: true }): none", + "history options on the stream after new readline.Interface({ input }): none", + "history options on the stream after readline/promises createInterface({ input }): none", + "history options on the stream after repl.start({ input, output }): none", + "history options on the stream after repl.start({ input, output, terminal: true }): none", + "history options on process.stdin after readline.createInterface({ input: process.stdin }): none", + "history options on the stream after FileHandle.readLines(): none", + "own size of the stream after createInterface({ input }): mine", + "history options that createInterface({ input }) reads from a stream with a getter for each: none", + "createInterface({ input, terminal: false }) on a stream that is not extensible: made", + "the history of the interface is the array that was given: true", + "createInterface(input) after createInterface({ input, historySize: 3, history: ['x'] }): historySize 30, history []", + "createInterface(input, output, undefined, true) after createInterface({ input, output, terminal: true, historySize: 3 }): historySize 30, history []", + "createInterface({ input, historySize: -1 }): threw ERR_OUT_OF_RANGE", + "createInterface(input) after that: historySize 30, history []", + "setupHistoryManager receives from new Interface({ input }): a null-prototype object with size,history,removeHistoryDuplicates", + // Without an options object Node.js reads the history options off the stream (nodejs/node#61526). This row keeps Bun the same. + "setupHistoryManager receives from new Interface(input): the stream", + ]; + for (const [name, runtime] of [ + ["bun", bunExe()], + // Before v26.7.0 Node.js stored the history options on the stream (nodejs/node#64585). + ["node", nodeExeAtLeast("26.7.0")], + ]) { + it.skipIf(!runtime)( + `should keep the history options off the input stream (${name})`, + async () => { + await using proc = Bun.spawn({ + cmd: [runtime, join(import.meta.dir, "history-options-off-input-fixture.cjs")], + env: bunEnv, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // A warning that Node.js writes to stderr (for NODE_OPTIONS, for a deprecation) is not part of the comparison. + const errors = name === "node" && exitCode === 0 ? "" : stderr; + expect({ rows: stdout.trimEnd().split(/\r?\n/), stderr: errors, exitCode }).toEqual({ + rows: historyOptionsOffInput, + stderr: "", + exitCode: 0, + }); + }, + // `bun test` gives a test 5 s and scripts/runner.node.ts gives it 90 s. A debug build needs 2 to 4 s to load the + // readline and REPL stacks, and more on a cold start. It gets the 90 s also without the runner. + isDebug ? 90_000 : undefined, + ); + } + it("should respond to home and end sequences for common pttys ", () => { const input = new PassThrough(); const rl = readline.createInterface({