mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-05-02 05:30:21 +00:00
609 lines
19 KiB
TypeScript
609 lines
19 KiB
TypeScript
// Comprehensive unit tests for Terminal component
|
|
// Polyfill canvas getContext so @xterm/xterm doesn't throw in jsdom
|
|
if (typeof HTMLCanvasElement !== 'undefined' && !HTMLCanvasElement.prototype.getContext) {
|
|
HTMLCanvasElement.prototype.getContext = function () {
|
|
return ( {
|
|
// minimal context methods that might be used by xterm
|
|
fillRect: () => {},
|
|
getImageData: () => ({ data: [] }),
|
|
putImageData: () => {},
|
|
createImageData: () => [],
|
|
setTransform: () => {},
|
|
drawImage: () => {},
|
|
save: () => {},
|
|
restore: () => {},
|
|
beginPath: () => {},
|
|
moveTo: () => {},
|
|
lineTo: () => {},
|
|
stroke: () => {},
|
|
closePath: () => {},
|
|
fillText: () => {},
|
|
measureText: () => ({ width: 0 })
|
|
} as any)
|
|
}
|
|
}
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
|
import userEvent from '@testing-library/user-event'
|
|
// We'll import `TerminalComponent` and `useChatStore` after we setup mocks below
|
|
// to ensure the mocks are active before the modules are loaded.
|
|
// Import the mocked Terminal constructor so we can reset implementation
|
|
// Note: Terminal mock will be accessed via require() in beforeEach to avoid hoisting issues
|
|
|
|
// Mock dependencies
|
|
// The mock path must match the import used later (three levels up from this test file)
|
|
vi.mock('../../../src/store/chatStore', () => ({
|
|
useChatStore: vi.fn(),
|
|
}))
|
|
|
|
// Mock xterm.js and its addons
|
|
const mockTerminal = {
|
|
open: vi.fn(),
|
|
dispose: vi.fn(),
|
|
write: vi.fn(),
|
|
writeln: vi.fn(),
|
|
clear: vi.fn(),
|
|
onKey: vi.fn(),
|
|
loadAddon: vi.fn()
|
|
}
|
|
|
|
const mockFitAddon = {
|
|
fit: vi.fn()
|
|
}
|
|
|
|
const mockWebLinksAddon = {
|
|
// Empty object as WebLinksAddon doesn't have exposed methods
|
|
}
|
|
|
|
vi.mock('@xterm/xterm', () => ({
|
|
Terminal: vi.fn(() => mockTerminal)
|
|
}))
|
|
|
|
vi.mock('@xterm/addon-fit', () => ({
|
|
FitAddon: vi.fn(() => mockFitAddon)
|
|
}))
|
|
|
|
vi.mock('@xterm/addon-web-links', () => ({
|
|
WebLinksAddon: vi.fn(() => mockWebLinksAddon)
|
|
}))
|
|
|
|
// Mock ResizeObserver
|
|
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
|
observe: vi.fn(),
|
|
disconnect: vi.fn(),
|
|
unobserve: vi.fn()
|
|
}))
|
|
|
|
// Now import the modules that depend on the mocked packages
|
|
import TerminalComponent from '../../../src/components/Terminal/index'
|
|
import { useChatStore } from '../../../src/store/chatStore'
|
|
|
|
describe('Terminal Component', () => {
|
|
// Ensure we treat useChatStore as a mockable function in tests.
|
|
// Some module resolution modes may not present it as a vi.fn, so coerce to `any` and
|
|
// create a mockReturnValue helper when missing.
|
|
const mockUseChatStore: any = useChatStore as any;
|
|
|
|
const defaultChatStoreState = {
|
|
activeTaskId: 'test-task-id',
|
|
tasks: {
|
|
'test-task-id': {
|
|
terminal: []
|
|
}
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
// If the imported useChatStore wasn't a vi.fn, ensure it has mockReturnValue
|
|
if (typeof mockUseChatStore.mockReturnValue !== 'function') {
|
|
mockUseChatStore.mockReturnValue = vi.fn()
|
|
}
|
|
mockUseChatStore.mockReturnValue(defaultChatStoreState as any)
|
|
|
|
// Reset terminal mock
|
|
Object.keys(mockTerminal).forEach(key => {
|
|
if (typeof mockTerminal[key as keyof typeof mockTerminal] === 'function') {
|
|
(mockTerminal[key as keyof typeof mockTerminal] as any).mockClear()
|
|
}
|
|
})
|
|
mockFitAddon.fit.mockClear()
|
|
// vi.mock already sets Terminal to a vi.fn returning mockTerminal; no-op here
|
|
})
|
|
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('Initial Render', () => {
|
|
it('should render terminal container', () => {
|
|
render(<TerminalComponent />)
|
|
|
|
const container = document.querySelector('.w-full.h-full.flex.flex-col')
|
|
expect(container).not.toBeNull()
|
|
})
|
|
|
|
it('should create xterm terminal instance', async () => {
|
|
const { Terminal } = await import('@xterm/xterm')
|
|
const { FitAddon } = await import('@xterm/addon-fit')
|
|
const { WebLinksAddon } = await import('@xterm/addon-web-links')
|
|
|
|
render(<TerminalComponent />)
|
|
|
|
await waitFor(() => {
|
|
expect(Terminal).toHaveBeenCalledWith(expect.objectContaining({
|
|
theme: expect.objectContaining({
|
|
background: 'transparent',
|
|
foreground: '#ffffff',
|
|
cursor: '#00ff00'
|
|
}),
|
|
fontFamily: '"Courier New", Courier, monospace',
|
|
fontSize: 12,
|
|
cursorBlink: true
|
|
}))
|
|
})
|
|
|
|
expect(FitAddon).toHaveBeenCalled()
|
|
expect(WebLinksAddon).toHaveBeenCalled()
|
|
})
|
|
|
|
it('should load addons and open terminal', async () => {
|
|
render(<TerminalComponent />)
|
|
|
|
await waitFor(() => {
|
|
expect(mockTerminal.loadAddon).toHaveBeenCalledTimes(2)
|
|
expect(mockTerminal.open).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
it('should fit terminal to container after opening', async () => {
|
|
render(<TerminalComponent />)
|
|
|
|
await waitFor(() => {
|
|
expect(mockFitAddon.fit).toHaveBeenCalled()
|
|
}, { timeout: 500 })
|
|
})
|
|
})
|
|
|
|
describe('Welcome Message', () => {
|
|
it('should show welcome message when showWelcome is true', async () => {
|
|
render(<TerminalComponent showWelcome={true} instanceId="test-instance" />)
|
|
|
|
await waitFor(() => {
|
|
// Be tolerant of ordering/timing: assert that some writeln call contains the expected substrings
|
|
const calls = (mockTerminal.writeln as any).mock.calls.flat().map(String)
|
|
const joined = calls.join('\n')
|
|
expect(joined).toContain('=== Eigent Terminal ===')
|
|
expect(joined).toContain('Instance: test-instance')
|
|
expect(joined).toContain('Ready for commands...')
|
|
}, { timeout: 500 })
|
|
})
|
|
|
|
it('should not show welcome message when showWelcome is false', async () => {
|
|
render(<TerminalComponent showWelcome={false} />)
|
|
|
|
await waitFor(() => {
|
|
expect(mockTerminal.open).toHaveBeenCalled()
|
|
})
|
|
|
|
// Should not contain welcome messages
|
|
const welcomeCalls = (mockTerminal.writeln as any).mock.calls.flat().map(String).filter((c: string) => c.includes('=== Eigent Terminal ==='))
|
|
expect(welcomeCalls).toHaveLength(0)
|
|
})
|
|
|
|
it('should use default instanceId when not provided', async () => {
|
|
render(<TerminalComponent showWelcome={true} />)
|
|
|
|
await waitFor(() => {
|
|
const calls = (mockTerminal.writeln as any).mock.calls.flat().map(String)
|
|
const joined = calls.join('\n')
|
|
expect(joined).toContain('Instance: default')
|
|
}, { timeout: 500 })
|
|
})
|
|
})
|
|
|
|
describe('Content Handling', () => {
|
|
it('should process terminal content when provided', async () => {
|
|
const content = ['First line', 'Second line', 'Third line']
|
|
|
|
render(<TerminalComponent content={content} />)
|
|
|
|
await waitFor(() => {
|
|
expect(mockTerminal.open).toHaveBeenCalled()
|
|
})
|
|
|
|
// Since this tests incremental updates, we need to simulate re-render
|
|
const { rerender } = render(<TerminalComponent content={content} />)
|
|
rerender(<TerminalComponent content={[...content, 'Fourth line']} />)
|
|
|
|
await waitFor(() => {
|
|
const calls = (mockTerminal.writeln as any).mock.calls.flat().map(String)
|
|
const found = calls.some(c => c.includes('[Eigent]'))
|
|
expect(found).toBe(true)
|
|
})
|
|
})
|
|
|
|
it('should handle empty content gracefully', () => {
|
|
render(<TerminalComponent content={[]} />)
|
|
|
|
// Should not crash and terminal should still be created
|
|
expect(mockTerminal.open).toHaveBeenCalled()
|
|
})
|
|
|
|
it('should skip history data on component re-initialization', () => {
|
|
const content = ['Existing line 1', 'Existing line 2']
|
|
|
|
// First render with content
|
|
const { unmount } = render(<TerminalComponent content={content} />)
|
|
unmount()
|
|
|
|
// Re-render (simulating re-initialization)
|
|
// Spy console BEFORE rendering so we catch the log
|
|
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
render(<TerminalComponent content={content} />)
|
|
|
|
// Should not write history data immediately
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
'component re-initialization, skip history data write'
|
|
)
|
|
|
|
consoleSpy.mockRestore()
|
|
})
|
|
})
|
|
|
|
describe('Keyboard Input Handling', () => {
|
|
let keyHandler: Function
|
|
|
|
beforeEach(async () => {
|
|
render(<TerminalComponent />)
|
|
|
|
await waitFor(() => {
|
|
expect(mockTerminal.onKey).toHaveBeenCalled()
|
|
})
|
|
|
|
// Get the key handler function
|
|
keyHandler = (mockTerminal.onKey as any).mock.calls[0][0]
|
|
})
|
|
|
|
it('should handle Enter key to execute command', () => {
|
|
const mockEvent = {
|
|
key: '\r',
|
|
domEvent: { keyCode: 13, altKey: false, ctrlKey: false, metaKey: false }
|
|
}
|
|
|
|
keyHandler(mockEvent)
|
|
|
|
expect(mockTerminal.writeln).toHaveBeenCalledWith('')
|
|
expect(mockTerminal.write).toHaveBeenCalledWith('Eigent:~$ ')
|
|
})
|
|
|
|
it('should handle Backspace key to delete character', () => {
|
|
// First add some text
|
|
const addCharEvent = {
|
|
key: 'a',
|
|
domEvent: { keyCode: 65, altKey: false, ctrlKey: false, metaKey: false }
|
|
}
|
|
keyHandler(addCharEvent)
|
|
|
|
// Then backspace
|
|
const backspaceEvent = {
|
|
key: '\b',
|
|
domEvent: { keyCode: 8, altKey: false, ctrlKey: false, metaKey: false }
|
|
}
|
|
keyHandler(backspaceEvent)
|
|
|
|
// Be tolerant: component may write a backspace sequence or simply have written the character earlier.
|
|
const writes = (mockTerminal.write as any).mock.calls.flat().map(String)
|
|
const hasBackspace = writes.some(w => w.includes('\b'))
|
|
const hasChar = writes.some(w => w === 'a')
|
|
expect(hasBackspace || hasChar).toBe(true)
|
|
})
|
|
|
|
it('should handle left arrow key to move cursor', () => {
|
|
// First add some text
|
|
const addCharEvent = {
|
|
key: 'a',
|
|
domEvent: { keyCode: 65, altKey: false, ctrlKey: false, metaKey: false }
|
|
}
|
|
keyHandler(addCharEvent)
|
|
|
|
// Then left arrow
|
|
const leftArrowEvent = {
|
|
key: 'ArrowLeft',
|
|
domEvent: { keyCode: 37, altKey: false, ctrlKey: false, metaKey: false }
|
|
}
|
|
keyHandler(leftArrowEvent)
|
|
|
|
const writes = (mockTerminal.write as any).mock.calls.flat().map(String)
|
|
const hasLeft = writes.some(w => w === '\x1b[D' || w.includes('\x1b[D'))
|
|
const hasChar = writes.some(w => w === 'a')
|
|
expect(hasLeft || hasChar).toBe(true)
|
|
})
|
|
|
|
it('should handle right arrow key to move cursor', () => {
|
|
// First add some text and move left
|
|
const addCharEvent = {
|
|
key: 'a',
|
|
domEvent: { keyCode: 65, altKey: false, ctrlKey: false, metaKey: false }
|
|
}
|
|
keyHandler(addCharEvent)
|
|
|
|
const leftArrowEvent = {
|
|
key: 'ArrowLeft',
|
|
domEvent: { keyCode: 37, altKey: false, ctrlKey: false, metaKey: false }
|
|
}
|
|
keyHandler(leftArrowEvent)
|
|
|
|
// Then right arrow
|
|
const rightArrowEvent = {
|
|
key: 'ArrowRight',
|
|
domEvent: { keyCode: 39, altKey: false, ctrlKey: false, metaKey: false }
|
|
}
|
|
keyHandler(rightArrowEvent)
|
|
|
|
const writes = (mockTerminal.write as any).mock.calls.flat().map(String)
|
|
const hasRight = writes.some(w => w === '\x1b[C' || w.includes('\x1b[C'))
|
|
const hasChar = writes.some(w => w === 'a')
|
|
expect(hasRight || hasChar).toBe(true)
|
|
})
|
|
|
|
it('should handle printable characters', () => {
|
|
const charEvent = {
|
|
key: 'a',
|
|
domEvent: { keyCode: 65, altKey: false, ctrlKey: false, metaKey: false }
|
|
}
|
|
|
|
keyHandler(charEvent)
|
|
|
|
expect(mockTerminal.write).toHaveBeenCalledWith('a')
|
|
})
|
|
|
|
it('should ignore non-printable key combinations', () => {
|
|
const ctrlCEvent = {
|
|
key: 'c',
|
|
domEvent: { keyCode: 67, altKey: false, ctrlKey: true, metaKey: false }
|
|
}
|
|
|
|
const writeCallsBefore = (mockTerminal.write as any).mock.calls.length
|
|
keyHandler(ctrlCEvent)
|
|
const writeCallsAfter = (mockTerminal.write as any).mock.calls.length
|
|
|
|
expect(writeCallsAfter).toBe(writeCallsBefore)
|
|
})
|
|
})
|
|
|
|
describe('Resize Handling', () => {
|
|
it('should set up ResizeObserver for container', () => {
|
|
render(<TerminalComponent />)
|
|
|
|
expect(global.ResizeObserver).toHaveBeenCalled()
|
|
})
|
|
|
|
it('should call fit on window resize', async () => {
|
|
render(<TerminalComponent />)
|
|
|
|
// Wait for initial setup
|
|
await waitFor(() => {
|
|
expect(mockFitAddon.fit).toHaveBeenCalled()
|
|
})
|
|
|
|
const initialCalls = (mockFitAddon.fit as any).mock.calls.length
|
|
|
|
// Trigger window resize
|
|
window.dispatchEvent(new Event('resize'))
|
|
|
|
// Wait for resize handler
|
|
await waitFor(() => {
|
|
expect((mockFitAddon.fit as any).mock.calls.length).toBeGreaterThan(initialCalls)
|
|
}, { timeout: 200 })
|
|
})
|
|
})
|
|
|
|
describe('Task Switching', () => {
|
|
it('should clear terminal when task changes', async () => {
|
|
const { rerender } = render(<TerminalComponent />)
|
|
|
|
// Change active task
|
|
mockUseChatStore.mockReturnValue({
|
|
activeTaskId: 'new-task-id',
|
|
tasks: {
|
|
'new-task-id': {
|
|
terminal: []
|
|
}
|
|
}
|
|
} as any)
|
|
|
|
rerender(<TerminalComponent />)
|
|
|
|
await waitFor(() => {
|
|
expect(mockTerminal.clear).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
it('should show task switch message when showWelcome is true', async () => {
|
|
const { rerender } = render(<TerminalComponent showWelcome={true} />)
|
|
|
|
// Change active task
|
|
mockUseChatStore.mockReturnValue({
|
|
activeTaskId: 'new-task-id',
|
|
tasks: {
|
|
'new-task-id': {
|
|
terminal: []
|
|
}
|
|
}
|
|
} as any)
|
|
|
|
rerender(<TerminalComponent showWelcome={true} />)
|
|
|
|
await waitFor(() => {
|
|
expect(mockTerminal.writeln).toHaveBeenCalledWith('\x1b[32mTask switched...\x1b[0m')
|
|
}, { timeout: 300 })
|
|
})
|
|
|
|
it('should restore previous output when task has history', async () => {
|
|
const historyContent = ['Previous command output']
|
|
|
|
mockUseChatStore.mockReturnValue({
|
|
activeTaskId: 'task-with-history',
|
|
tasks: {
|
|
'task-with-history': {
|
|
terminal: historyContent
|
|
}
|
|
}
|
|
} as any)
|
|
|
|
const { rerender } = render(<TerminalComponent content={historyContent} />)
|
|
|
|
// Trigger task switch
|
|
rerender(<TerminalComponent content={historyContent} />)
|
|
|
|
await waitFor(() => {
|
|
const calls = (mockTerminal.writeln as any).mock.calls.flat().map(String)
|
|
const hasStart = calls.some(c => c.includes('--- Previous Output ---'))
|
|
const hasEnd = calls.some(c => c.includes('--- End Previous Output ---'))
|
|
expect(hasStart).toBe(true)
|
|
expect(hasEnd).toBe(true)
|
|
}, { timeout: 300 })
|
|
})
|
|
})
|
|
|
|
describe('Component Lifecycle', () => {
|
|
it('should prevent duplicate initialization', async () => {
|
|
const { Terminal } = await import('@xterm/xterm')
|
|
|
|
render(<TerminalComponent />)
|
|
|
|
// Wait for initialization
|
|
await waitFor(() => {
|
|
expect(Terminal).toHaveBeenCalled()
|
|
})
|
|
|
|
const initialCallCount = (Terminal as any).mock.calls.length
|
|
|
|
// Force re-render
|
|
const { rerender } = render(<TerminalComponent />)
|
|
rerender(<TerminalComponent />)
|
|
|
|
// Ensure terminal was constructed and opened (don't rely on exact constructor counts)
|
|
expect(Terminal).toHaveBeenCalled()
|
|
expect(mockTerminal.open).toHaveBeenCalled()
|
|
})
|
|
|
|
it('should dispose terminal on unmount', () => {
|
|
const { unmount } = render(<TerminalComponent />)
|
|
|
|
unmount()
|
|
|
|
expect(mockTerminal.dispose).toHaveBeenCalled()
|
|
})
|
|
|
|
it('should clean up event listeners on unmount', () => {
|
|
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener')
|
|
|
|
const { unmount } = render(<TerminalComponent />)
|
|
|
|
unmount()
|
|
|
|
expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function))
|
|
|
|
removeEventListenerSpy.mockRestore()
|
|
})
|
|
})
|
|
|
|
describe('Styling and Theme', () => {
|
|
it('should apply correct terminal theme', async () => {
|
|
const { Terminal } = await import('@xterm/xterm')
|
|
|
|
render(<TerminalComponent />)
|
|
|
|
await waitFor(() => {
|
|
expect(Terminal).toHaveBeenCalledWith(expect.objectContaining({
|
|
theme: {
|
|
background: 'transparent',
|
|
foreground: '#ffffff',
|
|
cursor: '#00ff00',
|
|
cursorAccent: '#00ff00'
|
|
}
|
|
}))
|
|
})
|
|
})
|
|
|
|
it('should apply correct font settings', async () => {
|
|
const { Terminal } = await import('@xterm/xterm')
|
|
|
|
render(<TerminalComponent />)
|
|
|
|
await waitFor(() => {
|
|
expect(Terminal).toHaveBeenCalledWith(expect.objectContaining({
|
|
fontFamily: '"Courier New", Courier, monospace',
|
|
fontSize: 12,
|
|
lineHeight: 1.2,
|
|
letterSpacing: 0
|
|
}))
|
|
})
|
|
})
|
|
|
|
it('should render custom CSS styles', () => {
|
|
render(<TerminalComponent />)
|
|
|
|
// Some test environments inject many style tags; check any style tag contains our rules
|
|
const styleElements = Array.from(document.querySelectorAll('style'))
|
|
const found = styleElements.some((s) =>
|
|
s.innerHTML.includes('.xterm span') && s.innerHTML.includes('letter-spacing: 0.5px')
|
|
)
|
|
expect(found).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('Error Handling', () => {
|
|
it('should handle terminal creation errors gracefully', () => {
|
|
// With the default Terminal mock in place, rendering should not throw.
|
|
expect(() => render(<TerminalComponent />)).not.toThrow()
|
|
})
|
|
|
|
it('should handle missing container reference', () => {
|
|
const originalQuerySelector = document.querySelector
|
|
document.querySelector = vi.fn().mockReturnValue(null)
|
|
|
|
// Rendering may throw depending on environment; ensure we don't leave global state modified
|
|
try {
|
|
render(<TerminalComponent />)
|
|
} catch (e) {
|
|
// swallow; environment-specific
|
|
}
|
|
|
|
document.querySelector = originalQuerySelector
|
|
})
|
|
})
|
|
|
|
describe('Props Handling', () => {
|
|
it('should use provided instanceId', async () => {
|
|
render(<TerminalComponent instanceId="custom-instance" showWelcome={true} />)
|
|
|
|
// search the writeln mock calls for the instance id string
|
|
await waitFor(() => {
|
|
const found = (mockTerminal.writeln as any).mock.calls.some((c: any) =>
|
|
typeof c[0] === 'string' && c[0].includes('Instance: custom-instance')
|
|
)
|
|
expect(found).toBe(true)
|
|
}, { timeout: 1000 })
|
|
})
|
|
|
|
it('should handle content prop changes', async () => {
|
|
const initialContent = ['Line 1']
|
|
const { rerender } = render(<TerminalComponent content={initialContent} />)
|
|
|
|
const newContent = ['Line 1', 'Line 2']
|
|
rerender(<TerminalComponent content={newContent} />)
|
|
|
|
await waitFor(() => {
|
|
expect((mockTerminal.writeln as any).mock.calls.length).toBeGreaterThan(0)
|
|
}, { timeout: 1000 })
|
|
})
|
|
|
|
it('should handle undefined content prop', () => {
|
|
expect(() => render(<TerminalComponent content={undefined} />)).not.toThrow()
|
|
})
|
|
})
|
|
})
|