mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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.
129 lines
3.9 KiB
TypeScript
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')
|
|
})
|
|
})
|