cc-haha/desktop/src/pages/TerminalSettings.test.tsx
程序员阿江(Relakkes) 0c58a9f36f Give desktop users a real terminal for command-based setup
Some setup flows end in a shell command instead of a natural-language install path, so Settings now exposes a host PTY terminal backed by portable-pty and xterm. The terminal inherits the user's login-shell environment, forces a UTF-8 locale when needed, and preserves split UTF-8 output so Chinese paths render correctly.

Constraint: Desktop GUI apps do not inherit the user's interactive shell PATH on macOS.
Constraint: Command output may split UTF-8 characters across PTY reads.
Rejected: Use Tauri shell commands only | users need an interactive PTY for copy-pasted install flows.
Rejected: Ask users to edit shell profiles | terminal setup should work out of the box.
Confidence: high
Scope-risk: moderate
Directive: Keep terminal startup tied to host environment checks; do not bundle runtimes to solve PATH issues.
Tested: cargo fmt --check; cargo test --lib; cargo check; cd desktop && bun run lint; cd desktop && bun run test src/pages/TerminalSettings.test.tsx; cd desktop && bun run build; cd desktop && bun run build:macos-arm64
Tested: Computer Use verified npm is available in the built macOS app terminal and Chinese output renders after UTF-8 decoding fix.
Not-tested: Native Windows/Linux package runtime validation.
2026-04-24 09:36:00 +08:00

129 lines
3.9 KiB
TypeScript

import { act, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const terminalMocks = vi.hoisted(() => {
const terminalInstance = {
cols: 80,
rows: 24,
loadAddon: vi.fn(),
open: vi.fn(),
dispose: vi.fn(),
onData: vi.fn(),
write: vi.fn(),
writeln: vi.fn(),
clear: 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(),
}
})
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,
},
}))
import { TerminalSettings } from './TerminalSettings'
describe('TerminalSettings', () => {
beforeEach(() => {
terminalMocks.available = false
terminalMocks.spawn.mockReset()
terminalMocks.write.mockReset()
terminalMocks.resize.mockReset()
terminalMocks.kill.mockReset()
terminalMocks.onOutput.mockReset()
terminalMocks.onExit.mockReset()
terminalMocks.terminalInstance.loadAddon.mockClear()
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.fitInstance.fit.mockClear()
terminalMocks.onOutput.mockResolvedValue(vi.fn())
terminalMocks.onExit.mockResolvedValue(vi.fn())
terminalMocks.write.mockResolvedValue(undefined)
terminalMocks.resize.mockResolvedValue(undefined)
terminalMocks.kill.mockResolvedValue(undefined)
terminalMocks.spawn.mockResolvedValue({
session_id: 7,
shell: '/bin/zsh',
cwd: '/Users/test',
})
vi.stubGlobal('ResizeObserver', class {
observe = vi.fn()
disconnect = vi.fn()
})
})
it('shows a desktop-runtime empty state outside Tauri', () => {
render(<TerminalSettings />)
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('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')
})
})