diff --git a/packages/vitest/src/node/stdin.ts b/packages/vitest/src/node/stdin.ts index a9c4a3129edb..f055ebf869e7 100644 --- a/packages/vitest/src/node/stdin.ts +++ b/packages/vitest/src/node/stdin.ts @@ -1,9 +1,11 @@ import readline from 'node:readline' import c from 'picocolors' import prompt from 'prompts' -import { isWindows, stdout } from '../utils' +import { relative } from 'pathe' +import { getTests, isWindows, stdout } from '../utils' import { toArray } from '../utils/base' import type { Vitest } from './core' +import { WatchFilter } from './watch-filter' const keys = [ [['a', 'return'], 'rerun all tests'], @@ -95,14 +97,22 @@ export function registerConsoleShortcuts(ctx: Vitest) { async function inputNamePattern() { off() - const { filter = '' }: { filter: string } = await prompt([{ - name: 'filter', - type: 'text', - message: 'Input test name pattern (RegExp)', - initial: ctx.configOverride.testNamePattern?.source || '', - }]) + const watchFilter = new WatchFilter('Input test name pattern (RegExp)') + const filter = await watchFilter.filter((str: string) => { + const files = ctx.state.getFiles() + const tests = getTests(files) + try { + const reg = new RegExp(str) + return tests.map(test => test.name).filter(testName => testName.match(reg)) + } + catch { + // `new RegExp` may throw error when input is invalid regexp + return [] + } + }) + on() - await ctx.changeNamePattern(filter.trim(), undefined, 'change pattern') + await ctx.changeNamePattern(filter?.trim() || '', undefined, 'change pattern') } async function inputProjectName() { @@ -119,15 +129,21 @@ export function registerConsoleShortcuts(ctx: Vitest) { async function inputFilePattern() { off() - const { filter = '' }: { filter: string } = await prompt([{ - name: 'filter', - type: 'text', - message: 'Input filename pattern', - initial: latestFilename, - }]) - latestFilename = filter.trim() + + const watchFilter = new WatchFilter('Input filename pattern') + + const filter = await watchFilter.filter(async (str: string) => { + const files = await ctx.globTestFiles([str]) + return files.map(file => + relative(ctx.config.root, file[1]), + ) + }) + on() - await ctx.changeFilenamePattern(filter.trim()) + + latestFilename = filter?.trim() || '' + + await ctx.changeFilenamePattern(latestFilename) } let rl: readline.Interface | undefined diff --git a/packages/vitest/src/node/watch-filter.ts b/packages/vitest/src/node/watch-filter.ts new file mode 100644 index 000000000000..93060f93cf2d --- /dev/null +++ b/packages/vitest/src/node/watch-filter.ts @@ -0,0 +1,168 @@ +import readline from 'node:readline' +import c from 'picocolors' +import stripAnsi from 'strip-ansi' +import { createDefer } from '@vitest/utils' +import { stdout } from '../utils' + +const MAX_RESULT_COUNT = 10 +const SELECTION_MAX_INDEX = 7 +const ESC = '\u001B[' + +type FilterFunc = (keyword: string) => Promise | string[] + +export class WatchFilter { + private filterRL: readline.Interface + private currentKeyword: string | undefined = undefined + private message: string + private results: string[] = [] + private selectionIndex = -1 + private onKeyPress?: (str: string, key: any) => void + + constructor(message: string) { + this.message = message + this.filterRL = readline.createInterface({ input: process.stdin, escapeCodeTimeout: 50 }) + readline.emitKeypressEvents(process.stdin, this.filterRL) + if (process.stdin.isTTY) + process.stdin.setRawMode(true) + } + + public async filter(filterFunc: FilterFunc): Promise { + stdout().write(this.promptLine()) + + const resultPromise = createDefer() + + this.onKeyPress = this.filterHandler(filterFunc, (result) => { + resultPromise.resolve(result) + }) + process.stdin.on('keypress', this.onKeyPress) + try { + return await resultPromise + } + finally { + this.close() + } + } + + private filterHandler(filterFunc: FilterFunc, onSubmit: (result?: string) => void) { + return async (str: string | undefined, key: any) => { + switch (true) { + case key.sequence === '\x7F': + if (this.currentKeyword && this.currentKeyword?.length > 1) + this.currentKeyword = this.currentKeyword?.slice(0, -1) + + else + this.currentKeyword = undefined + + break + case key?.ctrl && key?.name === 'c': + case key?.name === 'escape': + this.cancel() + onSubmit(undefined) + break + case key?.name === 'enter': + case key?.name === 'return': + onSubmit(this.results[this.selectionIndex] || this.currentKeyword || '') + this.currentKeyword = undefined + break + case key?.name === 'up': + if (this.selectionIndex && this.selectionIndex > 0) + this.selectionIndex-- + else + this.selectionIndex = -1 + + break + case key?.name === 'down': + if (this.selectionIndex < this.results.length - 1) + this.selectionIndex++ + else if (this.selectionIndex >= this.results.length - 1) + this.selectionIndex = this.results.length - 1 + + break + case !key?.ctrl && !key?.meta: + if (this.currentKeyword === undefined) + this.currentKeyword = str + + else + this.currentKeyword += str || '' + break + } + + if (this.currentKeyword) + this.results = await filterFunc(this.currentKeyword) + + this.render() + } + } + + private render() { + let printStr = this.promptLine() + if (!this.currentKeyword) { + printStr += '\nPlease input filter pattern' + } + else if (this.currentKeyword && this.results.length === 0) { + printStr += '\nPattern matches no results' + } + else { + const resultCountLine = this.results.length === 1 ? `Pattern matches ${this.results.length} result` : `Pattern matches ${this.results.length} results` + + let resultBody = '' + + if (this.results.length > MAX_RESULT_COUNT) { + const offset = this.selectionIndex > SELECTION_MAX_INDEX ? this.selectionIndex - SELECTION_MAX_INDEX : 0 + const displayResults = this.results.slice(offset, MAX_RESULT_COUNT + offset) + const remainingResultCount = this.results.length - offset - displayResults.length + + resultBody = `${displayResults.map((result, index) => (index + offset === this.selectionIndex) ? c.green(` › ${result}`) : c.dim(` › ${result}`)).join('\n')}` + if (remainingResultCount > 0) + resultBody += '\n' + `${c.dim(` ...and ${remainingResultCount} more ${remainingResultCount === 1 ? 'result' : 'results'}`)}` + } + else { + resultBody = this.results.map((result, index) => (index === this.selectionIndex) ? c.green(` › ${result}`) : c.dim(` › ${result}`)) + .join('\n') + } + + printStr += `\n${resultCountLine}\n${resultBody}` + } + this.eraseAndPrint(printStr) + this.restoreCursor() + } + + private keywordOffset() { + return `? ${this.message} › `.length + 1 + } + + private promptLine() { + return `${c.cyan('?')} ${c.bold(this.message)} › ${this.currentKeyword || ''}` + } + + private eraseAndPrint(str: string) { + let rows = 0 + const lines = str.split(/\r?\n/) + for (const line of lines) + // We have to take care of screen width in case of long lines + rows += 1 + Math.floor(Math.max(stripAnsi(line).length - 1, 0) / stdout().columns) + + stdout().write(`${ESC}1G`) // move to the beginning of the line + stdout().write(`${ESC}J`) // erase down + stdout().write(str) + stdout().write(`${ESC}${rows - 1}A`) // moving up lines + } + + private close() { + this.filterRL.close() + if (this.onKeyPress) + process.stdin.removeListener('keypress', this.onKeyPress) + + if (process.stdin.isTTY) + process.stdin.setRawMode(false) + } + + private restoreCursor() { + const cursortPos = this.keywordOffset() + (this.currentKeyword?.length || 0) + stdout().write(`${ESC}${cursortPos}G`) + } + + private cancel() { + stdout().write(`${ESC}J`) // erase down + } +} diff --git a/test/watch/test/stdin.test.ts b/test/watch/test/stdin.test.ts index fae70077aaba..aea31f154b7e 100644 --- a/test/watch/test/stdin.test.ts +++ b/test/watch/test/stdin.test.ts @@ -45,7 +45,12 @@ test('filter by filename', async () => { await vitest.waitForStdout('Input filename pattern') - vitest.write('math\n') + vitest.write('math') + + await vitest.waitForStdout('Pattern matches 1 results') + await vitest.waitForStdout('› math.test.ts') + + vitest.write('\n') await vitest.waitForStdout('Filename pattern: math') await vitest.waitForStdout('1 passed') @@ -58,7 +63,11 @@ test('filter by test name', async () => { await vitest.waitForStdout('Input test name pattern') - vitest.write('sum\n') + vitest.write('sum') + await vitest.waitForStdout('Pattern matches 1 results') + await vitest.waitForStdout('› sum') + + vitest.write('\n') await vitest.waitForStdout('Test name pattern: /sum/') await vitest.waitForStdout('1 passed')