From e3911c82872fc7998a5c871177053c0a5e8f5ad0 Mon Sep 17 00:00:00 2001 From: Tim Date: Sat, 16 Nov 2024 18:30:39 +0100 Subject: [PATCH 01/15] initial commit --- cli/prompt_select.ts | 97 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 cli/prompt_select.ts diff --git a/cli/prompt_select.ts b/cli/prompt_select.ts new file mode 100644 index 000000000000..62f7a73ce7e3 --- /dev/null +++ b/cli/prompt_select.ts @@ -0,0 +1,97 @@ +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +/** Options for {@linkcode promptSelect}. */ +export interface PromptSelectOptions { + /** Clear the lines after the user's input. */ + clear?: boolean; +} + +const ESC_KEY = "\x03"; +const ARROW_UP_KEY = "\u001B[A"; +const ARROW_DOWN_KEY = "\u001B[B"; + +const INDICATOR = "❯"; + +class PromptSelect { + #selectedIndex = 0; + #values: string[]; + #options: PromptSelectOptions; + constructor(values: string[], options: PromptSelectOptions = {}) { + this.#values = values; + this.#options = options; + } + #render() { + const indicatorLength = INDICATOR.length; + for (const [index, value] of this.#values.entries()) { + const data = index === this.#selectedIndex + ? `${INDICATOR} ${value}` + : `${" ".repeat(indicatorLength)} ${value}`; + Deno.stdout.writeSync(encoder.encode(data + "\r\n")); + } + } + #clear(lines: number) { + Deno.stdout.writeSync(encoder.encode("\x1b[1A\x1b[2K".repeat(lines))); + } + prompt(message: string): string | null { + Deno.stdout.writeSync(encoder.encode(message + "\r\n")); + + this.#render(); + + Deno.stdin.setRaw(true); + + const c = new Uint8Array(4); + + loop: + while (true) { + const n = Deno.stdin.readSync(c); + if (n === null || n === 0) break; + const input = decoder.decode(c.slice(0, n)); + switch (input) { + case ESC_KEY: + return Deno.exit(0); + case ARROW_UP_KEY: + this.#selectedIndex = Math.max(0, this.#selectedIndex - 1); + break; + case ARROW_DOWN_KEY: + this.#selectedIndex = Math.min( + this.#values.length - 1, + this.#selectedIndex + 1, + ); + break; + case "\r": + break loop; + } + this.#clear(this.#values.length); + this.#render(); + } + if (this.#options.clear) { + // clear all including message + this.#clear(this.#values.length + 1); + } + Deno.stdin.setRaw(false); + return this.#values[this.#selectedIndex] ?? null; + } +} + +/** + * Shows the given message and waits for the user's input. Returns the user's selected value as string. + * + * @param message The prompt message to show to the user. + * @param values The values for the prompt. + * @returns The string that was entered or `null` if stdin is not a TTY. + * + * @example Usage + * ```ts ignore + * import { promptSelect } from "@std/cli/prompt-select"; + * + * const browser = promptSelect("Please select a browser:", ["safari", "chrome", "firefox"], { clear: true }); + * ``` + */ +export function promptSelect( + message: string, + values: string[], + options: PromptSelectOptions = {}, +): string | null { + return new PromptSelect(values, options).prompt(message); +} From 45129be13896b7cf21a57b9aa0f48ca3cca85f26 Mon Sep 17 00:00:00 2001 From: Tim Date: Sat, 16 Nov 2024 19:07:32 +0100 Subject: [PATCH 02/15] add copyright header --- cli/prompt_select.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cli/prompt_select.ts b/cli/prompt_select.ts index 62f7a73ce7e3..759c324056d7 100644 --- a/cli/prompt_select.ts +++ b/cli/prompt_select.ts @@ -1,3 +1,5 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + const encoder = new TextEncoder(); const decoder = new TextDecoder(); From 6e307d5d93e4bc0d593d0b8961f8df135dbd83ba Mon Sep 17 00:00:00 2001 From: Tim Date: Sat, 16 Nov 2024 19:08:58 +0100 Subject: [PATCH 03/15] add mod export --- cli/mod.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/mod.ts b/cli/mod.ts index ac169a5415c9..55320bb5e557 100644 --- a/cli/mod.ts +++ b/cli/mod.ts @@ -17,4 +17,5 @@ export * from "./parse_args.ts"; export * from "./prompt_secret.ts"; +export * from "./prompt_select.ts"; export * from "./unicode_width.ts"; From 41da4606c3538d7de73b5a5617194fd14fdccb7a Mon Sep 17 00:00:00 2001 From: Tim Date: Sat, 16 Nov 2024 19:11:13 +0100 Subject: [PATCH 04/15] add jsdoc --- cli/prompt_select.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/prompt_select.ts b/cli/prompt_select.ts index 759c324056d7..dccc7d4582ac 100644 --- a/cli/prompt_select.ts +++ b/cli/prompt_select.ts @@ -81,6 +81,7 @@ class PromptSelect { * * @param message The prompt message to show to the user. * @param values The values for the prompt. + * @param options The options for the prompt. * @returns The string that was entered or `null` if stdin is not a TTY. * * @example Usage From 414f1a3b359f5fc945182400f739c4576de62d5c Mon Sep 17 00:00:00 2001 From: Tim Date: Sat, 16 Nov 2024 20:26:20 +0100 Subject: [PATCH 05/15] update --- cli/prompt_select.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/cli/prompt_select.ts b/cli/prompt_select.ts index dccc7d4582ac..31ef5557c4ec 100644 --- a/cli/prompt_select.ts +++ b/cli/prompt_select.ts @@ -24,16 +24,16 @@ class PromptSelect { this.#options = options; } #render() { - const indicatorLength = INDICATOR.length; + const padding = " ".repeat(INDICATOR.length); for (const [index, value] of this.#values.entries()) { const data = index === this.#selectedIndex ? `${INDICATOR} ${value}` - : `${" ".repeat(indicatorLength)} ${value}`; + : `${padding} ${value}`; Deno.stdout.writeSync(encoder.encode(data + "\r\n")); } } - #clear(lines: number) { - Deno.stdout.writeSync(encoder.encode("\x1b[1A\x1b[2K".repeat(lines))); + #clear(lineCount: number) { + Deno.stdout.writeSync(encoder.encode("\x1b[1A\x1b[2K".repeat(lineCount))); } prompt(message: string): string | null { Deno.stdout.writeSync(encoder.encode(message + "\r\n")); @@ -42,8 +42,9 @@ class PromptSelect { Deno.stdin.setRaw(true); - const c = new Uint8Array(4); + const length = this.#values.length; + const c = new Uint8Array(4); loop: while (true) { const n = Deno.stdin.readSync(c); @@ -56,20 +57,17 @@ class PromptSelect { this.#selectedIndex = Math.max(0, this.#selectedIndex - 1); break; case ARROW_DOWN_KEY: - this.#selectedIndex = Math.min( - this.#values.length - 1, - this.#selectedIndex + 1, - ); + this.#selectedIndex = Math.min(length - 1, this.#selectedIndex + 1); break; case "\r": break loop; } - this.#clear(this.#values.length); + this.#clear(length); this.#render(); } if (this.#options.clear) { - // clear all including message - this.#clear(this.#values.length + 1); + // clear lines and message + this.#clear(length + 1); } Deno.stdin.setRaw(false); return this.#values[this.#selectedIndex] ?? null; From a974e1e7a1cfd525da0a4b2a29bfb32427908871 Mon Sep 17 00:00:00 2001 From: Tim Date: Sat, 16 Nov 2024 20:28:41 +0100 Subject: [PATCH 06/15] update --- cli/prompt_select.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/prompt_select.ts b/cli/prompt_select.ts index 31ef5557c4ec..09814604e425 100644 --- a/cli/prompt_select.ts +++ b/cli/prompt_select.ts @@ -1,8 +1,5 @@ // Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. -const encoder = new TextEncoder(); -const decoder = new TextDecoder(); - /** Options for {@linkcode promptSelect}. */ export interface PromptSelectOptions { /** Clear the lines after the user's input. */ @@ -15,6 +12,9 @@ const ARROW_DOWN_KEY = "\u001B[B"; const INDICATOR = "❯"; +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + class PromptSelect { #selectedIndex = 0; #values: string[]; From 428e799693cb637e63fbc70f42f0b2970097394e Mon Sep 17 00:00:00 2001 From: Tim Date: Sun, 17 Nov 2024 13:51:45 +0100 Subject: [PATCH 07/15] cleanup --- cli/prompt_select.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/cli/prompt_select.ts b/cli/prompt_select.ts index 09814604e425..27550b363f21 100644 --- a/cli/prompt_select.ts +++ b/cli/prompt_select.ts @@ -6,9 +6,9 @@ export interface PromptSelectOptions { clear?: boolean; } -const ESC_KEY = "\x03"; -const ARROW_UP_KEY = "\u001B[A"; -const ARROW_DOWN_KEY = "\u001B[B"; +const ETX = "\x03"; +const ARROW_UP = "\u001B[A"; +const ARROW_DOWN = "\u001B[B"; const INDICATOR = "❯"; @@ -26,10 +26,8 @@ class PromptSelect { #render() { const padding = " ".repeat(INDICATOR.length); for (const [index, value] of this.#values.entries()) { - const data = index === this.#selectedIndex - ? `${INDICATOR} ${value}` - : `${padding} ${value}`; - Deno.stdout.writeSync(encoder.encode(data + "\r\n")); + const start = index === this.#selectedIndex ? INDICATOR : padding; + Deno.stdout.writeSync(encoder.encode(`${start} ${value}\r\n`)); } } #clear(lineCount: number) { @@ -43,25 +41,27 @@ class PromptSelect { Deno.stdin.setRaw(true); const length = this.#values.length; + const buffer = new Uint8Array(4); - const c = new Uint8Array(4); loop: while (true) { - const n = Deno.stdin.readSync(c); + const n = Deno.stdin.readSync(buffer); if (n === null || n === 0) break; - const input = decoder.decode(c.slice(0, n)); + const input = decoder.decode(buffer.slice(0, n)); + switch (input) { - case ESC_KEY: + case ETX: return Deno.exit(0); - case ARROW_UP_KEY: + case ARROW_UP: this.#selectedIndex = Math.max(0, this.#selectedIndex - 1); break; - case ARROW_DOWN_KEY: + case ARROW_DOWN: this.#selectedIndex = Math.min(length - 1, this.#selectedIndex + 1); break; case "\r": break loop; } + this.#clear(length); this.#render(); } From c295684bad67edde96e35c78f29543419932149d Mon Sep 17 00:00:00 2001 From: Tim Date: Sun, 17 Nov 2024 13:57:14 +0100 Subject: [PATCH 08/15] remove obsolete class --- cli/prompt_select.ts | 106 +++++++++++++++++-------------------------- 1 file changed, 42 insertions(+), 64 deletions(-) diff --git a/cli/prompt_select.ts b/cli/prompt_select.ts index 27550b363f21..c3e02c11da34 100644 --- a/cli/prompt_select.ts +++ b/cli/prompt_select.ts @@ -9,71 +9,9 @@ export interface PromptSelectOptions { const ETX = "\x03"; const ARROW_UP = "\u001B[A"; const ARROW_DOWN = "\u001B[B"; - +const CR = "\r"; const INDICATOR = "❯"; -const encoder = new TextEncoder(); -const decoder = new TextDecoder(); - -class PromptSelect { - #selectedIndex = 0; - #values: string[]; - #options: PromptSelectOptions; - constructor(values: string[], options: PromptSelectOptions = {}) { - this.#values = values; - this.#options = options; - } - #render() { - const padding = " ".repeat(INDICATOR.length); - for (const [index, value] of this.#values.entries()) { - const start = index === this.#selectedIndex ? INDICATOR : padding; - Deno.stdout.writeSync(encoder.encode(`${start} ${value}\r\n`)); - } - } - #clear(lineCount: number) { - Deno.stdout.writeSync(encoder.encode("\x1b[1A\x1b[2K".repeat(lineCount))); - } - prompt(message: string): string | null { - Deno.stdout.writeSync(encoder.encode(message + "\r\n")); - - this.#render(); - - Deno.stdin.setRaw(true); - - const length = this.#values.length; - const buffer = new Uint8Array(4); - - loop: - while (true) { - const n = Deno.stdin.readSync(buffer); - if (n === null || n === 0) break; - const input = decoder.decode(buffer.slice(0, n)); - - switch (input) { - case ETX: - return Deno.exit(0); - case ARROW_UP: - this.#selectedIndex = Math.max(0, this.#selectedIndex - 1); - break; - case ARROW_DOWN: - this.#selectedIndex = Math.min(length - 1, this.#selectedIndex + 1); - break; - case "\r": - break loop; - } - - this.#clear(length); - this.#render(); - } - if (this.#options.clear) { - // clear lines and message - this.#clear(length + 1); - } - Deno.stdin.setRaw(false); - return this.#values[this.#selectedIndex] ?? null; - } -} - /** * Shows the given message and waits for the user's input. Returns the user's selected value as string. * @@ -94,5 +32,45 @@ export function promptSelect( values: string[], options: PromptSelectOptions = {}, ): string | null { - return new PromptSelect(values, options).prompt(message); + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const length = values.length; + let selectedIndex = 0; + + Deno.stdout.writeSync(encoder.encode(`${message}\r\n`)); + Deno.stdin.setRaw(true); + + const buffer = new Uint8Array(4); + loop: + while (true) { + const padding = " ".repeat(INDICATOR.length); + for (const [index, value] of values.entries()) { + const start = index === selectedIndex ? INDICATOR : padding; + Deno.stdout.writeSync(encoder.encode(`${start} ${value}\r\n`)); + } + + const n = Deno.stdin.readSync(buffer); + if (n === null || n === 0) break; + const input = decoder.decode(buffer.slice(0, n)); + + switch (input) { + case ETX: + return Deno.exit(0); + case ARROW_UP: + selectedIndex = Math.max(0, selectedIndex - 1); + break; + case ARROW_DOWN: + selectedIndex = Math.min(length - 1, selectedIndex + 1); + break; + case CR: + break loop; + } + Deno.stdout.writeSync(encoder.encode("\x1b[1A\x1b[2K".repeat(length))); + } + if (options.clear) { + // clear lines and message + Deno.stdout.writeSync(encoder.encode("\x1b[1A\x1b[2K".repeat(length + 1))); + } + Deno.stdin.setRaw(false); + return values[selectedIndex] ?? null; } From 0589c81b4355789de7ecc7d545b8b7152f59ea54 Mon Sep 17 00:00:00 2001 From: Tim Date: Sun, 17 Nov 2024 13:58:51 +0100 Subject: [PATCH 09/15] move padding declaration --- cli/prompt_select.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/prompt_select.ts b/cli/prompt_select.ts index c3e02c11da34..f7e552bea6e0 100644 --- a/cli/prompt_select.ts +++ b/cli/prompt_select.ts @@ -35,6 +35,7 @@ export function promptSelect( const encoder = new TextEncoder(); const decoder = new TextDecoder(); const length = values.length; + const padding = " ".repeat(INDICATOR.length); let selectedIndex = 0; Deno.stdout.writeSync(encoder.encode(`${message}\r\n`)); @@ -43,7 +44,6 @@ export function promptSelect( const buffer = new Uint8Array(4); loop: while (true) { - const padding = " ".repeat(INDICATOR.length); for (const [index, value] of values.entries()) { const start = index === selectedIndex ? INDICATOR : padding; Deno.stdout.writeSync(encoder.encode(`${start} ${value}\r\n`)); From 9fd7c4abe3836837d706bf3b2205ae3314944abf Mon Sep 17 00:00:00 2001 From: Tim Date: Sun, 17 Nov 2024 14:11:27 +0100 Subject: [PATCH 10/15] cleanup --- cli/prompt_select.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/cli/prompt_select.ts b/cli/prompt_select.ts index f7e552bea6e0..3d6b82d1fc7f 100644 --- a/cli/prompt_select.ts +++ b/cli/prompt_select.ts @@ -11,6 +11,12 @@ const ARROW_UP = "\u001B[A"; const ARROW_DOWN = "\u001B[B"; const CR = "\r"; const INDICATOR = "❯"; +const PADDING = " ".repeat(INDICATOR.length); + +const CLR = "\r\u001b[K"; // Clear the current line + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); /** * Shows the given message and waits for the user's input. Returns the user's selected value as string. @@ -32,10 +38,7 @@ export function promptSelect( values: string[], options: PromptSelectOptions = {}, ): string | null { - const encoder = new TextEncoder(); - const decoder = new TextDecoder(); const length = values.length; - const padding = " ".repeat(INDICATOR.length); let selectedIndex = 0; Deno.stdout.writeSync(encoder.encode(`${message}\r\n`)); @@ -45,14 +48,12 @@ export function promptSelect( loop: while (true) { for (const [index, value] of values.entries()) { - const start = index === selectedIndex ? INDICATOR : padding; + const start = index === selectedIndex ? INDICATOR : PADDING; Deno.stdout.writeSync(encoder.encode(`${start} ${value}\r\n`)); } - const n = Deno.stdin.readSync(buffer); if (n === null || n === 0) break; const input = decoder.decode(buffer.slice(0, n)); - switch (input) { case ETX: return Deno.exit(0); @@ -65,11 +66,10 @@ export function promptSelect( case CR: break loop; } - Deno.stdout.writeSync(encoder.encode("\x1b[1A\x1b[2K".repeat(length))); + Deno.stdout.writeSync(encoder.encode(`\x1b[${length}A${CLR}`)); } if (options.clear) { - // clear lines and message - Deno.stdout.writeSync(encoder.encode("\x1b[1A\x1b[2K".repeat(length + 1))); + Deno.stdout.writeSync(encoder.encode(`\x1b[${length + 1}A${CLR}`)); // clear values and message } Deno.stdin.setRaw(false); return values[selectedIndex] ?? null; From a738c450cd1d5cb5f2f8da43868c10f44c429d68 Mon Sep 17 00:00:00 2001 From: Tim Date: Sun, 17 Nov 2024 15:56:54 +0100 Subject: [PATCH 11/15] fix --- cli/prompt_select.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/prompt_select.ts b/cli/prompt_select.ts index 3d6b82d1fc7f..5280ed6be205 100644 --- a/cli/prompt_select.ts +++ b/cli/prompt_select.ts @@ -66,10 +66,10 @@ export function promptSelect( case CR: break loop; } - Deno.stdout.writeSync(encoder.encode(`\x1b[${length}A${CLR}`)); + Deno.stdout.writeSync(encoder.encode(`\x1b[1A${CLR}`.repeat(length))); } if (options.clear) { - Deno.stdout.writeSync(encoder.encode(`\x1b[${length + 1}A${CLR}`)); // clear values and message + Deno.stdout.writeSync(encoder.encode(`\x1b[1A${CLR}`.repeat(length + 1))); // clear values and message } Deno.stdin.setRaw(false); return values[selectedIndex] ?? null; From 5d97c32d20f06a62b36ab8a37affeb45d9b25d54 Mon Sep 17 00:00:00 2001 From: Tim Date: Mon, 18 Nov 2024 10:35:08 +0100 Subject: [PATCH 12/15] add tests --- cli/mod.ts | 1 - ...pt_select.ts => unstable_prompt_select.ts} | 12 +- cli/unstable_prompt_select_test.ts | 336 ++++++++++++++++++ 3 files changed, 343 insertions(+), 6 deletions(-) rename cli/{prompt_select.ts => unstable_prompt_select.ts} (87%) create mode 100644 cli/unstable_prompt_select_test.ts diff --git a/cli/mod.ts b/cli/mod.ts index 55320bb5e557..ac169a5415c9 100644 --- a/cli/mod.ts +++ b/cli/mod.ts @@ -17,5 +17,4 @@ export * from "./parse_args.ts"; export * from "./prompt_secret.ts"; -export * from "./prompt_select.ts"; export * from "./unicode_width.ts"; diff --git a/cli/prompt_select.ts b/cli/unstable_prompt_select.ts similarity index 87% rename from cli/prompt_select.ts rename to cli/unstable_prompt_select.ts index 5280ed6be205..3dd05dc048ab 100644 --- a/cli/prompt_select.ts +++ b/cli/unstable_prompt_select.ts @@ -36,7 +36,7 @@ const decoder = new TextDecoder(); export function promptSelect( message: string, values: string[], - options: PromptSelectOptions = {}, + { clear }: PromptSelectOptions = {}, ): string | null { const length = values.length; let selectedIndex = 0; @@ -47,10 +47,12 @@ export function promptSelect( const buffer = new Uint8Array(4); loop: while (true) { + let output = ""; for (const [index, value] of values.entries()) { const start = index === selectedIndex ? INDICATOR : PADDING; - Deno.stdout.writeSync(encoder.encode(`${start} ${value}\r\n`)); + output += `${start} ${value}\r\n`; } + Deno.stdout.writeSync(encoder.encode(output)); const n = Deno.stdin.readSync(buffer); if (n === null || n === 0) break; const input = decoder.decode(buffer.slice(0, n)); @@ -58,17 +60,17 @@ export function promptSelect( case ETX: return Deno.exit(0); case ARROW_UP: - selectedIndex = Math.max(0, selectedIndex - 1); + selectedIndex = (selectedIndex - 1 + length) % length; break; case ARROW_DOWN: - selectedIndex = Math.min(length - 1, selectedIndex + 1); + selectedIndex = (selectedIndex + 1) % length; break; case CR: break loop; } Deno.stdout.writeSync(encoder.encode(`\x1b[1A${CLR}`.repeat(length))); } - if (options.clear) { + if (clear) { Deno.stdout.writeSync(encoder.encode(`\x1b[1A${CLR}`.repeat(length + 1))); // clear values and message } Deno.stdin.setRaw(false); diff --git a/cli/unstable_prompt_select_test.ts b/cli/unstable_prompt_select_test.ts new file mode 100644 index 000000000000..1980daea0d99 --- /dev/null +++ b/cli/unstable_prompt_select_test.ts @@ -0,0 +1,336 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +import { assertEquals } from "../assert/equals.ts"; +import { promptSelect } from "./unstable_prompt_select.ts"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +function spyFn( + target: T, + key: K, + fn: T[K], +) { + const originalFn = target[key]; + target[key] = fn; + return () => target[key] = originalFn; +} + +Deno.test("promptSelect() handles enter", () => { + const expectedOutput = [ + "Please select a browser:\r\n", + "❯ safari\r\n chrome\r\n firefox\r\n", + ]; + + let writeIndex = 0; + + const restoreWriteSync = spyFn( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + assertEquals(output, expectedOutput[writeIndex]); + writeIndex++; + return data.length; + }, + ); + + let readIndex = 0; + + const inputs = [ + "\r", + ]; + + const restoreReadSync = spyFn( + Deno.stdin, + "readSync", + (data: Uint8Array) => { + const input = inputs[readIndex++]; + const bytes = encoder.encode(input); + data.set(bytes); + return bytes.length; + }, + ); + + const browser = promptSelect("Please select a browser:", [ + "safari", + "chrome", + "firefox", + ]); + + assertEquals(browser, "safari"); + + // Restore mocks + restoreWriteSync(); + restoreReadSync(); +}); + +Deno.test("promptSelect() handles arrow down", () => { + const expectedOutput = [ + "Please select a browser:\r\n", + "❯ safari\r\n chrome\r\n firefox\r\n", + "\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K", + " safari\r\n❯ chrome\r\n firefox\r\n", + "\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K", + " safari\r\n chrome\r\n❯ firefox\r\n", + ]; + + let writeIndex = 0; + + const restoreWriteSync = spyFn( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + assertEquals(output, expectedOutput[writeIndex]); + writeIndex++; + return data.length; + }, + ); + + let readIndex = 0; + + const inputs = [ + "\u001B[B", + "\u001B[B", + "\r", + ]; + + const restoreReadSync = spyFn( + Deno.stdin, + "readSync", + (data: Uint8Array) => { + const input = inputs[readIndex++]; + const bytes = encoder.encode(input); + data.set(bytes); + return bytes.length; + }, + ); + + const browser = promptSelect("Please select a browser:", [ + "safari", + "chrome", + "firefox", + ]); + + assertEquals(browser, "firefox"); + + // Restore mocks + restoreWriteSync(); + restoreReadSync(); +}); + +Deno.test("promptSelect() handles arrow up", () => { + const expectedOutput = [ + "Please select a browser:\r\n", + "❯ safari\r\n chrome\r\n firefox\r\n", + "\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K", + " safari\r\n❯ chrome\r\n firefox\r\n", + "\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K", + "❯ safari\r\n chrome\r\n firefox\r\n", + ]; + + let writeIndex = 0; + + const restoreWriteSync = spyFn( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + assertEquals(output, expectedOutput[writeIndex]); + writeIndex++; + return data.length; + }, + ); + + let readIndex = 0; + + const inputs = [ + "\u001B[B", + "\u001B[A", + "\r", + ]; + + const restoreReadSync = spyFn( + Deno.stdin, + "readSync", + (data: Uint8Array) => { + const input = inputs[readIndex++]; + const bytes = encoder.encode(input); + data.set(bytes); + return bytes.length; + }, + ); + + const browser = promptSelect("Please select a browser:", [ + "safari", + "chrome", + "firefox", + ]); + + assertEquals(browser, "safari"); + + // Restore mocks + restoreWriteSync(); + restoreReadSync(); +}); + +Deno.test("promptSelect() handles up index overflow", () => { + const expectedOutput = [ + "Please select a browser:\r\n", + "❯ safari\r\n chrome\r\n firefox\r\n", + "\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K", + " safari\r\n chrome\r\n❯ firefox\r\n", + ]; + + let writeIndex = 0; + + const restoreWriteSync = spyFn( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + assertEquals(output, expectedOutput[writeIndex]); + writeIndex++; + return data.length; + }, + ); + + let readIndex = 0; + + const inputs = [ + "\u001B[A", + "\r", + ]; + + const restoreReadSync = spyFn( + Deno.stdin, + "readSync", + (data: Uint8Array) => { + const input = inputs[readIndex++]; + const bytes = encoder.encode(input); + data.set(bytes); + return bytes.length; + }, + ); + + const browser = promptSelect("Please select a browser:", [ + "safari", + "chrome", + "firefox", + ]); + + assertEquals(browser, "firefox"); + + // Restore mocks + restoreWriteSync(); + restoreReadSync(); +}); + +Deno.test("promptSelect() handles down index overflow", () => { + const expectedOutput = [ + "Please select a browser:\r\n", + "❯ safari\r\n chrome\r\n firefox\r\n", + "\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K", + " safari\r\n❯ chrome\r\n firefox\r\n", + "\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K", + " safari\r\n chrome\r\n❯ firefox\r\n", + "\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K", + "❯ safari\r\n chrome\r\n firefox\r\n", + ]; + + let writeIndex = 0; + + const restoreWriteSync = spyFn( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + assertEquals(output, expectedOutput[writeIndex]); + writeIndex++; + return data.length; + }, + ); + + let readIndex = 0; + + const inputs = [ + "\u001B[B", + "\u001B[B", + "\u001B[B", + "\r", + ]; + + const restoreReadSync = spyFn( + Deno.stdin, + "readSync", + (data: Uint8Array) => { + const input = inputs[readIndex++]; + const bytes = encoder.encode(input); + data.set(bytes); + return bytes.length; + }, + ); + + const browser = promptSelect("Please select a browser:", [ + "safari", + "chrome", + "firefox", + ]); + + assertEquals(browser, "safari"); + + // Restore mocks + restoreWriteSync(); + restoreReadSync(); +}); + +Deno.test("promptSelect() handles clear option", () => { + const expectedOutput = [ + "Please select a browser:\r\n", + "❯ safari\r\n chrome\r\n firefox\r\n", + "\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K", + ]; + + let writeIndex = 0; + + const restoreWriteSync = spyFn( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + assertEquals(output, expectedOutput[writeIndex]); + writeIndex++; + return data.length; + }, + ); + + let readIndex = 0; + + const inputs = [ + "\r", + ]; + + const restoreReadSync = spyFn( + Deno.stdin, + "readSync", + (data: Uint8Array) => { + const input = inputs[readIndex++]; + const bytes = encoder.encode(input); + data.set(bytes); + return bytes.length; + }, + ); + + const browser = promptSelect("Please select a browser:", [ + "safari", + "chrome", + "firefox", + ], { clear: true }); + + assertEquals(browser, "safari"); + + // Restore mocks + restoreWriteSync(); + restoreReadSync(); +}); From d3b8bc5358decbfe7237a50810b713c58980a66f Mon Sep 17 00:00:00 2001 From: Tim Date: Mon, 18 Nov 2024 10:36:45 +0100 Subject: [PATCH 13/15] cleanup --- cli/unstable_prompt_select_test.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/cli/unstable_prompt_select_test.ts b/cli/unstable_prompt_select_test.ts index 1980daea0d99..73200e67895f 100644 --- a/cli/unstable_prompt_select_test.ts +++ b/cli/unstable_prompt_select_test.ts @@ -60,7 +60,6 @@ Deno.test("promptSelect() handles enter", () => { assertEquals(browser, "safari"); - // Restore mocks restoreWriteSync(); restoreReadSync(); }); @@ -115,7 +114,6 @@ Deno.test("promptSelect() handles arrow down", () => { assertEquals(browser, "firefox"); - // Restore mocks restoreWriteSync(); restoreReadSync(); }); @@ -170,7 +168,6 @@ Deno.test("promptSelect() handles arrow up", () => { assertEquals(browser, "safari"); - // Restore mocks restoreWriteSync(); restoreReadSync(); }); @@ -222,7 +219,6 @@ Deno.test("promptSelect() handles up index overflow", () => { assertEquals(browser, "firefox"); - // Restore mocks restoreWriteSync(); restoreReadSync(); }); @@ -280,7 +276,6 @@ Deno.test("promptSelect() handles down index overflow", () => { assertEquals(browser, "safari"); - // Restore mocks restoreWriteSync(); restoreReadSync(); }); @@ -330,7 +325,6 @@ Deno.test("promptSelect() handles clear option", () => { assertEquals(browser, "safari"); - // Restore mocks restoreWriteSync(); restoreReadSync(); }); From 0f2af0582347178fcf903b06e68dd63dc95314b0 Mon Sep 17 00:00:00 2001 From: Tim Date: Mon, 18 Nov 2024 11:10:41 +0100 Subject: [PATCH 14/15] update --- cli/unstable_prompt_select.ts | 4 +- cli/unstable_prompt_select_test.ts | 59 ++++++++++++++++++++++-------- 2 files changed, 45 insertions(+), 18 deletions(-) diff --git a/cli/unstable_prompt_select.ts b/cli/unstable_prompt_select.ts index 3dd05dc048ab..c46970a01dfa 100644 --- a/cli/unstable_prompt_select.ts +++ b/cli/unstable_prompt_select.ts @@ -47,12 +47,10 @@ export function promptSelect( const buffer = new Uint8Array(4); loop: while (true) { - let output = ""; for (const [index, value] of values.entries()) { const start = index === selectedIndex ? INDICATOR : PADDING; - output += `${start} ${value}\r\n`; + Deno.stdout.writeSync(encoder.encode(`${start} ${value}\r\n`)); } - Deno.stdout.writeSync(encoder.encode(output)); const n = Deno.stdin.readSync(buffer); if (n === null || n === 0) break; const input = decoder.decode(buffer.slice(0, n)); diff --git a/cli/unstable_prompt_select_test.ts b/cli/unstable_prompt_select_test.ts index 73200e67895f..0ee58de13a06 100644 --- a/cli/unstable_prompt_select_test.ts +++ b/cli/unstable_prompt_select_test.ts @@ -12,14 +12,17 @@ function spyFn( fn: T[K], ) { const originalFn = target[key]; - target[key] = fn; + if (typeof fn !== "function") throw new Error(`fn is not a target method`); + target[key] = fn.bind(target); return () => target[key] = originalFn; } Deno.test("promptSelect() handles enter", () => { const expectedOutput = [ "Please select a browser:\r\n", - "❯ safari\r\n chrome\r\n firefox\r\n", + "❯ safari\r\n", + " chrome\r\n", + " firefox\r\n", ]; let writeIndex = 0; @@ -67,11 +70,17 @@ Deno.test("promptSelect() handles enter", () => { Deno.test("promptSelect() handles arrow down", () => { const expectedOutput = [ "Please select a browser:\r\n", - "❯ safari\r\n chrome\r\n firefox\r\n", + "❯ safari\r\n", + " chrome\r\n", + " firefox\r\n", "\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K", - " safari\r\n❯ chrome\r\n firefox\r\n", + " safari\r\n", + "❯ chrome\r\n", + " firefox\r\n", "\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K", - " safari\r\n chrome\r\n❯ firefox\r\n", + " safari\r\n", + " chrome\r\n", + "❯ firefox\r\n", ]; let writeIndex = 0; @@ -121,11 +130,17 @@ Deno.test("promptSelect() handles arrow down", () => { Deno.test("promptSelect() handles arrow up", () => { const expectedOutput = [ "Please select a browser:\r\n", - "❯ safari\r\n chrome\r\n firefox\r\n", + "❯ safari\r\n", + " chrome\r\n", + " firefox\r\n", "\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K", - " safari\r\n❯ chrome\r\n firefox\r\n", + " safari\r\n", + "❯ chrome\r\n", + " firefox\r\n", "\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K", - "❯ safari\r\n chrome\r\n firefox\r\n", + "❯ safari\r\n", + " chrome\r\n", + " firefox\r\n", ]; let writeIndex = 0; @@ -175,9 +190,13 @@ Deno.test("promptSelect() handles arrow up", () => { Deno.test("promptSelect() handles up index overflow", () => { const expectedOutput = [ "Please select a browser:\r\n", - "❯ safari\r\n chrome\r\n firefox\r\n", + "❯ safari\r\n", + " chrome\r\n", + " firefox\r\n", "\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K", - " safari\r\n chrome\r\n❯ firefox\r\n", + " safari\r\n", + " chrome\r\n", + "❯ firefox\r\n", ]; let writeIndex = 0; @@ -226,13 +245,21 @@ Deno.test("promptSelect() handles up index overflow", () => { Deno.test("promptSelect() handles down index overflow", () => { const expectedOutput = [ "Please select a browser:\r\n", - "❯ safari\r\n chrome\r\n firefox\r\n", + "❯ safari\r\n", + " chrome\r\n", + " firefox\r\n", "\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K", - " safari\r\n❯ chrome\r\n firefox\r\n", + " safari\r\n", + "❯ chrome\r\n", + " firefox\r\n", "\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K", - " safari\r\n chrome\r\n❯ firefox\r\n", + " safari\r\n", + " chrome\r\n", + "❯ firefox\r\n", "\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K", - "❯ safari\r\n chrome\r\n firefox\r\n", + "❯ safari\r\n", + " chrome\r\n", + " firefox\r\n", ]; let writeIndex = 0; @@ -283,7 +310,9 @@ Deno.test("promptSelect() handles down index overflow", () => { Deno.test("promptSelect() handles clear option", () => { const expectedOutput = [ "Please select a browser:\r\n", - "❯ safari\r\n chrome\r\n firefox\r\n", + "❯ safari\r\n", + " chrome\r\n", + " firefox\r\n", "\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K", ]; From d3a32d71b1a44b29e24ffbaf104f249df819b502 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 20 Nov 2024 19:50:23 +0100 Subject: [PATCH 15/15] update --- cli/unstable_prompt_select_test.ts | 74 +++++++++++++----------------- 1 file changed, 32 insertions(+), 42 deletions(-) diff --git a/cli/unstable_prompt_select_test.ts b/cli/unstable_prompt_select_test.ts index 0ee58de13a06..2aa03c6429d4 100644 --- a/cli/unstable_prompt_select_test.ts +++ b/cli/unstable_prompt_select_test.ts @@ -1,23 +1,15 @@ // Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. -import { assertEquals } from "../assert/equals.ts"; +import { assertEquals } from "@std/assert/equals"; import { promptSelect } from "./unstable_prompt_select.ts"; +import { restore, stub } from "@std/testing/mock"; const encoder = new TextEncoder(); const decoder = new TextDecoder(); -function spyFn( - target: T, - key: K, - fn: T[K], -) { - const originalFn = target[key]; - if (typeof fn !== "function") throw new Error(`fn is not a target method`); - target[key] = fn.bind(target); - return () => target[key] = originalFn; -} - Deno.test("promptSelect() handles enter", () => { + stub(Deno.stdin, "setRaw"); + const expectedOutput = [ "Please select a browser:\r\n", "❯ safari\r\n", @@ -27,7 +19,7 @@ Deno.test("promptSelect() handles enter", () => { let writeIndex = 0; - const restoreWriteSync = spyFn( + stub( Deno.stdout, "writeSync", (data: Uint8Array) => { @@ -44,7 +36,7 @@ Deno.test("promptSelect() handles enter", () => { "\r", ]; - const restoreReadSync = spyFn( + stub( Deno.stdin, "readSync", (data: Uint8Array) => { @@ -62,12 +54,12 @@ Deno.test("promptSelect() handles enter", () => { ]); assertEquals(browser, "safari"); - - restoreWriteSync(); - restoreReadSync(); + restore(); }); Deno.test("promptSelect() handles arrow down", () => { + stub(Deno.stdin, "setRaw"); + const expectedOutput = [ "Please select a browser:\r\n", "❯ safari\r\n", @@ -85,7 +77,7 @@ Deno.test("promptSelect() handles arrow down", () => { let writeIndex = 0; - const restoreWriteSync = spyFn( + stub( Deno.stdout, "writeSync", (data: Uint8Array) => { @@ -104,7 +96,7 @@ Deno.test("promptSelect() handles arrow down", () => { "\r", ]; - const restoreReadSync = spyFn( + stub( Deno.stdin, "readSync", (data: Uint8Array) => { @@ -122,12 +114,12 @@ Deno.test("promptSelect() handles arrow down", () => { ]); assertEquals(browser, "firefox"); - - restoreWriteSync(); - restoreReadSync(); + restore(); }); Deno.test("promptSelect() handles arrow up", () => { + stub(Deno.stdin, "setRaw"); + const expectedOutput = [ "Please select a browser:\r\n", "❯ safari\r\n", @@ -145,7 +137,7 @@ Deno.test("promptSelect() handles arrow up", () => { let writeIndex = 0; - const restoreWriteSync = spyFn( + stub( Deno.stdout, "writeSync", (data: Uint8Array) => { @@ -164,7 +156,7 @@ Deno.test("promptSelect() handles arrow up", () => { "\r", ]; - const restoreReadSync = spyFn( + stub( Deno.stdin, "readSync", (data: Uint8Array) => { @@ -182,12 +174,12 @@ Deno.test("promptSelect() handles arrow up", () => { ]); assertEquals(browser, "safari"); - - restoreWriteSync(); - restoreReadSync(); + restore(); }); Deno.test("promptSelect() handles up index overflow", () => { + stub(Deno.stdin, "setRaw"); + const expectedOutput = [ "Please select a browser:\r\n", "❯ safari\r\n", @@ -201,7 +193,7 @@ Deno.test("promptSelect() handles up index overflow", () => { let writeIndex = 0; - const restoreWriteSync = spyFn( + stub( Deno.stdout, "writeSync", (data: Uint8Array) => { @@ -219,7 +211,7 @@ Deno.test("promptSelect() handles up index overflow", () => { "\r", ]; - const restoreReadSync = spyFn( + stub( Deno.stdin, "readSync", (data: Uint8Array) => { @@ -237,12 +229,12 @@ Deno.test("promptSelect() handles up index overflow", () => { ]); assertEquals(browser, "firefox"); - - restoreWriteSync(); - restoreReadSync(); + restore(); }); Deno.test("promptSelect() handles down index overflow", () => { + stub(Deno.stdin, "setRaw"); + const expectedOutput = [ "Please select a browser:\r\n", "❯ safari\r\n", @@ -264,7 +256,7 @@ Deno.test("promptSelect() handles down index overflow", () => { let writeIndex = 0; - const restoreWriteSync = spyFn( + stub( Deno.stdout, "writeSync", (data: Uint8Array) => { @@ -284,7 +276,7 @@ Deno.test("promptSelect() handles down index overflow", () => { "\r", ]; - const restoreReadSync = spyFn( + stub( Deno.stdin, "readSync", (data: Uint8Array) => { @@ -302,12 +294,12 @@ Deno.test("promptSelect() handles down index overflow", () => { ]); assertEquals(browser, "safari"); - - restoreWriteSync(); - restoreReadSync(); + restore(); }); Deno.test("promptSelect() handles clear option", () => { + stub(Deno.stdin, "setRaw"); + const expectedOutput = [ "Please select a browser:\r\n", "❯ safari\r\n", @@ -318,7 +310,7 @@ Deno.test("promptSelect() handles clear option", () => { let writeIndex = 0; - const restoreWriteSync = spyFn( + stub( Deno.stdout, "writeSync", (data: Uint8Array) => { @@ -335,7 +327,7 @@ Deno.test("promptSelect() handles clear option", () => { "\r", ]; - const restoreReadSync = spyFn( + stub( Deno.stdin, "readSync", (data: Uint8Array) => { @@ -353,7 +345,5 @@ Deno.test("promptSelect() handles clear option", () => { ], { clear: true }); assertEquals(browser, "safari"); - - restoreWriteSync(); - restoreReadSync(); + restore(); });