mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Settings needed a real shell for plugin, MCP, and skill setup without relying on a globally installed Claude CLI. Add an xterm.js terminal backed by portable-pty, wire it into the Tauri desktop runtime, and move shell restart handoff to the new session before old PTY teardown so the UI is less likely to stall behind child shutdown. Constraint: The desktop app must inject the bundled CLI into the shell environment instead of requiring a separate global install Constraint: Restart teardown cannot block the frontend-facing Tauri command path Rejected: Keep terminal setup inside installer chat only | that flow cannot replace an interactive shell Rejected: Wait for old PTY shutdown before adopting the new session | it keeps restart vulnerable to hung child teardown Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Preserve new-session handoff before old-session cleanup when changing terminal lifecycle or restart logic Tested: bunx vitest run src/__tests__/terminalPanel.test.tsx src/components/settings/TerminalPanel.restart.test.tsx; bun run lint; cargo check Not-tested: Full packaged-app command echo and repeated manual restart behavior still need additional runtime verification
128 lines
3.2 KiB
TypeScript
128 lines
3.2 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
import '@testing-library/jest-dom'
|
|
|
|
import { useSettingsStore } from '../../stores/settingsStore'
|
|
|
|
const terminalMocks = vi.hoisted(() => ({
|
|
startDesktopTerminal: vi.fn(),
|
|
closeDesktopTerminal: vi.fn(),
|
|
resizeDesktopTerminal: vi.fn().mockResolvedValue(undefined),
|
|
writeDesktopTerminal: vi.fn().mockResolvedValue(undefined),
|
|
listenDesktopTerminalEvents: vi.fn().mockResolvedValue(() => {}),
|
|
}))
|
|
|
|
vi.mock('../../lib/desktopRuntime', () => ({
|
|
isTauriRuntime: () => true,
|
|
}))
|
|
|
|
vi.mock('../../lib/settingsTerminal', () => ({
|
|
startDesktopTerminal: terminalMocks.startDesktopTerminal,
|
|
closeDesktopTerminal: terminalMocks.closeDesktopTerminal,
|
|
resizeDesktopTerminal: terminalMocks.resizeDesktopTerminal,
|
|
writeDesktopTerminal: terminalMocks.writeDesktopTerminal,
|
|
listenDesktopTerminalEvents: terminalMocks.listenDesktopTerminalEvents,
|
|
}))
|
|
|
|
class MockTerminal {
|
|
cols = 100
|
|
rows = 24
|
|
|
|
loadAddon() {}
|
|
|
|
open() {}
|
|
|
|
focus() {}
|
|
|
|
clear() {}
|
|
|
|
write() {}
|
|
|
|
onData() {
|
|
return { dispose() {} }
|
|
}
|
|
|
|
dispose() {}
|
|
}
|
|
|
|
class MockFitAddon {
|
|
fit() {}
|
|
}
|
|
|
|
vi.mock('@xterm/xterm', () => ({
|
|
Terminal: MockTerminal,
|
|
}))
|
|
|
|
vi.mock('@xterm/addon-fit', () => ({
|
|
FitAddon: MockFitAddon,
|
|
}))
|
|
|
|
import { TerminalPanel } from './TerminalPanel'
|
|
|
|
function createDeferred<T>() {
|
|
let resolve!: (value: T | PromiseLike<T>) => void
|
|
const promise = new Promise<T>((innerResolve) => {
|
|
resolve = innerResolve
|
|
})
|
|
return { promise, resolve }
|
|
}
|
|
|
|
describe('TerminalPanel restart flow', () => {
|
|
beforeEach(() => {
|
|
useSettingsStore.setState({ locale: 'en' })
|
|
|
|
vi.stubGlobal(
|
|
'ResizeObserver',
|
|
class {
|
|
observe() {}
|
|
disconnect() {}
|
|
},
|
|
)
|
|
|
|
terminalMocks.startDesktopTerminal.mockReset()
|
|
terminalMocks.closeDesktopTerminal.mockReset()
|
|
terminalMocks.resizeDesktopTerminal.mockClear()
|
|
terminalMocks.writeDesktopTerminal.mockClear()
|
|
terminalMocks.listenDesktopTerminalEvents.mockClear()
|
|
|
|
terminalMocks.startDesktopTerminal
|
|
.mockResolvedValueOnce({
|
|
sessionId: 'session-1',
|
|
shell: 'bash',
|
|
cwd: '/Users/nanmi',
|
|
explicitCommandName: 'claude-haha',
|
|
docsCommandName: 'claude',
|
|
})
|
|
.mockResolvedValueOnce({
|
|
sessionId: 'session-2',
|
|
shell: 'bash',
|
|
cwd: '/tmp/restarted',
|
|
explicitCommandName: 'claude-haha',
|
|
docsCommandName: 'claude',
|
|
})
|
|
})
|
|
|
|
it('switches to the new session without waiting for old close to finish', async () => {
|
|
const oldClose = createDeferred<void>()
|
|
terminalMocks.closeDesktopTerminal.mockImplementation((sessionId: string) => {
|
|
if (sessionId === 'session-1') return oldClose.promise
|
|
return Promise.resolve()
|
|
})
|
|
|
|
render(<TerminalPanel />)
|
|
|
|
await screen.findByText('Shell: bash · Working directory: /Users/nanmi')
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Restart shell' }))
|
|
|
|
await waitFor(() =>
|
|
expect(
|
|
screen.getByText('Shell: bash · Working directory: /tmp/restarted'),
|
|
).toBeInTheDocument(),
|
|
)
|
|
expect(terminalMocks.closeDesktopTerminal).toHaveBeenCalledWith('session-1')
|
|
|
|
oldClose.resolve()
|
|
})
|
|
})
|