Files
claude-code-haha/desktop/src/pages/TerminalSettings.test.tsx
T
2026-07-22 20:46:34 +08:00

459 lines
16 KiB
TypeScript

import { StrictMode } from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSettingsStore } from '../stores/settingsStore'
import { destroyTerminalRuntime } from '../lib/terminalRuntime'
import { browserHost } from '../lib/desktopHost/browserHost'
const terminalMocks = vi.hoisted(() => {
const terminalInstance = {
cols: 80,
rows: 24,
element: null as HTMLElement | null,
loadAddon: vi.fn(),
open: vi.fn(),
dispose: vi.fn(),
onData: vi.fn(),
write: vi.fn(),
writeln: vi.fn(),
clear: vi.fn(),
focus: vi.fn(),
getSelection: vi.fn(),
hasSelection: vi.fn(),
paste: vi.fn(),
}
const fitInstance = {
fit: vi.fn(),
}
return {
available: false,
terminalInstance,
fitInstance,
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
onOutput: vi.fn(),
onExit: vi.fn(),
getBashPath: vi.fn(),
setBashPath: vi.fn(),
}
})
vi.mock('@xterm/xterm', () => ({
Terminal: vi.fn(() => terminalMocks.terminalInstance),
}))
vi.mock('@xterm/addon-fit', () => ({
FitAddon: vi.fn(() => terminalMocks.fitInstance),
}))
vi.mock('../api/terminal', () => ({
terminalApi: {
isAvailable: () => terminalMocks.available,
spawn: terminalMocks.spawn,
write: terminalMocks.write,
resize: terminalMocks.resize,
kill: terminalMocks.kill,
onOutput: terminalMocks.onOutput,
onExit: terminalMocks.onExit,
getBashPath: terminalMocks.getBashPath,
setBashPath: terminalMocks.setBashPath,
},
}))
import { TerminalSettings } from './TerminalSettings'
describe('TerminalSettings', () => {
beforeEach(() => {
vi.restoreAllMocks()
useSettingsStore.setState({ locale: 'en' })
useSettingsStore.setState({
desktopTerminal: {
startupShell: 'system',
customShellPath: '',
},
setDesktopTerminal: vi.fn().mockResolvedValue(undefined),
})
terminalMocks.available = false
terminalMocks.spawn.mockReset()
terminalMocks.write.mockReset()
terminalMocks.resize.mockReset()
terminalMocks.kill.mockReset()
terminalMocks.onOutput.mockReset()
terminalMocks.onExit.mockReset()
terminalMocks.getBashPath.mockReset()
terminalMocks.setBashPath.mockReset()
terminalMocks.terminalInstance.loadAddon.mockClear()
terminalMocks.terminalInstance.element = null
terminalMocks.terminalInstance.open.mockClear()
terminalMocks.terminalInstance.dispose.mockClear()
terminalMocks.terminalInstance.onData.mockClear()
terminalMocks.terminalInstance.write.mockClear()
terminalMocks.terminalInstance.writeln.mockClear()
terminalMocks.terminalInstance.clear.mockClear()
terminalMocks.terminalInstance.focus.mockClear()
terminalMocks.terminalInstance.getSelection.mockReset()
terminalMocks.terminalInstance.hasSelection.mockReset()
terminalMocks.terminalInstance.paste.mockClear()
terminalMocks.terminalInstance.getSelection.mockReturnValue('')
terminalMocks.terminalInstance.hasSelection.mockReturnValue(false)
terminalMocks.fitInstance.fit.mockClear()
terminalMocks.onOutput.mockResolvedValue(vi.fn())
terminalMocks.onExit.mockResolvedValue(vi.fn())
terminalMocks.getBashPath.mockResolvedValue(null)
terminalMocks.setBashPath.mockResolvedValue(undefined)
terminalMocks.write.mockResolvedValue(undefined)
terminalMocks.resize.mockResolvedValue(undefined)
terminalMocks.kill.mockResolvedValue(undefined)
terminalMocks.spawn.mockResolvedValue({
session_id: 7,
shell: '/bin/zsh',
cwd: '/Users/test',
})
Reflect.deleteProperty(window, 'desktopHost')
vi.stubGlobal('ResizeObserver', class {
observe = vi.fn()
disconnect = vi.fn()
})
vi.spyOn(navigator, 'platform', 'get').mockReturnValue('MacIntel')
})
it('shows a desktop-runtime empty state outside Tauri', () => {
render(<TerminalSettings />)
expect(screen.getByTestId('settings-terminal-toolbar')).toHaveTextContent('Terminal')
expect(screen.getByText('Desktop runtime required')).toBeInTheDocument()
expect(terminalMocks.spawn).not.toHaveBeenCalled()
})
it('starts a host terminal session when Tauri is available', async () => {
terminalMocks.available = true
render(<TerminalSettings />)
await waitFor(() => {
expect(terminalMocks.spawn).toHaveBeenCalledWith({ cols: 80, rows: 24 })
})
expect(screen.getByText('/bin/zsh')).toBeInTheDocument()
expect(screen.getByText('/Users/test')).toBeInTheDocument()
expect(terminalMocks.terminalInstance.open).toHaveBeenCalled()
expect(terminalMocks.fitInstance.fit).toHaveBeenCalled()
})
it('keeps the terminal runtime current across the StrictMode effect replay', async () => {
terminalMocks.available = true
render(
<StrictMode>
<TerminalSettings runtimeId="strict-mode-terminal" />
</StrictMode>,
)
await waitFor(() => expect(terminalMocks.spawn).toHaveBeenCalledTimes(1))
expect(screen.getByText('Running')).toBeInTheDocument()
destroyTerminalRuntime('strict-mode-terminal')
})
it('does not start duplicate xterm surfaces for one runtime', async () => {
terminalMocks.available = true
terminalMocks.terminalInstance.open.mockImplementation((host: HTMLElement) => {
const element = document.createElement('div')
element.className = 'xterm'
terminalMocks.terminalInstance.element = element
host.appendChild(element)
})
render(
<>
<TerminalSettings runtimeId="shared-terminal-runtime" preserveOnUnmount />
<TerminalSettings runtimeId="shared-terminal-runtime" preserveOnUnmount testId="settings-terminal-host-secondary" />
</>,
)
await waitFor(() => expect(terminalMocks.spawn).toHaveBeenCalled())
await vi.dynamicImportSettled()
expect(terminalMocks.spawn).toHaveBeenCalledTimes(1)
expect(terminalMocks.terminalInstance.open).toHaveBeenCalledTimes(1)
expect(document.body.querySelectorAll('.settings-terminal-host .xterm')).toHaveLength(1)
destroyTerminalRuntime('shared-terminal-runtime')
})
it('uses one compact toolbar instead of a nested terminal title bar', async () => {
terminalMocks.available = true
render(<TerminalSettings />)
await waitFor(() => expect(terminalMocks.spawn).toHaveBeenCalled())
expect(screen.getByTestId('settings-terminal-toolbar')).toHaveTextContent('/bin/zsh')
expect(screen.getByTestId('settings-terminal-frame')).toBeInTheDocument()
expect(screen.queryByText('Host shell')).not.toBeInTheDocument()
})
it('shows setup guidance from the terminal info button', () => {
render(<TerminalSettings />)
const button = screen.getByRole('button', { name: 'Terminal setup help' })
const help = screen.getByRole('tooltip')
expect(button).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(button)
expect(button).toHaveAttribute('aria-expanded', 'true')
expect(help).toHaveTextContent('plugin, skill, and MCP setup')
expect(help).toHaveTextContent('claude-haha plugin install')
})
it('lets the settings page keep scrolling when the terminal is not focused', async () => {
terminalMocks.available = true
const container = document.createElement('div')
container.style.overflowY = 'auto'
let scrollTop = 0
Object.defineProperty(container, 'clientHeight', { configurable: true, value: 100 })
Object.defineProperty(container, 'scrollHeight', { configurable: true, value: 300 })
Object.defineProperty(container, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value) => { scrollTop = value },
})
const scrollBy = vi.fn(({ top }: ScrollToOptions) => {
scrollTop += Number(top ?? 0)
})
Object.defineProperty(container, 'scrollBy', { configurable: true, value: scrollBy })
document.body.appendChild(container)
render(<TerminalSettings />, { container })
await waitFor(() => expect(terminalMocks.spawn).toHaveBeenCalled())
fireEvent.wheel(screen.getByTestId('settings-terminal-frame'), { deltaY: 48 })
expect(scrollBy).toHaveBeenCalledWith({ top: 48, left: 0 })
expect(scrollTop).toBe(48)
})
it('starts in the provided cwd when embedded in a project session', async () => {
terminalMocks.available = true
render(<TerminalSettings cwd="/tmp/current-project" />)
await waitFor(() => {
expect(terminalMocks.spawn).toHaveBeenCalledWith({
cols: 80,
rows: 24,
cwd: '/tmp/current-project',
})
})
})
it('writes matching terminal output events into xterm', async () => {
terminalMocks.available = true
let outputHandler: ((payload: { session_id: number; data: string }) => void) | undefined
terminalMocks.onOutput.mockImplementation(async (handler) => {
outputHandler = handler
return vi.fn()
})
render(<TerminalSettings />)
await waitFor(() => expect(terminalMocks.spawn).toHaveBeenCalled())
act(() => {
outputHandler?.({ session_id: 7, data: 'hello\r\n' })
outputHandler?.({ session_id: 8, data: 'ignored\r\n' })
})
expect(terminalMocks.terminalInstance.write).toHaveBeenCalledWith('hello\r\n')
expect(terminalMocks.terminalInstance.write).not.toHaveBeenCalledWith('ignored\r\n')
})
it('copies the terminal selection through the desktop clipboard on Windows shortcuts', async () => {
vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Win32')
terminalMocks.available = true
terminalMocks.terminalInstance.hasSelection.mockReturnValue(true)
terminalMocks.terminalInstance.getSelection.mockReturnValue('Microsoft Windows [Version 10.0.19045]')
const writeText = vi.fn().mockResolvedValue(undefined)
window.desktopHost = {
...browserHost,
capabilities: { ...browserHost.capabilities, clipboard: true },
clipboard: {
readText: vi.fn(),
writeText,
},
}
render(<TerminalSettings />)
await waitFor(() => expect(terminalMocks.spawn).toHaveBeenCalled())
fireEvent.keyDown(screen.getByTestId('settings-terminal-frame'), { key: 'c', ctrlKey: true })
await waitFor(() => {
expect(writeText).toHaveBeenCalledWith('Microsoft Windows [Version 10.0.19045]')
})
fireEvent.keyDown(screen.getByTestId('settings-terminal-frame'), { key: 'C', ctrlKey: true, shiftKey: true })
fireEvent.keyDown(screen.getByTestId('settings-terminal-frame'), { key: 'Insert', ctrlKey: true })
await waitFor(() => {
expect(writeText).toHaveBeenCalledTimes(3)
})
expect(terminalMocks.terminalInstance.focus).toHaveBeenCalled()
})
it('pastes clipboard text into xterm on Windows paste shortcuts', async () => {
vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Win32')
terminalMocks.available = true
const readText = vi.fn().mockResolvedValue('dir')
window.desktopHost = {
...browserHost,
capabilities: { ...browserHost.capabilities, clipboard: true },
clipboard: {
readText,
writeText: vi.fn(),
},
}
render(<TerminalSettings />)
await waitFor(() => expect(terminalMocks.spawn).toHaveBeenCalled())
fireEvent.keyDown(screen.getByTestId('settings-terminal-frame'), { key: 'v', ctrlKey: true })
await waitFor(() => {
expect(terminalMocks.terminalInstance.paste).toHaveBeenCalledWith('dir')
})
fireEvent.keyDown(screen.getByTestId('settings-terminal-frame'), { key: 'V', ctrlKey: true, shiftKey: true })
fireEvent.keyDown(screen.getByTestId('settings-terminal-frame'), { key: 'Insert', shiftKey: true })
await waitFor(() => {
expect(terminalMocks.terminalInstance.paste).toHaveBeenCalledTimes(3)
})
expect(terminalMocks.terminalInstance.focus).toHaveBeenCalled()
})
it('does not intercept Ctrl+C as copy when the Windows terminal has no selection', async () => {
vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Win32')
terminalMocks.available = true
const writeText = vi.fn().mockResolvedValue(undefined)
window.desktopHost = {
...browserHost,
capabilities: { ...browserHost.capabilities, clipboard: true },
clipboard: {
readText: vi.fn(),
writeText,
},
}
render(<TerminalSettings />)
await waitFor(() => expect(terminalMocks.spawn).toHaveBeenCalled())
const event = new window.KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
ctrlKey: true,
key: 'c',
})
screen.getByTestId('settings-terminal-frame').dispatchEvent(event)
expect(event.defaultPrevented).toBe(false)
expect(writeText).not.toHaveBeenCalled()
})
it('can preserve and reattach a running terminal runtime across unmounts', async () => {
terminalMocks.available = true
const first = render(<TerminalSettings runtimeId="shared-runtime" preserveOnUnmount />)
await waitFor(() => expect(terminalMocks.spawn).toHaveBeenCalledTimes(1))
first.unmount()
expect(terminalMocks.kill).not.toHaveBeenCalled()
render(<TerminalSettings runtimeId="shared-runtime" />)
await waitFor(() => {
expect(terminalMocks.terminalInstance.open).toHaveBeenCalledTimes(2)
})
expect(terminalMocks.spawn).toHaveBeenCalledTimes(1)
destroyTerminalRuntime('shared-runtime')
})
it('shows Windows-only startup shell controls in settings mode', () => {
vi.stubGlobal('navigator', {
...navigator,
platform: 'Win32',
userAgent: 'Windows',
})
render(<TerminalSettings showPreferences />)
expect(screen.getAllByText('Startup shell')).toHaveLength(2)
expect(screen.getByText('Use for new terminal sessions and after restart.')).toBeInTheDocument()
})
it('saves a custom Windows bash path from the terminal settings panel', async () => {
vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Win32')
terminalMocks.available = true
terminalMocks.getBashPath.mockResolvedValue('C:\\Program Files\\Git\\bin\\bash.exe')
render(<TerminalSettings showPreferences />)
const input = await screen.findByDisplayValue('C:\\Program Files\\Git\\bin\\bash.exe')
fireEvent.change(input, { target: { value: ' C:\\Tools\\Git\\bin\\bash.exe ' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(terminalMocks.setBashPath).toHaveBeenCalledWith('C:\\Tools\\Git\\bin\\bash.exe')
})
expect(await screen.findByRole('button', { name: 'Saved' })).toBeInTheDocument()
})
it('shows an invalid path message when native bash path validation fails', async () => {
vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Win32')
terminalMocks.available = true
terminalMocks.setBashPath.mockRejectedValue(new Error('terminal bash path does not exist'))
render(<TerminalSettings showPreferences />)
const input = await screen.findByPlaceholderText('Bash Path')
fireEvent.change(input, { target: { value: 'C:\\missing\\bash.exe' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(await screen.findByText('Path does not exist. Select a valid Bash executable.')).toBeInTheDocument()
})
it('selects a Windows bash path through the injected desktop host', async () => {
vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Win32')
terminalMocks.available = true
const open = vi.fn().mockResolvedValue('C:\\Program Files\\Git\\bin\\bash.exe')
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
capabilities: {
...browserHost.capabilities,
dialogs: true,
},
dialogs: {
...browserHost.dialogs,
open,
},
}
render(<TerminalSettings showPreferences />)
await screen.findByPlaceholderText('Bash Path')
fireEvent.click(screen.getByText('folder_open').closest('button')!)
expect(await screen.findByDisplayValue('C:\\Program Files\\Git\\bin\\bash.exe')).toBeInTheDocument()
expect(open).toHaveBeenCalledWith({
title: 'Bash Path',
multiple: false,
filters: [{
name: 'Bash Executable',
extensions: ['exe', '', 'bat', 'cmd', 'ps1'],
}],
})
})
})