diff --git a/.changeset/curvy-seals-sit.md b/.changeset/curvy-seals-sit.md new file mode 100644 index 00000000..69ce6dae --- /dev/null +++ b/.changeset/curvy-seals-sit.md @@ -0,0 +1,6 @@ +--- +"@clack/prompts": minor +"@clack/core": minor +--- + +Adds suggestion and path prompts diff --git a/examples/basic/package.json b/examples/basic/package.json index 410a7fab..f8e617e7 100644 --- a/examples/basic/package.json +++ b/examples/basic/package.json @@ -13,6 +13,7 @@ "stream": "jiti ./stream.ts", "progress": "jiti ./progress.ts", "spinner": "jiti ./spinner.ts", + "path": "jiti ./path.ts", "spinner-ci": "npx cross-env CI=\"true\" jiti ./spinner-ci.ts", "spinner-timer": "jiti ./spinner-timer.ts", "task-log": "jiti ./task-log.ts" diff --git a/examples/basic/path.ts b/examples/basic/path.ts new file mode 100644 index 00000000..644afd04 --- /dev/null +++ b/examples/basic/path.ts @@ -0,0 +1,13 @@ +import * as p from '@clack/prompts'; + +async function demo() { + p.intro('path start...'); + + const path = await p.path({ + message: 'Read file', + }); + + p.outro('path stop...'); +} + +void demo(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ca4103f4..569f506d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,4 @@ -export type { ClackState as State } from './types.js'; +export type { ClackState as State, ValueWithCursorPart } from './types.js'; export type { ClackSettings } from './utils/settings.js'; export { default as ConfirmPrompt } from './prompts/confirm.js'; @@ -10,5 +10,6 @@ export { default as SelectPrompt } from './prompts/select.js'; export { default as SelectKeyPrompt } from './prompts/select-key.js'; export { default as TextPrompt } from './prompts/text.js'; export { default as AutocompletePrompt } from './prompts/autocomplete.js'; +export { default as SuggestionPrompt } from './prompts/suggestion.js'; export { block, isCancel, getColumns } from './utils/index.js'; export { updateSettings, settings } from './utils/settings.js'; diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index 7e58b866..d213a60d 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -26,7 +26,7 @@ export default class Prompt { protected output: Writable; private _abortSignal?: AbortSignal; - private rl: ReadLine | undefined; + protected rl: ReadLine | undefined; private opts: Omit, 'render' | 'input' | 'output'>; private _render: (context: Omit) => string | undefined; private _track = false; diff --git a/packages/core/src/prompts/suggestion.ts b/packages/core/src/prompts/suggestion.ts new file mode 100644 index 00000000..f06f80f1 --- /dev/null +++ b/packages/core/src/prompts/suggestion.ts @@ -0,0 +1,114 @@ +import color from 'picocolors'; +import type { ValueWithCursorPart } from '../types.js'; +import Prompt, { type PromptOptions } from './prompt.js'; + +interface SuggestionOptions extends PromptOptions { + suggest: (value: string) => Array; + initialValue: string; +} + +export default class SuggestionPrompt extends Prompt { + value: string; + protected suggest: (value: string) => Array; + private selectionIndex = 0; + private nextItems: Array = []; + + constructor(opts: SuggestionOptions) { + super(opts); + + this.value = opts.initialValue; + this.suggest = opts.suggest; + this.getNextItems(); + this.selectionIndex = 0; + this._cursor = this.value.length; + + this.on('cursor', (key) => { + switch (key) { + case 'up': + this.selectionIndex = Math.max( + 0, + this.selectionIndex === 0 ? this.nextItems.length - 1 : this.selectionIndex - 1 + ); + break; + case 'down': + this.selectionIndex = + this.nextItems.length === 0 ? 0 : (this.selectionIndex + 1) % this.nextItems.length; + break; + } + }); + this.on('key', (key, info) => { + if (info.name === 'tab' && this.nextItems.length > 0) { + const delta = this.nextItems[this.selectionIndex].substring(this.value.length); + this.value = this.nextItems[this.selectionIndex]; + this.rl?.write(delta); + this._cursor = this.value.length; + this.selectionIndex = 0; + this.getNextItems(); + } + }); + this.on('value', () => { + this.getNextItems(); + }); + } + + get displayValue(): Array { + const result: Array = []; + if (this._cursor > 0) { + result.push({ + text: this.value.substring(0, this._cursor), + type: 'value', + }); + } + if (this._cursor < this.value.length) { + result.push({ + text: this.value.substring(this._cursor, this._cursor + 1), + type: 'cursor_on_value', + }); + const left = this.value.substring(this._cursor + 1); + if (left.length > 0) { + result.push({ + text: left, + type: 'value', + }); + } + if (this.suggestion.length > 0) { + result.push({ + text: this.suggestion, + type: 'suggestion', + }); + } + return result; + } + if (this.suggestion.length === 0) { + result.push({ + text: '\u00A0', + type: 'cursor_on_value', + }); + return result; + } + result.push( + { + text: this.suggestion[0], + type: 'cursor_on_suggestion', + }, + { + text: this.suggestion.substring(1), + type: 'suggestion', + } + ); + return result; + } + + get suggestion(): string { + return this.nextItems[this.selectionIndex]?.substring(this.value.length) ?? ''; + } + + private getNextItems(): void { + this.nextItems = this.suggest(this.value).filter((item) => { + return item.startsWith(this.value) && item !== this.value; + }); + if (this.selectionIndex > this.nextItems.length) { + this.selectionIndex = 0; + } + } +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 3250177c..27397f17 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -21,3 +21,11 @@ export interface ClackEvents { confirm: (value?: boolean) => void; finalize: () => void; } + +/** + * Display a value + */ +export interface ValueWithCursorPart { + text: string; + type: 'value' | 'cursor_on_value' | 'suggestion' | 'cursor_on_suggestion'; +} diff --git a/packages/core/test/prompts/suggestion.test.ts b/packages/core/test/prompts/suggestion.test.ts new file mode 100644 index 00000000..1551add3 --- /dev/null +++ b/packages/core/test/prompts/suggestion.test.ts @@ -0,0 +1,226 @@ +import color from 'picocolors'; +import { cursor } from 'sisteransi'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { default as SelectPrompt } from '../../src/prompts/select.js'; +import { default as SuggestionPrompt } from '../../src/prompts/suggestion.js'; +import { MockReadable } from '../mock-readable.js'; +import { MockWritable } from '../mock-writable.js'; + +describe(SuggestionPrompt.name, () => { + let input: MockReadable; + let output: MockWritable; + + beforeEach(() => { + input = new MockReadable(); + output = new MockWritable(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('displayValue getter return all parts/cases', () => { + test('no suggestion, cursor at the end', () => { + const instance = new SuggestionPrompt({ + input, + output, + suggest: () => [], + initialValue: 'Lorem ipsum', + render: () => 'Lorem ipsum', + }); + // leave the promise hanging since we don't want to submit in this test + instance.prompt(); + expect(instance.displayValue).to.deep.equal([ + { text: 'Lorem ipsum', type: 'value' }, + { text: '\u00a0', type: 'cursor_on_value' }, + ]); + }); + + test('no suggestion, cursor at the start', () => { + const instance = new SuggestionPrompt({ + input, + output, + suggest: () => [], + initialValue: 'Lorem', + render: () => 'Lorem', + }); + // leave the promise hanging since we don't want to submit in this test + instance.prompt(); + for (let index = 0; index < 5; index++) input.emit('keypress', '', { name: 'left' }); + expect(instance.displayValue).to.deep.equal([ + { text: 'L', type: 'cursor_on_value' }, + { text: 'orem', type: 'value' }, + ]); + }); + test('no suggestion, cursor in the middle', () => { + const instance = new SuggestionPrompt({ + input, + output, + suggest: () => [], + initialValue: 'Lorem', + render: () => 'Lorem', + }); + // leave the promise hanging since we don't want to submit in this test + instance.prompt(); + for (let index = 0; index < 3; index++) input.emit('keypress', '', { name: 'left' }); + expect(instance.displayValue).to.deep.equal([ + { text: 'Lo', type: 'value' }, + { text: 'r', type: 'cursor_on_value' }, + { text: 'em', type: 'value' }, + ]); + }); + test('no suggestion, cursor on the last letter', () => { + const instance = new SuggestionPrompt({ + input, + output, + suggest: () => [], + initialValue: 'Lorem', + render: () => 'Lorem', + }); + // leave the promise hanging since we don't want to submit in this test + instance.prompt(); + input.emit('keypress', '', { name: 'left' }); + expect(instance.displayValue).to.deep.equal([ + { text: 'Lore', type: 'value' }, + { text: 'm', type: 'cursor_on_value' }, + ]); + }); + test('with suggestion, cursor at the end', () => { + const instance = new SuggestionPrompt({ + input, + output, + suggest: () => ['Lorem ipsum dolor sit amet, consectetur adipiscing elit'], + initialValue: 'Lorem ipsum dolor sit amet', + render: () => 'Lorem ipsum dolor sit amet', + }); + // leave the promise hanging since we don't want to submit in this test + instance.prompt(); + expect(instance.displayValue).to.deep.equal([ + { text: 'Lorem ipsum dolor sit amet', type: 'value' }, + { text: ',', type: 'cursor_on_suggestion' }, + { text: ' consectetur adipiscing elit', type: 'suggestion' }, + ]); + }); + test('with suggestion, cursor not at the end', () => { + const instance = new SuggestionPrompt({ + input, + output, + suggest: () => ['Lorem ipsum dolor sit amet, consectetur adipiscing elit'], + initialValue: 'Lorem ipsum dolor sit amet', + render: () => 'Lorem ipsum dolor sit amet', + }); + // leave the promise hanging since we don't want to submit in this test + instance.prompt(); + for (let index = 0; index < 3; index++) input.emit('keypress', '', { name: 'left' }); + expect(instance.displayValue).to.deep.equal([ + { text: 'Lorem ipsum dolor sit a', type: 'value' }, + { text: 'm', type: 'cursor_on_value' }, + { text: 'et', type: 'value' }, + { text: ', consectetur adipiscing elit', type: 'suggestion' }, + ]); + }); + }); + describe('navigate suggestion', () => { + test('the default is the first suggestion', () => { + const instance = new SuggestionPrompt({ + input, + output, + suggest: () => ['foobar', 'foobaz'], + initialValue: 'foo', + render: () => 'foo', + }); + // leave the promise hanging since we don't want to submit in this test + instance.prompt(); + expect(instance.suggestion).to.be.equal('bar'); + }); + test('down display next suggestion', () => { + const instance = new SuggestionPrompt({ + input, + output, + suggest: () => ['foobar', 'foobaz'], + initialValue: 'foo', + render: () => 'foo', + }); + // leave the promise hanging since we don't want to submit in this test + instance.prompt(); + input.emit('keypress', '', { name: 'down' }); + + expect(instance.suggestion).to.be.equal('baz'); + }); + test('suggestions loops (down)', () => { + const instance = new SuggestionPrompt({ + input, + output, + suggest: () => ['foobar', 'foobaz'], + initialValue: 'foo', + render: () => 'foo', + }); + // leave the promise hanging since we don't want to submit in this test + instance.prompt(); + expect(instance.suggestion).to.be.equal('bar'); + input.emit('keypress', '', { name: 'down' }); + expect(instance.suggestion).to.be.equal('baz'); + input.emit('keypress', '', { name: 'down' }); + expect(instance.suggestion).to.be.equal('bar'); + }); + + test('suggestions loops (up)', () => { + const instance = new SuggestionPrompt({ + input, + output, + suggest: () => ['foobar', 'foobaz'], + initialValue: 'foo', + render: () => 'foo', + }); + // leave the promise hanging since we don't want to submit in this test + instance.prompt(); + expect(instance.suggestion).to.be.equal('bar'); + input.emit('keypress', '', { name: 'up' }); + expect(instance.suggestion).to.be.equal('baz'); + input.emit('keypress', '', { name: 'up' }); + expect(instance.suggestion).to.be.equal('bar'); + }); + }); + test('tab validate suggestion', () => { + const instance = new SuggestionPrompt({ + input, + output, + suggest: () => ['foobar', 'foobaz'], + initialValue: 'foo', + render: () => 'foo', + }); + // leave the promise hanging since we don't want to submit in this test + instance.prompt(); + expect(instance.suggestion).to.be.equal('bar'); + expect(instance.value).to.be.equal('foo'); + input.emit('keypress', '', { name: 'tab' }); + expect(instance.suggestion).to.be.equal(''); + expect(instance.value).to.be.equal('foobar'); + }); + describe('suggestion are filtered', () => { + test("suggestion that don't match (begin) at not displayed", () => { + const instance = new SuggestionPrompt({ + input, + output, + suggest: () => ['foobar', 'foobaz', 'hello world'], + initialValue: 'foo', + render: () => 'foo', + }); + // leave the promise hanging since we don't want to submit in this test + instance.prompt(); + expect((instance as unknown as { nextItems: Array }).nextItems.length).to.be.equal(2); + }); + test('empty suggestions are removed', () => { + const instance = new SuggestionPrompt({ + input, + output, + suggest: () => ['foo'], + initialValue: 'foo', + render: () => 'foo', + }); + // leave the promise hanging since we don't want to submit in this test + instance.prompt(); + expect((instance as unknown as { nextItems: Array }).nextItems.length).to.be.equal(0); + }); + }); +}); diff --git a/packages/prompts/__mocks__/fs.cjs b/packages/prompts/__mocks__/fs.cjs new file mode 100644 index 00000000..3c1dd612 --- /dev/null +++ b/packages/prompts/__mocks__/fs.cjs @@ -0,0 +1,2 @@ +const { fs } = require('memfs'); +module.exports = fs; diff --git a/packages/prompts/package.json b/packages/prompts/package.json index 45edd3cd..676a7e46 100644 --- a/packages/prompts/package.json +++ b/packages/prompts/package.json @@ -56,6 +56,7 @@ }, "devDependencies": { "is-unicode-supported": "^1.3.0", + "memfs": "^4.17.1", "vitest": "^3.1.1", "vitest-ansi-serializer": "^0.1.2" } diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index c77046c7..e316ef65 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -11,11 +11,13 @@ export * from './messages.js'; export * from './multi-select.js'; export * from './note.js'; export * from './password.js'; +export * from './path.js'; export * from './progress-bar.js'; export * from './select-key.js'; export * from './select.js'; export * from './spinner.js'; export * from './stream.js'; +export * from './suggestion.js'; export * from './task.js'; export * from './task-log.js'; export * from './text.js'; diff --git a/packages/prompts/src/path.ts b/packages/prompts/src/path.ts new file mode 100644 index 00000000..b1e04b14 --- /dev/null +++ b/packages/prompts/src/path.ts @@ -0,0 +1,44 @@ +import { existsSync, lstatSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { dirname } from 'knip/dist/util/path.js'; +import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js'; +import { suggestion } from './suggestion.js'; + +export interface PathOptions extends CommonOptions { + root?: string; + directory?: boolean; + initialValue?: string; + message: string; + validate?: (value: string) => string | Error | undefined; +} + +export const path = (opts: PathOptions) => { + return suggestion({ + ...opts, + initialValue: opts.initialValue ?? opts.root ?? process.cwd(), + suggest: (value: string) => { + try { + const searchPath = !existsSync(value) ? dirname(value) : value; + if (!lstatSync(searchPath).isDirectory()) { + return []; + } + const items = readdirSync(searchPath) + .map((item) => { + const path = join(searchPath, item); + const stats = lstatSync(path); + return { + name: item, + path, + isDirectory: stats.isDirectory(), + }; + }) + .filter(({ path }) => path.startsWith(value)); + return ((opts.directory ?? false) ? items.filter((item) => item.isDirectory) : items).map( + ({ path }) => path + ); + } catch (e) { + return []; + } + }, + }); +}; diff --git a/packages/prompts/src/suggestion.ts b/packages/prompts/src/suggestion.ts new file mode 100644 index 00000000..885524fa --- /dev/null +++ b/packages/prompts/src/suggestion.ts @@ -0,0 +1,50 @@ +import { SuggestionPrompt, type ValueWithCursorPart } from '@clack/core'; +import color from 'picocolors'; +import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js'; + +export interface SuggestionOptions extends CommonOptions { + initialValue?: string; + message: string; + validate?: (value: string) => string | Error | undefined; + suggest: (value: string) => Array; +} + +export const suggestion = (opts: SuggestionOptions) => { + return new SuggestionPrompt({ + initialValue: opts.initialValue ?? '', + output: opts.output, + input: opts.input, + validate: opts.validate, + suggest: opts.suggest, + render() { + const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; + const value = this.displayValue.reduce((text: string, line: ValueWithCursorPart) => { + switch (line.type) { + case 'value': + return text + line.text; + case 'cursor_on_value': + return text + color.inverse(line.text); + case 'suggestion': + return text + color.gray(line.text); + case 'cursor_on_suggestion': + return text + color.inverse(color.gray(line.text)); + } + }, ''); + + switch (this.state) { + case 'error': + return `${title.trim()}\n${color.yellow(S_BAR)} ${value}\n${color.yellow( + S_BAR_END + )} ${color.yellow(this.error)}\n`; + case 'submit': + return `${title}${color.gray(S_BAR)} ${color.dim(this.value)}`; + case 'cancel': + return `${title}${color.gray(S_BAR)} ${color.strikethrough( + color.dim(this.value ?? '') + )}${this.value?.trim() ? `\n${color.gray(S_BAR)}` : ''}`; + default: + return `${title}${color.cyan(S_BAR)} ${value}\n${color.cyan(S_BAR_END)}\n`; + } + }, + }).prompt() as Promise; +}; diff --git a/packages/prompts/test/__snapshots__/path.test.ts.snap b/packages/prompts/test/__snapshots__/path.test.ts.snap new file mode 100644 index 00000000..ce1138df --- /dev/null +++ b/packages/prompts/test/__snapshots__/path.test.ts.snap @@ -0,0 +1,455 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`text (isCI = false) > can cancel 1`] = ` +[ + "", + "│ +◆ foo +│ /tmp/foo +└ +", + "", + "", + "", + "■ foo +│ /tmp/ +│", + " +", + "", +] +`; + +exports[`text (isCI = false) > initialValue sets the value 1`] = ` +[ + "", + "│ +◆ foo +│ /tmp/bar  +└ +", + "", + "", + "", + "◇ foo +│ /tmp/bar", + " +", + "", +] +`; + +exports[`text (isCI = false) > renders and apply () suggestion 1`] = ` +[ + "", + "│ +◆ foo +│ /tmp/foo +└ +", + "", + "", + "", + "│ /tmp/foo/bar.txt", + "", + "", + "", + "", + "◇ foo +│ /tmp/foo", + " +", + "", +] +`; + +exports[`text (isCI = false) > renders cancelled value if one set 1`] = ` +[ + "", + "│ +◆ foo +│ /tmp/foo +└ +", + "", + "", + "", + "│ /tmp/x ", + "", + "", + "", + "", + "│ /tmp/xy ", + "", + "", + "", + "", + "■ foo +│ /tmp/xy +│", + " +", + "", +] +`; + +exports[`text (isCI = false) > renders message 1`] = ` +[ + "", + "│ +◆ foo +│ /tmp/foo +└ +", + "", + "", + "", + "◇ foo +│ /tmp/", + " +", + "", +] +`; + +exports[`text (isCI = false) > renders submitted value 1`] = ` +[ + "", + "│ +◆ foo +│ /tmp/foo +└ +", + "", + "", + "", + "│ /tmp/x ", + "", + "", + "", + "", + "│ /tmp/xy ", + "", + "", + "", + "", + "◇ foo +│ /tmp/xy", + " +", + "", +] +`; + +exports[`text (isCI = false) > validation errors render and clear (using Error) 1`] = ` +[ + "", + "│ +◆ foo +│ /tmp/foo +└ +", + "", + "", + "", + "│ /tmp/b ", + "", + "", + "", + "", + "▲ foo +│ /tmp/b  +└ should be /tmp/bar +", + "", + "", + "", + "◆ foo +│ /tmp/ba  +└ +", + "", + "", + "", + "│ /tmp/bar ", + "", + "", + "", + "", + "◇ foo +│ /tmp/bar", + " +", + "", +] +`; + +exports[`text (isCI = false) > validation errors render and clear 1`] = ` +[ + "", + "│ +◆ foo +│ /tmp/foo +└ +", + "", + "", + "", + "│ /tmp/b ", + "", + "", + "", + "", + "▲ foo +│ /tmp/b  +└ should be /tmp/bar +", + "", + "", + "", + "◆ foo +│ /tmp/ba  +└ +", + "", + "", + "", + "│ /tmp/bar ", + "", + "", + "", + "", + "◇ foo +│ /tmp/bar", + " +", + "", +] +`; + +exports[`text (isCI = true) > can cancel 1`] = ` +[ + "", + "│ +◆ foo +│ /tmp/foo +└ +", + "", + "", + "", + "■ foo +│ /tmp/ +│", + " +", + "", +] +`; + +exports[`text (isCI = true) > initialValue sets the value 1`] = ` +[ + "", + "│ +◆ foo +│ /tmp/bar  +└ +", + "", + "", + "", + "◇ foo +│ /tmp/bar", + " +", + "", +] +`; + +exports[`text (isCI = true) > renders and apply () suggestion 1`] = ` +[ + "", + "│ +◆ foo +│ /tmp/foo +└ +", + "", + "", + "", + "│ /tmp/foo/bar.txt", + "", + "", + "", + "", + "◇ foo +│ /tmp/foo", + " +", + "", +] +`; + +exports[`text (isCI = true) > renders cancelled value if one set 1`] = ` +[ + "", + "│ +◆ foo +│ /tmp/foo +└ +", + "", + "", + "", + "│ /tmp/x ", + "", + "", + "", + "", + "│ /tmp/xy ", + "", + "", + "", + "", + "■ foo +│ /tmp/xy +│", + " +", + "", +] +`; + +exports[`text (isCI = true) > renders message 1`] = ` +[ + "", + "│ +◆ foo +│ /tmp/foo +└ +", + "", + "", + "", + "◇ foo +│ /tmp/", + " +", + "", +] +`; + +exports[`text (isCI = true) > renders submitted value 1`] = ` +[ + "", + "│ +◆ foo +│ /tmp/foo +└ +", + "", + "", + "", + "│ /tmp/x ", + "", + "", + "", + "", + "│ /tmp/xy ", + "", + "", + "", + "", + "◇ foo +│ /tmp/xy", + " +", + "", +] +`; + +exports[`text (isCI = true) > validation errors render and clear (using Error) 1`] = ` +[ + "", + "│ +◆ foo +│ /tmp/foo +└ +", + "", + "", + "", + "│ /tmp/b ", + "", + "", + "", + "", + "▲ foo +│ /tmp/b  +└ should be /tmp/bar +", + "", + "", + "", + "◆ foo +│ /tmp/ba  +└ +", + "", + "", + "", + "│ /tmp/bar ", + "", + "", + "", + "", + "◇ foo +│ /tmp/bar", + " +", + "", +] +`; + +exports[`text (isCI = true) > validation errors render and clear 1`] = ` +[ + "", + "│ +◆ foo +│ /tmp/foo +└ +", + "", + "", + "", + "│ /tmp/b ", + "", + "", + "", + "", + "▲ foo +│ /tmp/b  +└ should be /tmp/bar +", + "", + "", + "", + "◆ foo +│ /tmp/ba  +└ +", + "", + "", + "", + "│ /tmp/bar ", + "", + "", + "", + "", + "◇ foo +│ /tmp/bar", + " +", + "", +] +`; diff --git a/packages/prompts/test/__snapshots__/suggestion.test.ts.snap b/packages/prompts/test/__snapshots__/suggestion.test.ts.snap new file mode 100644 index 00000000..e1f2aeeb --- /dev/null +++ b/packages/prompts/test/__snapshots__/suggestion.test.ts.snap @@ -0,0 +1,433 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`text (isCI = false) > can cancel 1`] = ` +[ + "", + "│ +◆ foo +│   +└ +", + "", + "", + "", + "■ foo +│", + " +", + "", +] +`; + +exports[`text (isCI = false) > initialValue sets the value 1`] = ` +[ + "", + "│ +◆ foo +│ bar  +└ +", + "", + "", + "", + "◇ foo +│ bar", + " +", + "", +] +`; + +exports[`text (isCI = false) > renders and apply () suggestion 1`] = ` +[ + "", + "│ +◆ foo +│ bar +└ +", + "", + "", + "", + "│ bar ", + "", + "", + "", + "", + "◇ foo +│ bar", + " +", + "", +] +`; + +exports[`text (isCI = false) > renders cancelled value if one set 1`] = ` +[ + "", + "│ +◆ foo +│ xyz +└ +", + "", + "", + "", + "│ xyz", + "", + "", + "", + "", + "│ xyz", + "", + "", + "", + "", + "■ foo +│ xy +│", + " +", + "", +] +`; + +exports[`text (isCI = false) > renders message 1`] = ` +[ + "", + "│ +◆ foo +│   +└ +", + "", + "", + "", + "◇ foo +│", + " +", + "", +] +`; + +exports[`text (isCI = false) > renders submitted value 1`] = ` +[ + "", + "│ +◆ foo +│ xyz +└ +", + "", + "", + "", + "│ xyz", + "", + "", + "", + "", + "│ xyz", + "", + "", + "", + "", + "◇ foo +│ xy", + " +", + "", +] +`; + +exports[`text (isCI = false) > validation errors render and clear (using Error) 1`] = ` +[ + "", + "│ +◆ foo +│ xyz +└ +", + "", + "", + "", + "│ xyz", + "", + "", + "", + "", + "▲ foo +│ xyz +└ should be xy +", + "", + "", + "", + "◆ foo +│ xyz +└ +", + "", + "", + "", + "◇ foo +│ xy", + " +", + "", +] +`; + +exports[`text (isCI = false) > validation errors render and clear 1`] = ` +[ + "", + "│ +◆ foo +│ xyz +└ +", + "", + "", + "", + "│ xyz", + "", + "", + "", + "", + "▲ foo +│ xyz +└ should be xy +", + "", + "", + "", + "◆ foo +│ xyz +└ +", + "", + "", + "", + "◇ foo +│ xy", + " +", + "", +] +`; + +exports[`text (isCI = true) > can cancel 1`] = ` +[ + "", + "│ +◆ foo +│   +└ +", + "", + "", + "", + "■ foo +│", + " +", + "", +] +`; + +exports[`text (isCI = true) > initialValue sets the value 1`] = ` +[ + "", + "│ +◆ foo +│ bar  +└ +", + "", + "", + "", + "◇ foo +│ bar", + " +", + "", +] +`; + +exports[`text (isCI = true) > renders and apply () suggestion 1`] = ` +[ + "", + "│ +◆ foo +│ bar +└ +", + "", + "", + "", + "│ bar ", + "", + "", + "", + "", + "◇ foo +│ bar", + " +", + "", +] +`; + +exports[`text (isCI = true) > renders cancelled value if one set 1`] = ` +[ + "", + "│ +◆ foo +│ xyz +└ +", + "", + "", + "", + "│ xyz", + "", + "", + "", + "", + "│ xyz", + "", + "", + "", + "", + "■ foo +│ xy +│", + " +", + "", +] +`; + +exports[`text (isCI = true) > renders message 1`] = ` +[ + "", + "│ +◆ foo +│   +└ +", + "", + "", + "", + "◇ foo +│", + " +", + "", +] +`; + +exports[`text (isCI = true) > renders submitted value 1`] = ` +[ + "", + "│ +◆ foo +│ xyz +└ +", + "", + "", + "", + "│ xyz", + "", + "", + "", + "", + "│ xyz", + "", + "", + "", + "", + "◇ foo +│ xy", + " +", + "", +] +`; + +exports[`text (isCI = true) > validation errors render and clear (using Error) 1`] = ` +[ + "", + "│ +◆ foo +│ xyz +└ +", + "", + "", + "", + "│ xyz", + "", + "", + "", + "", + "▲ foo +│ xyz +└ should be xy +", + "", + "", + "", + "◆ foo +│ xyz +└ +", + "", + "", + "", + "◇ foo +│ xy", + " +", + "", +] +`; + +exports[`text (isCI = true) > validation errors render and clear 1`] = ` +[ + "", + "│ +◆ foo +│ xyz +└ +", + "", + "", + "", + "│ xyz", + "", + "", + "", + "", + "▲ foo +│ xyz +└ should be xy +", + "", + "", + "", + "◆ foo +│ xyz +└ +", + "", + "", + "", + "◇ foo +│ xy", + " +", + "", +] +`; diff --git a/packages/prompts/test/path.test.ts b/packages/prompts/test/path.test.ts new file mode 100644 index 00000000..52df1d8b --- /dev/null +++ b/packages/prompts/test/path.test.ts @@ -0,0 +1,186 @@ +import { fs, vol } from 'memfs'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; +import * as prompts from '../src/index.js'; +import { MockReadable, MockWritable } from './test-utils.js'; + +vi.mock('node:fs'); + +describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { + let originalCI: string | undefined; + let output: MockWritable; + let input: MockReadable; + + beforeAll(() => { + originalCI = process.env.CI; + process.env.CI = isCI; + }); + + afterAll(() => { + process.env.CI = originalCI; + }); + + beforeEach(() => { + output = new MockWritable(); + input = new MockReadable(); + vol.reset(); + vol.fromJSON( + { + './foo/bar.txt': '1', + './foo/baz.text': '2', + './hello/world.jpg': '3', + './hello/john.jpg': '4', + './hello/jeanne.png': '5', + './root.zip': '6', + }, + '/tmp' + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('renders message', async () => { + const result = prompts.path({ + message: 'foo', + input, + output, + root: '/tmp/', + }); + + input.emit('keypress', '', { name: 'return' }); + + await result; + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders and apply () suggestion', async () => { + const result = prompts.path({ + message: 'foo', + root: '/tmp', + input, + output, + }); + + input.emit('keypress', '\t', { name: 'tab' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(output.buffer).toMatchSnapshot(); + + expect(value).toBe('/tmp/foo'); + }); + + test('can cancel', async () => { + const result = prompts.path({ + message: 'foo', + root: '/tmp/', + input, + output, + }); + + input.emit('keypress', 'escape', { name: 'escape' }); + + const value = await result; + + expect(prompts.isCancel(value)).toBe(true); + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders cancelled value if one set', async () => { + const result = prompts.path({ + message: 'foo', + input, + output, + root: '/tmp/', + }); + + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', 'y', { name: 'y' }); + input.emit('keypress', '', { name: 'escape' }); + + const value = await result; + + expect(prompts.isCancel(value)).toBe(true); + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders submitted value', async () => { + const result = prompts.path({ + message: 'foo', + root: '/tmp/', + input, + output, + }); + + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', 'y', { name: 'y' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('/tmp/xy'); + expect(output.buffer).toMatchSnapshot(); + }); + + test('initialValue sets the value', async () => { + const result = prompts.path({ + message: 'foo', + initialValue: '/tmp/bar', + root: '/tmp/', + input, + output, + }); + + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('/tmp/bar'); + expect(output.buffer).toMatchSnapshot(); + }); + + test('validation errors render and clear', async () => { + const result = prompts.path({ + message: 'foo', + root: '/tmp/', + validate: (val) => (val !== '/tmp/bar' ? 'should be /tmp/bar' : undefined), + input, + output, + }); + + input.emit('keypress', 'b', { name: 'b' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', 'a', { name: 'a' }); + input.emit('keypress', 'r', { name: 'r' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('/tmp/bar'); + expect(output.buffer).toMatchSnapshot(); + }); + + test('validation errors render and clear (using Error)', async () => { + const result = prompts.path({ + message: 'foo', + root: '/tmp/', + validate: (val) => (val !== '/tmp/bar' ? new Error('should be /tmp/bar') : undefined), + input, + output, + }); + + input.emit('keypress', 'b', { name: 'b' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', 'a', { name: 'a' }); + input.emit('keypress', 'r', { name: 'r' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('/tmp/bar'); + expect(output.buffer).toMatchSnapshot(); + }); +}); diff --git a/packages/prompts/test/suggestion.test.ts b/packages/prompts/test/suggestion.test.ts new file mode 100644 index 00000000..37caf3ef --- /dev/null +++ b/packages/prompts/test/suggestion.test.ts @@ -0,0 +1,169 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; +import * as prompts from '../src/index.js'; +import { MockReadable, MockWritable } from './test-utils.js'; + +describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { + let originalCI: string | undefined; + let output: MockWritable; + let input: MockReadable; + + beforeAll(() => { + originalCI = process.env.CI; + process.env.CI = isCI; + }); + + afterAll(() => { + process.env.CI = originalCI; + }); + + beforeEach(() => { + output = new MockWritable(); + input = new MockReadable(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('renders message', async () => { + const result = prompts.suggestion({ + message: 'foo', + input, + output, + suggest: () => [], + }); + + input.emit('keypress', '', { name: 'return' }); + + await result; + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders and apply () suggestion', async () => { + const result = prompts.suggestion({ + message: 'foo', + suggest: () => ['bar'], + input, + output, + }); + + input.emit('keypress', '\t', { name: 'tab' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(output.buffer).toMatchSnapshot(); + + expect(value).toBe('bar'); + }); + + test('can cancel', async () => { + const result = prompts.suggestion({ + message: 'foo', + suggest: () => [], + input, + output, + }); + + input.emit('keypress', 'escape', { name: 'escape' }); + + const value = await result; + + expect(prompts.isCancel(value)).toBe(true); + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders cancelled value if one set', async () => { + const result = prompts.suggestion({ + message: 'foo', + input, + output, + suggest: () => ['xyz'], + }); + + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', 'y', { name: 'y' }); + input.emit('keypress', '', { name: 'escape' }); + + const value = await result; + + expect(prompts.isCancel(value)).toBe(true); + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders submitted value', async () => { + const result = prompts.suggestion({ + message: 'foo', + suggest: () => ['xyz'], + input, + output, + }); + + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', 'y', { name: 'y' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('xy'); + expect(output.buffer).toMatchSnapshot(); + }); + + test('initialValue sets the value', async () => { + const result = prompts.suggestion({ + message: 'foo', + initialValue: 'bar', + suggest: () => [], + input, + output, + }); + + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('bar'); + expect(output.buffer).toMatchSnapshot(); + }); + + test('validation errors render and clear', async () => { + const result = prompts.suggestion({ + message: 'foo', + suggest: () => ['xyz'], + validate: (val) => (val !== 'xy' ? 'should be xy' : undefined), + input, + output, + }); + + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', 'y', { name: 'y' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('xy'); + expect(output.buffer).toMatchSnapshot(); + }); + + test('validation errors render and clear (using Error)', async () => { + const result = prompts.suggestion({ + message: 'foo', + suggest: () => ['xyz'], + validate: (val) => (val !== 'xy' ? new Error('should be xy') : undefined), + input, + output, + }); + + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', 'y', { name: 'y' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('xy'); + expect(output.buffer).toMatchSnapshot(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2eb8527e..d2ae3cb1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: is-unicode-supported: specifier: ^1.3.0 version: 1.3.0 + memfs: + specifier: ^4.17.1 + version: 4.17.1 vitest: specifier: ^3.1.1 version: 3.1.1(@types/node@18.16.0) @@ -716,6 +719,24 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@jsonjoy.com/base64@1.1.2': + resolution: {integrity: sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pack@1.2.0': + resolution: {integrity: sha512-io1zEbbYcElht3tdlqEOFxZ0dMTYrHz9iMf0gqn1pPjZFTCgM5R4R5IMA20Chb2UPYYsxjzs8CgZ7Nb5n2K2rA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/util@1.6.0': + resolution: {integrity: sha512-sw/RMbehRhN68WRtcKCpQOPfnH6lLP4GJfqzi3iYej8tnzpZUDr6UkZYJjcjjC0FWEJOJbyM3PTIwxucUmDG2A==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -1544,6 +1565,10 @@ packages: human-id@1.0.2: resolution: {integrity: sha512-UNopramDEhHJD+VR+ehk8rOslwSfByxPIZyJRfV739NDhN5LF1fa1MqnzKm2lGTQRjNrjK19Q5fhkgIfjlVUKw==} + hyperdyperid@1.2.0: + resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} + engines: {node: '>=10.18'} + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -1826,6 +1851,10 @@ packages: mdn-data@2.0.30: resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + memfs@4.17.1: + resolution: {integrity: sha512-thuTRd7F4m4dReCIy7vv4eNYnU6XI/tHMLSMMHLiortw/Y0QxqKtinG523U2aerzwYWGi606oBP4oMPy4+edag==} + engines: {node: '>= 4.0.0'} + meow@6.1.1: resolution: {integrity: sha512-3YffViIt2QWgTy6Pale5QpopX/IvU3LPL03jOTqp6pGj3VjesdO/U8CuHMKpnQr4shCNCM5fd5XFFvIIl6JBHg==} engines: {node: '>=8'} @@ -2477,6 +2506,12 @@ packages: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} + thingies@1.21.0: + resolution: {integrity: sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g==} + engines: {node: '>=10.18'} + peerDependencies: + tslib: ^2 + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -2507,10 +2542,19 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tree-dump@1.0.2: + resolution: {integrity: sha512-dpev9ABuLWdEubk+cIaI9cHwRNNDjkBBLXTwI4UCUFdQ5xXKqNXoK4FEciw/vxf+NQ7Cb7sGUyeUtORvHIdRXQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + trim-newlines@3.0.1: resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==} engines: {node: '>=8'} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tty-table@4.2.3: resolution: {integrity: sha512-Fs15mu0vGzCrj8fmJNP7Ynxt5J7praPXqFN0leZeZBXJwkMxv9cb2D454k1ltrtUSJbZ4yH4e0CynsHLxmUfFA==} engines: {node: '>=8.0.0'} @@ -3271,6 +3315,22 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@jsonjoy.com/base64@1.1.2(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/json-pack@1.2.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/base64': 1.1.2(tslib@2.8.1) + '@jsonjoy.com/util': 1.6.0(tslib@2.8.1) + hyperdyperid: 1.2.0 + thingies: 1.21.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/util@1.6.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.26.0 @@ -4208,6 +4268,8 @@ snapshots: human-id@1.0.2: {} + hyperdyperid@1.2.0: {} + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -4459,6 +4521,13 @@ snapshots: mdn-data@2.0.30: {} + memfs@4.17.1: + dependencies: + '@jsonjoy.com/json-pack': 1.2.0(tslib@2.8.1) + '@jsonjoy.com/util': 1.6.0(tslib@2.8.1) + tree-dump: 1.0.2(tslib@2.8.1) + tslib: 2.8.1 + meow@6.1.1: dependencies: '@types/minimist': 1.2.5 @@ -5109,6 +5178,10 @@ snapshots: term-size@2.2.1: {} + thingies@1.21.0(tslib@2.8.1): + dependencies: + tslib: 2.8.1 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -5132,8 +5205,14 @@ snapshots: dependencies: is-number: 7.0.0 + tree-dump@1.0.2(tslib@2.8.1): + dependencies: + tslib: 2.8.1 + trim-newlines@3.0.1: {} + tslib@2.8.1: {} + tty-table@4.2.3: dependencies: chalk: 4.1.2