diff --git a/frontend/src/components/shared/terminal/terminal.test.tsx b/frontend/src/components/shared/terminal/terminal.test.tsx new file mode 100644 index 00000000..7d139455 --- /dev/null +++ b/frontend/src/components/shared/terminal/terminal.test.tsx @@ -0,0 +1,86 @@ +import { render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +const xterm = vi.hoisted(() => { + const state = { + clear() { + state.clearCount += 1; + state.visible = ''; + }, + clearCount: 0, + scrollToBottom() {}, + visible: '', + write(chunk: string) { + state.visible += chunk; + }, + }; + + return state; +}); + +vi.mock('./use-xterm', () => ({ + useXterm: () => ({ + clear: xterm.clear, + containerRef: { current: null }, + isReady: true, + scrollToBottom: xterm.scrollToBottom, + searchAddon: null, + write: xterm.write, + }), +})); + +vi.mock('./use-terminal-search', () => ({ + useTerminalSearch: () => ({ findNext: () => {}, findPrevious: () => {} }), +})); + +vi.mock('@/hooks/use-theme', () => ({ useTheme: () => ({ theme: 'dark' }) })); + +vi.mock('./terminal-sanitizer', () => ({ processLog: (line: string) => line })); + +import Terminal from './terminal'; + +const visibleLines = () => xterm.visible.split('\r\n').filter(Boolean); + +const reset = () => { + xterm.visible = ''; + xterm.clearCount = 0; +}; + +describe('Terminal incremental writer', () => { + it('restores the full log set after a filter narrows then clears', () => { + reset(); + const full = ['alpha-0', 'bravo-1', 'alpha-2', 'bravo-3', 'alpha-4']; + const filtered = ['bravo-1', 'bravo-3']; + + const { rerender } = render(); + expect(visibleLines()).toEqual(full); + + rerender(); + expect(visibleLines()).toEqual(filtered); + + rerender(); + expect(visibleLines()).toEqual(full); + }); + + it('appends streamed lines incrementally without clearing', () => { + reset(); + const { rerender } = render(); + + rerender(); + rerender(); + + expect(visibleLines()).toEqual(['line-0', 'line-1', 'line-2']); + expect(xterm.clearCount).toBe(0); + }); + + it('rewrites when the filter changes to a different, non-prefix set', () => { + reset(); + const full = ['keep-0', 'drop-1', 'keep-2', 'drop-3']; + + const { rerender } = render(); + rerender(); + rerender(); + + expect(visibleLines()).toEqual(['drop-1', 'drop-3']); + }); +}); diff --git a/frontend/src/components/shared/terminal/terminal.tsx b/frontend/src/components/shared/terminal/terminal.tsx index 5ec710f4..c7c10d64 100644 --- a/frontend/src/components/shared/terminal/terminal.tsx +++ b/frontend/src/components/shared/terminal/terminal.tsx @@ -28,8 +28,7 @@ function Terminal({ const { clear, containerRef, isReady, scrollToBottom, searchAddon, write } = useXterm({ theme }); const { findNext, findPrevious } = useTerminalSearch(searchAddon, isReady, searchValue, theme); - const lastLogIndexRef = useRef(0); - const prevLogsLengthRef = useRef(0); + const renderedLogsRef = useRef([]); useImperativeHandle(ref, () => ({ findNext, findPrevious }), [findNext, findPrevious]); @@ -38,20 +37,25 @@ function Terminal({ return; } - if (logs.length === 0 && prevLogsLengthRef.current > 0) { - clear(); - lastLogIndexRef.current = 0; - prevLogsLengthRef.current = 0; - - return; - } + const rendered = renderedLogsRef.current; if (logs.length === 0) { + if (rendered.length > 0) { + clear(); + renderedLogsRef.current = []; + } + return; } - if (logs.length >= lastLogIndexRef.current) { - const newLogs = logs.slice(lastLogIndexRef.current); + // Append the tail only when the on-screen lines are an exact prefix of + // the new array. `logs` is also fed filtered subsets (flow-terminal), so + // a length-only check reappends the tail over stale lines when it shrinks + // then grows back. + const isAppend = logs.length >= rendered.length && rendered.every((line, index) => line === logs[index]); + + if (isAppend) { + const newLogs = logs.slice(rendered.length); if (newLogs.length > 0) { const batch = newLogs.filter(Boolean).map(processLog).join('\r\n'); @@ -73,8 +77,7 @@ function Terminal({ scrollToBottom(); } - lastLogIndexRef.current = logs.length; - prevLogsLengthRef.current = logs.length; + renderedLogsRef.current = logs; }, [logs, isReady, write, clear, scrollToBottom]); return (