Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions packages/vite/src/node/__tests__/shortcuts.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, test, vi } from 'vitest'
import { createServer } from '../server'
import { preview } from '../preview'
import { bindCLIShortcuts } from '../shortcuts'

describe('bindCLIShortcuts', () => {
test.each([
['dev server', () => createServer()],
['preview server', () => preview()],
])('binding custom shortcuts with the %s', async (_, startServer) => {
const server = await startServer()

try {
const xAction = vi.fn()
const yAction = vi.fn()

bindCLIShortcuts(
server,
{
customShortcuts: [
{ key: 'x', description: 'test x', action: xAction },
{ key: 'y', description: 'test y', action: yAction },
],
},
true,
)

expect.assert(
server._rl,
'The readline interface should be defined after binding shortcuts.',
)
expect(xAction).not.toHaveBeenCalled()

server._rl.emit('line', 'x')
await vi.waitFor(() => expect(xAction).toHaveBeenCalledOnce())

const xUpdatedAction = vi.fn()
const zAction = vi.fn()

xAction.mockClear()
bindCLIShortcuts(
server,
{
customShortcuts: [
{ key: 'x', description: 'test x updated', action: xUpdatedAction },
{ key: 'z', description: 'test z', action: zAction },
],
},
true,
)

expect(xUpdatedAction).not.toHaveBeenCalled()
server._rl.emit('line', 'x')
await vi.waitFor(() => expect(xUpdatedAction).toHaveBeenCalledOnce())

// Ensure original xAction is not called again
expect(xAction).not.toBeCalled()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's probably a miss-understanding of the mocking system but I don't understand this assertion. Does using toHaveBeenCalledOnce above "reset" the counter? Otherwise I would have use toHaveBeenCalledOnce again

Copy link
Contributor Author

@edmundhung edmundhung Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reset the counter with xAction.mockClear() right before calling bindCLIShortcuts again. If you find it confusing, happy to remove that and just assert whether xAction is still only called once.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I missed that line. Ok makes more sense thanks. I think it just taste then so no need to change


expect(yAction).not.toHaveBeenCalled()
server._rl.emit('line', 'y')
await vi.waitFor(() => expect(yAction).toHaveBeenCalledOnce())

expect(zAction).not.toHaveBeenCalled()
server._rl.emit('line', 'z')
await vi.waitFor(() => expect(zAction).toHaveBeenCalledOnce())
} finally {
await server.close()
}
})
})
9 changes: 9 additions & 0 deletions packages/vite/src/node/preview.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import fs from 'node:fs'
import path from 'node:path'
import type readline from 'node:readline'
import sirv from 'sirv'
import compression from '@polka/compression'
import connect from 'connect'
Expand Down Expand Up @@ -107,6 +108,14 @@ export interface PreviewServer {
* Bind CLI shortcuts
*/
bindCLIShortcuts(options?: BindCLIShortcutsOptions<PreviewServer>): void
/**
* @internal
*/
_shortcutsOptions?: BindCLIShortcutsOptions<PreviewServer>
/**
* @internal
*/
_rl?: readline.Interface | undefined
}

export type PreviewServerHook = (
Expand Down
5 changes: 5 additions & 0 deletions packages/vite/src/node/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { get as httpsGet } from 'node:https'
import type * as http from 'node:http'
import { performance } from 'node:perf_hooks'
import type { Http2SecureServer } from 'node:http2'
import type readline from 'node:readline'
import connect from 'connect'
import corsMiddleware from 'cors'
import colors from 'picocolors'
Expand Down Expand Up @@ -408,6 +409,10 @@ export interface ViteDevServer {
* @internal
*/
_shortcutsOptions?: BindCLIShortcutsOptions<ViteDevServer>
/**
* @internal
*/
_rl?: readline.Interface | undefined
/**
* @internal
*/
Expand Down
33 changes: 26 additions & 7 deletions packages/vite/src/node/shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,28 @@ export type CLIShortcut<Server = ViteDevServer | PreviewServer> = {
export function bindCLIShortcuts<Server extends ViteDevServer | PreviewServer>(
server: Server,
opts?: BindCLIShortcutsOptions<Server>,
enabled: boolean = process.stdin.isTTY && !process.env.CI,
): void {
if (!server.httpServer || !process.stdin.isTTY || process.env.CI) {
if (!server.httpServer || !enabled) {
return
}

const isDev = isDevServer(server)

if (isDev) {
server._shortcutsOptions = opts as BindCLIShortcutsOptions<ViteDevServer>
const customShortcuts: CLIShortcut<ViteDevServer | PreviewServer>[] =
opts?.customShortcuts ?? []

// Merge custom shortcuts from existing options
// with new shortcuts taking priority
for (const shortcut of server._shortcutsOptions?.customShortcuts ?? []) {
if (!customShortcuts.some((s) => s.key === shortcut.key)) {
customShortcuts.push(shortcut)
}
}

server._shortcutsOptions = {
...opts,
customShortcuts,
}

if (opts?.print) {
Expand All @@ -48,7 +61,7 @@ export function bindCLIShortcuts<Server extends ViteDevServer | PreviewServer>(
)
}

const shortcuts = (opts?.customShortcuts ?? []).concat(
const shortcuts = customShortcuts.concat(
(isDev
? BASE_DEV_SHORTCUTS
: BASE_PREVIEW_SHORTCUTS) as CLIShortcut<Server>[],
Expand Down Expand Up @@ -87,9 +100,15 @@ export function bindCLIShortcuts<Server extends ViteDevServer | PreviewServer>(
actionRunning = false
}

const rl = readline.createInterface({ input: process.stdin })
rl.on('line', onInput)
server.httpServer.on('close', () => rl.close())
if (!server._rl) {
const rl = readline.createInterface({ input: process.stdin })
server._rl = rl
server.httpServer.on('close', () => rl.close())
} else {
server._rl.removeAllListeners('line')
}

server._rl.on('line', onInput)
}

const BASE_DEV_SHORTCUTS: CLIShortcut<ViteDevServer>[] = [
Expand Down