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() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); return { promise, resolve, reject }; } const runtimeSnapshot = deferred(); 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(); 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( , ); 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(); }); });