290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
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(
|
|
<CodingConversationTimeline conversationId="conversation-attachment" />,
|
|
);
|
|
|
|
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(<CodingConversationTimeline conversationId="conversation-window" />);
|
|
|
|
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(
|
|
<Profiler id="selected-timeline" onRender={(_id, _phase, duration) => commits.push(duration)}>
|
|
<CodingConversationTimeline conversationId="conversation-selected" />
|
|
</Profiler>,
|
|
);
|
|
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(<CodingConversationTimeline conversationId="conversation-feature-ui" onFork={onFork} />);
|
|
|
|
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();
|
|
});
|
|
});
|