cc-haha/desktop/src/components/settings/TerminalPanel.restart.test.tsx
程序员阿江(Relakkes) 376e255b6b feat: add a bundled desktop setup terminal with safer restart handoff
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
2026-04-22 18:07:09 +08:00

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()
})
})