Files
makelore/tests/unit/coding-chat-panel.test.tsx

401 lines
15 KiB
TypeScript

import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ConversationSnapshot } from '@/types/coding-conversation';
import type {
CodingConversationMetadata,
CodingProjectAgent,
CodingProjectConfig,
CodingProjectSummary,
} from '@/types/coding-project';
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
const runtimeSnapshot = deferred<ConversationSnapshot>();
class FakeEventSource {
onopen: ((event: Event) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
readonly close = vi.fn();
addEventListener = vi.fn();
}
const source = new FakeEventSource();
const project: CodingProjectSummary = {
id: 'project-1',
name: 'Local project',
createdAt: '2026-08-23T00:00:00.000Z',
updatedAt: '2026-08-23T00:00:00.000Z',
lastOpenedAt: '2026-08-23T00:00:00.000Z',
};
const agent: CodingProjectAgent = {
id: 'agent-1',
avatarId: 'default',
roleName: '伙伴',
name: 'Builder',
builtIn: false,
enabled: true,
skillIds: [],
responsibility: {
mission: 'Build',
owns: [],
boundaries: [],
collaborators: [],
principles: [],
},
prompt: '',
archivedAt: null,
pinned: true,
createdAt: '2026-08-23T00:00:00.000Z',
updatedAt: '2026-08-23T00:00:00.000Z',
model: null,
modelResolution: 'required',
};
const config: CodingProjectConfig = {
schemaVersion: 2,
projectType: 'custom',
initialized: true,
agents: [agent],
knowledgeDirectory: 'knowledge',
legacyConversationNotice: 'none',
createdAt: '2026-08-23T00:00:00.000Z',
updatedAt: '2026-08-23T00:00:00.000Z',
};
const conversation: CodingConversationMetadata = {
id: 'conversation-1',
agentId: agent.id,
title: '新对话',
archivedAt: null,
unread: false,
createdAt: '2026-08-23T00:00:00.000Z',
updatedAt: '2026-08-23T00:00:00.000Z',
model: null,
modelResolution: 'required',
};
const reviewer: CodingProjectAgent = {
...agent,
id: 'agent-2',
name: 'Reviewer',
pinned: false,
};
const reviewerConversation: CodingConversationMetadata = {
...conversation,
id: 'conversation-2',
agentId: reviewer.id,
title: 'Reviewer conversation',
};
function configForAgents(agents: CodingProjectAgent[]): CodingProjectConfig {
return { ...config, agents };
}
const projectApi = vi.hoisted(() => ({
list: vi.fn(),
config: vi.fn(),
conversations: vi.fn(),
create: vi.fn(),
}));
const conversationApi = vi.hoisted(() => ({
snapshot: vi.fn(),
events: vi.fn(),
submit: vi.fn(),
recover: vi.fn(),
}));
const attachmentApi = vi.hoisted(() => ({ upload: vi.fn() }));
vi.mock('@/lib/coding-projects', () => ({
listCodingProjects: projectApi.list,
getCodingProjectConfig: projectApi.config,
listCodingProjectConversations: projectApi.conversations,
createCodingProjectConversation: projectApi.create,
}));
vi.mock('@/lib/coding-conversations', () => ({
getCodingConversationSnapshot: conversationApi.snapshot,
openCodingConversationEvents: conversationApi.events,
submitCodingConversationPrompt: conversationApi.submit,
recoverCodingConversation: conversationApi.recover,
}));
vi.mock('@/lib/coding-attachments', async (importOriginal) => ({
...await importOriginal<typeof import('@/lib/coding-attachments')>(),
uploadCodingAttachment: attachmentApi.upload,
}));
describe('CodingChatPanel first Conversation', () => {
beforeEach(() => {
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn((file: File) => `blob:${file.name}`),
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn(),
});
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
vi.resetModules();
});
it('makes the first-Conversation textarea editable while runtime metadata is held', async () => {
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });
projectApi.conversations.mockResolvedValue([]);
projectApi.create.mockResolvedValue(conversation);
conversationApi.snapshot.mockReturnValue(runtimeSnapshot.promise);
conversationApi.events.mockResolvedValue(source as unknown as EventSource);
conversationApi.submit.mockRejectedValue(new Error('Provider is intentionally not used'));
conversationApi.recover.mockResolvedValue(undefined);
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { createLocalConversationSnapshot } = await import(
'@/pages/Chat/coding-chat-snapshot'
);
const view = render(<CodingChatPanel />);
const textbox = await screen.findByRole('textbox');
expect(textbox).toBeEnabled();
expect(projectApi.create).toHaveBeenCalledOnce();
expect(conversationApi.snapshot).toHaveBeenCalledWith(conversation.id);
fireEvent.change(textbox, { target: { value: 'First prompt' } });
const sendButton = screen.getByRole('button', { name: '发送' });
await waitFor(() => expect(sendButton).toBeEnabled());
expect(textbox).toHaveValue('First prompt');
expect(conversationApi.submit).not.toHaveBeenCalled();
await act(async () => {
runtimeSnapshot.resolve({
...createLocalConversationSnapshot(project.id, conversation),
worker: { status: 'ready', generation: 1 },
cursor: { workerGeneration: 1, seq: 0 },
});
await runtimeSnapshot.promise;
});
view.unmount();
expect(source.close).toHaveBeenCalled();
});
it('keeps the new core Chat Renderer free of legacy OpenCode imports', async () => {
const files = [
'../../src/pages/Chat/CodingChatPanel.tsx',
'../../src/pages/Chat/CodingComposer.tsx',
'../../src/pages/Chat/CodingConversationTimeline.tsx',
'../../src/stores/coding-workspace.ts',
];
for (const relativePath of files) {
const sourceText = await readFile(
fileURLToPath(new URL(relativePath, import.meta.url)),
'utf8',
);
expect(sourceText).not.toMatch(/(?:import|export)[\s\S]*?from\s+['"][^'"]*opencode/i);
}
});
it('does not let a slow first-Conversation completion steal selection after an Agent switch', async () => {
const slowCreate = deferred<CodingConversationMetadata>();
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config: configForAgents([agent, reviewer]) });
projectApi.conversations.mockResolvedValue([reviewerConversation]);
projectApi.create.mockReturnValue(slowCreate.promise);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
conversationApi.submit.mockRejectedValue(new Error('Provider is intentionally not used'));
conversationApi.recover.mockResolvedValue(undefined);
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
conversationApi.snapshot.mockImplementation(async (conversationId: string) => (
createLocalConversationSnapshot(
project.id,
conversationId === reviewerConversation.id ? reviewerConversation : conversation,
)
));
render(<CodingChatPanel />);
await screen.findByRole('textbox');
await waitFor(() => expect(projectApi.create).toHaveBeenCalledWith({
projectId: project.id,
agentId: agent.id,
title: '新对话',
}));
fireEvent.click(screen.getByRole('button', { name: /Reviewer/ }));
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
.toBe(reviewerConversation.id));
await act(async () => {
slowCreate.resolve(conversation);
await slowCreate.promise;
});
expect(codingConversationStore.getState().selectedConversationId).toBe(reviewerConversation.id);
});
it('keeps submission state and rejection scoped to the originating Conversation', async () => {
const pendingSubmit = deferred<never>();
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config: configForAgents([agent, reviewer]) });
projectApi.conversations.mockResolvedValue([conversation, reviewerConversation]);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
conversationApi.submit.mockReturnValueOnce(pendingSubmit.promise);
conversationApi.recover.mockResolvedValue(undefined);
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
conversationApi.snapshot.mockImplementation(async (conversationId: string) => (
createLocalConversationSnapshot(
project.id,
conversationId === reviewerConversation.id ? reviewerConversation : conversation,
)
));
render(<CodingChatPanel />);
const textbox = await screen.findByRole('textbox');
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
.toBe(conversation.id));
fireEvent.change(textbox, { target: { value: 'A prompt' } });
await waitFor(() => expect(
codingConversationStore.getState().draftsByConversationId[conversation.id]?.text,
).toBe('A prompt'));
expect(codingConversationStore.getState().entriesByConversationId[conversation.id])
.toMatchObject({ loadState: 'live', error: null });
await waitFor(() => expect(screen.getByRole('button', { name: '发送' })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledOnce());
fireEvent.click(screen.getByRole('button', { name: /Reviewer/ }));
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue(''));
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'B prompt' } });
await waitFor(() => expect(screen.getByRole('button', { name: '发送' })).toBeEnabled());
await act(async () => {
pendingSubmit.reject(new Error('A submission failed'));
await pendingSubmit.promise.catch(() => undefined);
});
expect(screen.queryByText('A submission failed')).not.toBeInTheDocument();
expect(screen.getByRole('textbox')).toHaveValue('B prompt');
expect(screen.getByRole('button', { name: '发送' })).toBeEnabled();
});
it('caps one message at 16 images and uploads at most four concurrently', async () => {
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });
projectApi.conversations.mockResolvedValue([conversation]);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
conversationApi.recover.mockResolvedValue(undefined);
conversationApi.submit.mockImplementation(async (input: {
conversationId: string;
clientRequestId: string;
mode: 'prompt';
}) => ({
accepted: true,
conversationId: input.conversationId,
clientRequestId: input.clientRequestId,
runId: 'run-1',
mode: input.mode,
}));
const uploadFlights: Array<ReturnType<typeof deferred<{
attachmentId: string;
mime: string;
byteLength: number;
}>>> = [];
let activeUploads = 0;
let maxActiveUploads = 0;
attachmentApi.upload.mockImplementation((file: File) => {
const flight = deferred<{ attachmentId: string; mime: string; byteLength: number }>();
uploadFlights.push(flight);
activeUploads += 1;
maxActiveUploads = Math.max(maxActiveUploads, activeUploads);
return flight.promise.finally(() => {
activeUploads -= 1;
}).then(() => ({
attachmentId: `attachment-${file.name}`,
mime: file.type,
byteLength: file.size,
}));
});
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
render(<CodingChatPanel />);
await screen.findByRole('textbox');
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
.toBe(conversation.id));
const files = Array.from({ length: 17 }, (_, index) => new File(
[new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, index])],
`image-${index}.png`,
{ type: 'image/png' },
));
fireEvent.change(screen.getByTestId('coding-file-attachment-input'), {
target: { files },
});
expect(await screen.findByText('每条消息最多添加 16 张图片。')).toBeInTheDocument();
expect(screen.getAllByRole('img')).toHaveLength(16);
expect(codingConversationStore.getState().entriesByConversationId[conversation.id])
.toMatchObject({ loadState: 'live', error: null });
await waitFor(() => expect(screen.getByRole('button', { name: '发送' })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => expect(attachmentApi.upload).toHaveBeenCalledTimes(4));
let resolved = 0;
while (resolved < 16) {
const available = uploadFlights.slice(resolved);
for (const flight of available) flight.resolve({
attachmentId: `resolved-${resolved++}`,
mime: 'image/png',
byteLength: 9,
});
if (resolved < 16) {
await waitFor(() => expect(uploadFlights.length).toBeGreaterThan(resolved));
}
}
await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledOnce());
expect(attachmentApi.upload).toHaveBeenCalledTimes(16);
expect(maxActiveUploads).toBeLessThanOrEqual(4);
});
it('shows the 202 acceptance and preserves Enter versus Shift+Enter behavior', async () => {
const { CodingComposer } = await import('@/pages/Chat/CodingComposer');
const onSubmit = vi.fn();
render(
<CodingComposer
value="Ready"
editable
canSend
preparing
recovering={false}
runStatus="preparing"
error={null}
recoverableError={false}
acceptedCount={1}
attachments={[]}
submitting={false}
placeholder="Message"
onChange={vi.fn()}
onSubmit={onSubmit}
onRecover={vi.fn()}
onAddFiles={vi.fn()}
onRemoveAttachment={vi.fn()}
/>,
);
expect(screen.getByText('1 条消息已被本地 Agent 接收。')).toBeInTheDocument();
const textbox = screen.getByRole('textbox');
fireEvent.keyDown(textbox, { key: 'Enter', code: 'Enter', shiftKey: true });
expect(onSubmit).not.toHaveBeenCalled();
fireEvent.keyDown(textbox, { key: 'Enter', code: 'Enter' });
expect(onSubmit).toHaveBeenCalledOnce();
});
});