feat: implement PI core chat timeline
This commit is contained in:
201
tests/unit/coding-chat-panel.test.tsx
Normal file
201
tests/unit/coding-chat-panel.test.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, 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: 'default',
|
||||
};
|
||||
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: 'default',
|
||||
};
|
||||
|
||||
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(),
|
||||
}));
|
||||
|
||||
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,
|
||||
}));
|
||||
|
||||
describe('CodingChatPanel first Conversation', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
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('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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user