diff --git a/web/src/components/Chat/MessageList.test.tsx b/web/src/components/Chat/MessageList.test.tsx
new file mode 100644
index 0000000..685d69c
--- /dev/null
+++ b/web/src/components/Chat/MessageList.test.tsx
@@ -0,0 +1,62 @@
+import { render } from '@testing-library/react';
+import { describe, it, expect } from 'vitest';
+import { MessageList } from './MessageList';
+import type { ChatMessage } from '../../types/protocol';
+
+function makeMessage(
+ id: string,
+ role: 'user' | 'assistant' = 'assistant',
+ content = 'hello',
+): ChatMessage {
+ return {
+ id,
+ role,
+ content,
+ timestamp: Math.floor(Date.now() / 1000),
+ type: 'message',
+ };
+}
+
+describe('MessageList (virtualized)', () => {
+ it('1. renders empty state when no messages', () => {
+ const { getByText } = render();
+ expect(getByText('开始新的对话')).toBeDefined();
+ });
+
+ it('2. renders without crash with many messages (virtualization smoke test)', () => {
+ const messages = Array.from({ length: 100 }, (_, i) =>
+ makeMessage(`m${i}`, 'assistant', `content ${i}`),
+ );
+ const { container } = render();
+ // 虚拟化下 DOM 节点数应远少于 100(jsdom 下可能渲染部分或全部,但不崩溃)
+ expect(container.firstChild).not.toBeNull();
+ });
+
+ it('3. highlightedMessageId does not crash when target is virtualized out', () => {
+ const messages = Array.from({ length: 50 }, (_, i) => makeMessage(`m${i}`));
+ const { rerender } = render();
+ // 高亮一条靠后的消息(可能在视口外),不应崩溃
+ expect(() =>
+ rerender(),
+ ).not.toThrow();
+ });
+
+ it('4. viewKey switch does not crash', () => {
+ const messages = Array.from({ length: 10 }, (_, i) => makeMessage(`m${i}`));
+ const { rerender } = render();
+ expect(() => rerender()).not.toThrow();
+ });
+
+ it('5. streaming (content grows on rerender) does not crash', () => {
+ // 注意:jsdom 下 getBoundingClientRect 返回 0 + rAF 同步化,virtualizer 测量后
+ // 可见范围可能变空(textContent 不可靠)。本用例只验证流式 rerender 不崩溃,
+ // 内容渲染的正确性由浏览器真实布局保证,jsdom 无法可靠断言。
+ const base = Array.from({ length: 5 }, (_, i) => makeMessage(`m${i}`));
+ const { rerender } = render();
+ const streamed = [...base];
+ streamed[4] = { ...streamed[4], content: 'streaming part1' };
+ expect(() => rerender()).not.toThrow();
+ streamed[4] = { ...streamed[4], content: 'streaming part1 part2' };
+ expect(() => rerender()).not.toThrow();
+ });
+});