import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { Profiler } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { createProductSnapshot } from '../fixtures/coding-conversation-product-fixtures'; const attachmentApi = vi.hoisted(() => ({ load: vi.fn() })); vi.mock('@/lib/coding-attachments', () => ({ loadCodingAttachmentPreview: attachmentApi.load, })); describe('CodingConversationTimeline', () => { afterEach(() => vi.unstubAllGlobals()); it('resolves attachment refs into temporary object URLs without base64 state', async () => { const createObjectURL = vi.fn(() => 'blob:authenticated-preview'); const revokeObjectURL = vi.fn(); class TestURL extends URL {} TestURL.createObjectURL = createObjectURL; TestURL.revokeObjectURL = revokeObjectURL; vi.stubGlobal('URL', TestURL); attachmentApi.load.mockResolvedValue({ bytes: new Uint8Array([1, 2, 3]), mime: 'image/png', }); const { codingConversationStore } = await import('@/stores/coding-conversations'); const { CodingConversationTimeline } = await import( '@/pages/Chat/CodingConversationTimeline' ); const base = createProductSnapshot('conversation-attachment', 1); const snapshot = { ...base, nodes: [{ kind: 'message' as const, id: 'message-1', role: 'user' as const, status: 'complete' as const, blocks: [{ kind: 'image' as const, id: 'image-1', attachmentId: 'attachment-1', mime: 'image/png', }], }], }; codingConversationStore.getState().applySnapshotEvent({ type: 'snapshot', conversationId: 'conversation-attachment', workerGeneration: 1, seq: snapshot.cursor.seq, snapshot, }); const view = render( , ); await waitFor(() => expect(screen.getByRole('img', { name: '对话图片附件' })) .toHaveAttribute('src', 'blob:authenticated-preview')); expect(attachmentApi.load).toHaveBeenCalledWith('attachment-1'); expect(createObjectURL).toHaveBeenCalledOnce(); const blob = createObjectURL.mock.calls[0][0] as Blob; expect(blob.type).toBe('image/png'); expect(JSON.stringify(codingConversationStore.getState())).not.toContain('base64'); view.unmount(); expect(revokeObjectURL).toHaveBeenCalledWith('blob:authenticated-preview'); }); it('windows long timelines and loads earlier nodes only on demand', async () => { const { codingConversationStore } = await import('@/stores/coding-conversations'); const { CodingConversationTimeline } = await import( '@/pages/Chat/CodingConversationTimeline' ); const base = createProductSnapshot('conversation-window', 1); const snapshot = { ...base, nodes: Array.from({ length: 150 }, (_, index) => ({ kind: 'notice' as const, id: `notice-${index}`, code: `NOTICE_${index}`, level: 'info' as const, message: `Notice ${index}`, })), }; codingConversationStore.getState().applySnapshotEvent({ type: 'snapshot', conversationId: 'conversation-window', workerGeneration: 1, seq: snapshot.cursor.seq, snapshot, }); render(); expect(screen.queryByText('Notice 0')).not.toBeInTheDocument(); expect(screen.getByText('Notice 30')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '加载更早内容' })); expect(screen.getByText('Notice 0')).toBeInTheDocument(); }); it('does not commit the selected timeline when a hidden Conversation streams', async () => { const { codingConversationStore } = await import('@/stores/coding-conversations'); const { CodingConversationTimeline } = await import( '@/pages/Chat/CodingConversationTimeline' ); const selected = createProductSnapshot('conversation-selected', 1); const hidden = createProductSnapshot('conversation-hidden', 1); codingConversationStore.getState().applySnapshotEvent({ type: 'snapshot', conversationId: 'conversation-selected', workerGeneration: 1, seq: selected.cursor.seq, snapshot: selected, }); codingConversationStore.getState().applySnapshotEvent({ type: 'snapshot', conversationId: 'conversation-hidden', workerGeneration: 1, seq: hidden.cursor.seq, snapshot: hidden, }); const commits: number[] = []; render( commits.push(duration)}> , ); commits.length = 0; act(() => { codingConversationStore.getState().applyPatchBatchEvent({ type: 'patch-batch', conversationId: 'conversation-hidden', workerGeneration: 1, fromSeq: 1, toSeq: 1, items: [{ seq: 1, at: 1, patch: { op: 'message.upsert', node: { kind: 'message', id: 'hidden-message', role: 'assistant', status: 'streaming', blocks: [{ kind: 'text', id: 'hidden-text', text: 'hidden token', status: 'streaming', }], }, }, }], }); }); expect(commits).toEqual([]); }); it('renders nested subagents, compacted retry state, inline tool output, and explicit branch wording', async () => { const { codingConversationStore } = await import('@/stores/coding-conversations'); const { CodingConversationTimeline } = await import( '@/pages/Chat/CodingConversationTimeline' ); const base = createProductSnapshot('conversation-feature-ui', 1); const snapshot = { ...base, nodes: [ { kind: 'message' as const, id: 'message-user-feature', sourceEntryId: 'entry-user-feature', role: 'user' as const, status: 'complete' as const, blocks: [{ kind: 'text' as const, id: 'text-user-feature', text: 'Build it', status: 'complete' as const }], }, { kind: 'message' as const, id: 'message-feature', sourceEntryId: 'entry-assistant-feature', role: 'assistant' as const, status: 'complete' as const, blocks: [{ kind: 'text' as const, id: 'text-feature', text: 'Ready', status: 'complete' as const }], }, { kind: 'message' as const, id: 'message-optimistic-feature', sourceEntryId: 'entry-optimistic-feature', clientRequestId: 'request-optimistic-feature', role: 'user' as const, status: 'optimistic' as const, blocks: [{ kind: 'text' as const, id: 'text-optimistic-feature', text: 'Pending', status: 'complete' as const }], }, { kind: 'message' as const, id: 'message-without-entry-feature', role: 'user' as const, status: 'complete' as const, blocks: [{ kind: 'text' as const, id: 'text-without-entry-feature', text: 'Local only', status: 'complete' as const }], }, { kind: 'tool' as const, id: 'tool-feature', toolCallId: 'call-feature', toolName: 'read_file', title: '读取文件', inputText: 'src/app.ts', status: 'complete' as const, output: [{ kind: 'text' as const, id: 'tool-output', text: 'inline result', status: 'complete' as const }], details: { schema: 'changed-file.v1' as const, paths: ['src/app.ts'] }, }, { kind: 'subagent' as const, id: 'subagent-feature', runId: 'run-feature', details: { schema: 'subagent.v1' as const, dispatchId: 'dispatch-feature', mode: 'parallel' as const, tasks: [ { taskId: 'task-a', agentId: 'reader', toolProfile: 'read-only' as const, status: 'complete' as const, summary: 'Read complete' }, { taskId: 'task-b', agentId: 'builder', toolProfile: 'coding' as const, status: 'error' as const, errorCode: 'BUILD_FAILED' }, ], }, }, { kind: 'subagent' as const, id: 'subagent-single', runId: 'run-feature', details: { schema: 'subagent.v1' as const, dispatchId: 'dispatch-single', mode: 'single' as const, tasks: [{ taskId: 'task-single', agentId: 'reviewer', toolProfile: 'read-only' as const, status: 'aborted' as const }], }, }, { kind: 'subagent' as const, id: 'subagent-chain', runId: 'run-feature', details: { schema: 'subagent.v1' as const, dispatchId: 'dispatch-chain', mode: 'chain' as const, tasks: [ { taskId: 'task-chain-a', agentId: 'planner', toolProfile: 'read-only' as const, status: 'complete' as const }, { taskId: 'task-chain-b', agentId: 'implementer', toolProfile: 'coding' as const, status: 'skipped' as const }, ], }, }, { kind: 'compaction' as const, id: 'compact-feature', runId: 'run-feature', source: 'automatic' as const, status: 'error' as const, willRetry: true, summary: '保留了本轮关键上下文。', }, ], }; codingConversationStore.getState().applySnapshotEvent({ type: 'snapshot', conversationId: 'conversation-feature-ui', workerGeneration: 1, seq: snapshot.cursor.seq, snapshot, }); const onFork = vi.fn(); render(); const forkActions = screen.getAllByRole('button', { name: '从这里创建新对话分支' }); expect(forkActions).toHaveLength(1); fireEvent.click(forkActions[0]!); expect(onFork).toHaveBeenCalledWith('entry-user-feature'); expect(screen.getByText('并行子任务')).toBeInTheDocument(); expect(screen.getByText('单个子任务')).toBeInTheDocument(); expect(screen.getByText('串行子任务')).toBeInTheDocument(); expect(screen.getByText('reader')).toBeInTheDocument(); expect(screen.getByText('BUILD_FAILED')).toBeInTheDocument(); expect(screen.getByText('系统会自动重试。')).toBeInTheDocument(); expect(screen.getByText('inline result').closest('[data-node-kind="tool"]')).not.toBeNull(); expect(screen.queryByText(/回滚|revert/i)).not.toBeInTheDocument(); }); });