Skip to content

Commit 480d866

Browse files
feat: Add typeahead search (#4275) (#4733)
Co-authored-by: Vladimir <[email protected]>
1 parent 1326c6e commit 480d866

File tree

3 files changed

+211
-18
lines changed

3 files changed

+211
-18
lines changed

packages/vitest/src/node/stdin.ts

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import readline from 'node:readline'
22
import c from 'picocolors'
33
import prompt from 'prompts'
4-
import { isWindows, stdout } from '../utils'
4+
import { relative } from 'pathe'
5+
import { getTests, isWindows, stdout } from '../utils'
56
import { toArray } from '../utils/base'
67
import type { Vitest } from './core'
8+
import { WatchFilter } from './watch-filter'
79

810
const keys = [
911
[['a', 'return'], 'rerun all tests'],
@@ -95,14 +97,22 @@ export function registerConsoleShortcuts(ctx: Vitest) {
9597

9698
async function inputNamePattern() {
9799
off()
98-
const { filter = '' }: { filter: string } = await prompt([{
99-
name: 'filter',
100-
type: 'text',
101-
message: 'Input test name pattern (RegExp)',
102-
initial: ctx.configOverride.testNamePattern?.source || '',
103-
}])
100+
const watchFilter = new WatchFilter('Input test name pattern (RegExp)')
101+
const filter = await watchFilter.filter((str: string) => {
102+
const files = ctx.state.getFiles()
103+
const tests = getTests(files)
104+
try {
105+
const reg = new RegExp(str)
106+
return tests.map(test => test.name).filter(testName => testName.match(reg))
107+
}
108+
catch {
109+
// `new RegExp` may throw error when input is invalid regexp
110+
return []
111+
}
112+
})
113+
104114
on()
105-
await ctx.changeNamePattern(filter.trim(), undefined, 'change pattern')
115+
await ctx.changeNamePattern(filter?.trim() || '', undefined, 'change pattern')
106116
}
107117

108118
async function inputProjectName() {
@@ -119,15 +129,21 @@ export function registerConsoleShortcuts(ctx: Vitest) {
119129

120130
async function inputFilePattern() {
121131
off()
122-
const { filter = '' }: { filter: string } = await prompt([{
123-
name: 'filter',
124-
type: 'text',
125-
message: 'Input filename pattern',
126-
initial: latestFilename,
127-
}])
128-
latestFilename = filter.trim()
132+
133+
const watchFilter = new WatchFilter('Input filename pattern')
134+
135+
const filter = await watchFilter.filter(async (str: string) => {
136+
const files = await ctx.globTestFiles([str])
137+
return files.map(file =>
138+
relative(ctx.config.root, file[1]),
139+
)
140+
})
141+
129142
on()
130-
await ctx.changeFilenamePattern(filter.trim())
143+
144+
latestFilename = filter?.trim() || ''
145+
146+
await ctx.changeFilenamePattern(latestFilename)
131147
}
132148

133149
let rl: readline.Interface | undefined
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import readline from 'node:readline'
2+
import c from 'picocolors'
3+
import stripAnsi from 'strip-ansi'
4+
import { createDefer } from '@vitest/utils'
5+
import { stdout } from '../utils'
6+
7+
const MAX_RESULT_COUNT = 10
8+
const SELECTION_MAX_INDEX = 7
9+
const ESC = '\u001B['
10+
11+
type FilterFunc = (keyword: string) => Promise<string[]> | string[]
12+
13+
export class WatchFilter {
14+
private filterRL: readline.Interface
15+
private currentKeyword: string | undefined = undefined
16+
private message: string
17+
private results: string[] = []
18+
private selectionIndex = -1
19+
private onKeyPress?: (str: string, key: any) => void
20+
21+
constructor(message: string) {
22+
this.message = message
23+
this.filterRL = readline.createInterface({ input: process.stdin, escapeCodeTimeout: 50 })
24+
readline.emitKeypressEvents(process.stdin, this.filterRL)
25+
if (process.stdin.isTTY)
26+
process.stdin.setRawMode(true)
27+
}
28+
29+
public async filter(filterFunc: FilterFunc): Promise<string | undefined> {
30+
stdout().write(this.promptLine())
31+
32+
const resultPromise = createDefer<string | undefined>()
33+
34+
this.onKeyPress = this.filterHandler(filterFunc, (result) => {
35+
resultPromise.resolve(result)
36+
})
37+
process.stdin.on('keypress', this.onKeyPress)
38+
try {
39+
return await resultPromise
40+
}
41+
finally {
42+
this.close()
43+
}
44+
}
45+
46+
private filterHandler(filterFunc: FilterFunc, onSubmit: (result?: string) => void) {
47+
return async (str: string | undefined, key: any) => {
48+
switch (true) {
49+
case key.sequence === '\x7F':
50+
if (this.currentKeyword && this.currentKeyword?.length > 1)
51+
this.currentKeyword = this.currentKeyword?.slice(0, -1)
52+
53+
else
54+
this.currentKeyword = undefined
55+
56+
break
57+
case key?.ctrl && key?.name === 'c':
58+
case key?.name === 'escape':
59+
this.cancel()
60+
onSubmit(undefined)
61+
break
62+
case key?.name === 'enter':
63+
case key?.name === 'return':
64+
onSubmit(this.results[this.selectionIndex] || this.currentKeyword || '')
65+
this.currentKeyword = undefined
66+
break
67+
case key?.name === 'up':
68+
if (this.selectionIndex && this.selectionIndex > 0)
69+
this.selectionIndex--
70+
else
71+
this.selectionIndex = -1
72+
73+
break
74+
case key?.name === 'down':
75+
if (this.selectionIndex < this.results.length - 1)
76+
this.selectionIndex++
77+
else if (this.selectionIndex >= this.results.length - 1)
78+
this.selectionIndex = this.results.length - 1
79+
80+
break
81+
case !key?.ctrl && !key?.meta:
82+
if (this.currentKeyword === undefined)
83+
this.currentKeyword = str
84+
85+
else
86+
this.currentKeyword += str || ''
87+
break
88+
}
89+
90+
if (this.currentKeyword)
91+
this.results = await filterFunc(this.currentKeyword)
92+
93+
this.render()
94+
}
95+
}
96+
97+
private render() {
98+
let printStr = this.promptLine()
99+
if (!this.currentKeyword) {
100+
printStr += '\nPlease input filter pattern'
101+
}
102+
else if (this.currentKeyword && this.results.length === 0) {
103+
printStr += '\nPattern matches no results'
104+
}
105+
else {
106+
const resultCountLine = this.results.length === 1 ? `Pattern matches ${this.results.length} result` : `Pattern matches ${this.results.length} results`
107+
108+
let resultBody = ''
109+
110+
if (this.results.length > MAX_RESULT_COUNT) {
111+
const offset = this.selectionIndex > SELECTION_MAX_INDEX ? this.selectionIndex - SELECTION_MAX_INDEX : 0
112+
const displayResults = this.results.slice(offset, MAX_RESULT_COUNT + offset)
113+
const remainingResultCount = this.results.length - offset - displayResults.length
114+
115+
resultBody = `${displayResults.map((result, index) => (index + offset === this.selectionIndex) ? c.green(` › ${result}`) : c.dim(` › ${result}`)).join('\n')}`
116+
if (remainingResultCount > 0)
117+
resultBody += '\n' + `${c.dim(` ...and ${remainingResultCount} more ${remainingResultCount === 1 ? 'result' : 'results'}`)}`
118+
}
119+
else {
120+
resultBody = this.results.map((result, index) => (index === this.selectionIndex) ? c.green(` › ${result}`) : c.dim(` › ${result}`))
121+
.join('\n')
122+
}
123+
124+
printStr += `\n${resultCountLine}\n${resultBody}`
125+
}
126+
this.eraseAndPrint(printStr)
127+
this.restoreCursor()
128+
}
129+
130+
private keywordOffset() {
131+
return `? ${this.message} › `.length + 1
132+
}
133+
134+
private promptLine() {
135+
return `${c.cyan('?')} ${c.bold(this.message)}${this.currentKeyword || ''}`
136+
}
137+
138+
private eraseAndPrint(str: string) {
139+
let rows = 0
140+
const lines = str.split(/\r?\n/)
141+
for (const line of lines)
142+
// We have to take care of screen width in case of long lines
143+
rows += 1 + Math.floor(Math.max(stripAnsi(line).length - 1, 0) / stdout().columns)
144+
145+
stdout().write(`${ESC}1G`) // move to the beginning of the line
146+
stdout().write(`${ESC}J`) // erase down
147+
stdout().write(str)
148+
stdout().write(`${ESC}${rows - 1}A`) // moving up lines
149+
}
150+
151+
private close() {
152+
this.filterRL.close()
153+
if (this.onKeyPress)
154+
process.stdin.removeListener('keypress', this.onKeyPress)
155+
156+
if (process.stdin.isTTY)
157+
process.stdin.setRawMode(false)
158+
}
159+
160+
private restoreCursor() {
161+
const cursortPos = this.keywordOffset() + (this.currentKeyword?.length || 0)
162+
stdout().write(`${ESC}${cursortPos}G`)
163+
}
164+
165+
private cancel() {
166+
stdout().write(`${ESC}J`) // erase down
167+
}
168+
}

test/watch/test/stdin.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,12 @@ test('filter by filename', async () => {
4545

4646
await vitest.waitForStdout('Input filename pattern')
4747

48-
vitest.write('math\n')
48+
vitest.write('math')
49+
50+
await vitest.waitForStdout('Pattern matches 1 results')
51+
await vitest.waitForStdout('› math.test.ts')
52+
53+
vitest.write('\n')
4954

5055
await vitest.waitForStdout('Filename pattern: math')
5156
await vitest.waitForStdout('1 passed')
@@ -58,7 +63,11 @@ test('filter by test name', async () => {
5863

5964
await vitest.waitForStdout('Input test name pattern')
6065

61-
vitest.write('sum\n')
66+
vitest.write('sum')
67+
await vitest.waitForStdout('Pattern matches 1 results')
68+
await vitest.waitForStdout('› sum')
69+
70+
vitest.write('\n')
6271

6372
await vitest.waitForStdout('Test name pattern: /sum/')
6473
await vitest.waitForStdout('1 passed')

0 commit comments

Comments
 (0)