import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ProjectConfiguration } from '@/pages/ProjectConfiguration'; import { useOpencodeStore } from '@/stores/opencode'; import { useProjectConversationStore } from '@/stores/project-conversations'; import { useProjectConfigStore } from '@/stores/project-config'; import { useProviderStore } from '@/stores/providers'; import { createProjectConfig } from '../../shared/project-config'; import { createProjectConversationState, removeProjectSessionMetadata, upsertProjectSessionMetadata } from '../../shared/project-conversations'; const hostApiFetchMock = vi.fn(); vi.mock('@/lib/host-api', () => ({ hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args) })); describe('ProjectConfiguration', () => { const project = { id: 'prj_config', path: 'D:/repo/config', name: 'config', createdAt: '', updatedAt: '', lastOpenedAt: '' }; const account = { id: 'openai-account', vendorId: 'openai', label: 'OpenAI', authMode: 'api_key' as const, model: 'gpt-4o-mini', enabled: true, isDefault: true, createdAt: '2026-07-11T00:00:00.000Z', updatedAt: '2026-07-11T00:00:00.000Z', }; const status = { id: account.id, name: account.label, type: account.vendorId, model: account.model, enabled: true, hasKey: true, keyMasked: 'sk-***', createdAt: account.createdAt, updatedAt: account.updatedAt, }; beforeEach(() => { vi.clearAllMocks(); Object.defineProperty(window, 'matchMedia', { configurable: true, value: vi.fn().mockImplementation((query: string) => ({ matches: query === '(prefers-reduced-motion: reduce)', media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), })), }); const config = createProjectConfig(); useOpencodeStore.setState({ activeProject: project, projects: [project] }); useProjectConfigStore.setState({ configsByProjectId: { [project.id]: config }, knowledgeByProjectId: { [project.id]: [] }, errorsByProjectId: {}, loadingProjectId: null }); useProviderStore.setState({ accounts: [account], statuses: [status], defaultAccountId: account.id, loading: false, error: null, }); hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => { if (path === `/api/opencode/projects/config?projectId=${project.id}`) return { status: 'valid', config, knowledgeFiles: [] }; if (path === '/api/opencode/skills') return { skills: [] }; if (path === '/api/provider-accounts') return [account]; if (path === '/api/provider-accounts/key-info') return [{ accountId: account.id, hasKey: true, keyMasked: 'sk-***' }]; if (path === '/api/provider-vendors') return []; if (path === '/api/provider-accounts/default') return { accountId: account.id }; if (path === '/api/opencode/projects/config' && init?.method === 'PUT') return { success: true, config: { ...(JSON.parse(init.body as string).config), initialized: true }, knowledgeFiles: [] }; if (path === '/api/opencode/projects/remove' && init?.method === 'POST') return { success: true, removedProjectId: project.id, projects: [], activeProject: null }; throw new Error(`Unexpected path ${path}`); }); }); it('shows an empty contact list for a new project', async () => { render(); expect(await screen.findByText('配置你的项目空间')).toBeInTheDocument(); expect(screen.getByText(/新项目还没有联系人/)).toBeInTheDocument(); expect(screen.getByText('还没有联系人,点击右上角创建第一个。')).toBeInTheDocument(); expect(screen.queryAllByTestId(/^project-agent-/)).toHaveLength(0); }); it('creates a contact with the required basics and leaves advanced settings empty', async () => { render(); fireEvent.click(await screen.findByRole('button', { name: '新增伙伴' })); expect(screen.getByRole('dialog', { name: '创建项目联系人' })).toBeInTheDocument(); fireEvent.change(screen.getByLabelText(/联系人名称/), { target: { value: '小明' } }); fireEvent.change(screen.getByLabelText(/职责简述/), { target: { value: '负责把想法拆成步骤。' } }); fireEvent.click(screen.getByRole('button', { name: '更换头像' })); fireEvent.click(screen.getByRole('button', { name: '选择头像:像素伙伴 3' })); fireEvent.change(screen.getByLabelText('Agent 提示词'), { target: { value: '先拆解目标,再给出执行步骤。' } }); fireEvent.click(screen.getByRole('button', { name: '创建伙伴' })); await waitFor(() => expect(screen.queryByRole('dialog', { name: '创建项目联系人' })).not.toBeInTheDocument()); fireEvent.click(screen.getByRole('button', { name: '确认并完成初始化' })); await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/projects/config', expect.objectContaining({ method: 'PUT' }))); const saveCall = hostApiFetchMock.mock.calls.find(([path, init]) => path === '/api/opencode/projects/config' && (init as RequestInit | undefined)?.method === 'PUT'); const savedConfig = JSON.parse((saveCall?.[1] as RequestInit).body as string).config; expect(savedConfig.initialized).toBe(true); expect(savedConfig.agents).toEqual([expect.objectContaining({ name: '小明', avatarId: 'avatar-03', model: 'openai/gpt-4o-mini', prompt: '先拆解目标,再给出执行步骤。', skillIds: [], responsibility: expect.objectContaining({ mission: '负责把想法拆成步骤。' }), })]); }); it('requires confirmation before archiving a contact in settings', async () => { const config = createProjectConfig(); const contact = { id: 'agent-settings', avatarId: 'avatar-01', roleName: '项目伙伴', name: '小红', builtIn: false, enabled: true, model: 'openai/gpt-4o-mini', skillIds: [], responsibility: { mission: '负责设计体验。', owns: [], boundaries: [], collaborators: [], principles: [] }, prompt: '', archivedAt: null, pinned: false, }; const configWithContact = { ...config, initialized: true, agents: [contact] }; useProjectConfigStore.setState({ configsByProjectId: { [project.id]: configWithContact }, knowledgeByProjectId: { [project.id]: [] }, errorsByProjectId: {}, loadingProjectId: null }); hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => { if (path === `/api/opencode/projects/config?projectId=${project.id}`) return { status: 'valid', config: configWithContact, knowledgeFiles: [] }; if (path === '/api/opencode/skills') return { skills: [] }; if (path === '/api/provider-accounts') return [account]; if (path === '/api/provider-accounts/key-info') return [{ accountId: account.id, hasKey: true, keyMasked: 'sk-***' }]; if (path === '/api/provider-vendors') return []; if (path === '/api/provider-accounts/default') return { accountId: account.id }; if (path === '/api/opencode/projects/conversations') return { state: { schemaVersion: 1, sessions: [], updatedAt: new Date().toISOString() } }; if (path === '/api/opencode/projects/config' && init?.method === 'PUT') return { success: true, config: JSON.parse(init.body as string).config, knowledgeFiles: [] }; throw new Error(`Unexpected path ${path}`); }); render(); fireEvent.click(await screen.findByTestId('project-agent-agent-settings')); fireEvent.click(screen.getByRole('button', { name: '归档联系人' })); expect(screen.getByRole('dialog', { name: '归档联系人“小红”?' })).toBeInTheDocument(); expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/opencode/projects/config')).toBe(false); fireEvent.click(screen.getByRole('button', { name: '取消' })); expect(screen.queryByRole('dialog', { name: '归档联系人“小红”?' })).not.toBeInTheDocument(); }); it('requires explicit confirmation before removing a project', async () => { render(); fireEvent.click(await screen.findByRole('button', { name: '删除项目' })); const dialog = screen.getByRole('dialog', { name: `删除项目“${project.name}”?` }); expect(dialog).toHaveTextContent('不会删除磁盘中的项目文件'); expect(hostApiFetchMock).not.toHaveBeenCalledWith('/api/opencode/projects/remove', expect.anything()); fireEvent.click(screen.getByRole('button', { name: '确认删除项目' })); await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/projects/remove', { method: 'POST', body: JSON.stringify({ projectId: project.id }), })); expect(useOpencodeStore.getState().projects).toEqual([]); expect(useOpencodeStore.getState().activeProject).toBeNull(); }); it('permanently deletes archived custom contacts and their sessions', async () => { const contact = { id: 'agent-archived-delete', avatarId: 'avatar-01', roleName: '项目伙伴', name: '待删除伙伴', builtIn: false, enabled: true, model: 'openai/gpt-4o-mini', skillIds: [], responsibility: { mission: '负责清理测试数据。', owns: [], boundaries: [], collaborators: [], principles: [] }, prompt: '', archivedAt: '2026-07-29T00:00:00.000Z', pinned: false, }; const config = { ...createProjectConfig(), initialized: true, agents: [contact] }; let conversationState = upsertProjectSessionMetadata( createProjectConversationState(), 'ses-archived-delete', contact.id, '2026-07-29T00:00:00.000Z', ); conversationState = { ...conversationState, sessions: conversationState.sessions.map((session) => ({ ...session, archivedAt: '2026-07-29T00:00:00.000Z' })), }; useOpencodeStore.setState({ activeProject: project, projects: [project], sessions: [{ id: 'ses-archived-delete', title: '旧会话' }] }); useProjectConversationStore.setState({ statesByProjectId: { [project.id]: conversationState } }); useProjectConfigStore.setState({ configsByProjectId: { [project.id]: config }, knowledgeByProjectId: { [project.id]: [] }, errorsByProjectId: {}, loadingProjectId: null }); hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => { if (path === `/api/opencode/projects/config?projectId=${project.id}`) return { status: 'valid', config, knowledgeFiles: [] }; if (path === '/api/opencode/skills') return { skills: [] }; if (path === '/api/provider-accounts') return [account]; if (path === '/api/provider-accounts/key-info') return [{ accountId: account.id, hasKey: true, keyMasked: 'sk-***' }]; if (path === '/api/provider-vendors') return []; if (path === '/api/provider-accounts/default') return { accountId: account.id }; if (path === `/api/opencode/projects/conversations?projectId=${project.id}`) return { state: conversationState }; if (path === '/api/opencode/sessions/ses-archived-delete' && init?.method === 'DELETE') return { success: true }; if (path === '/api/opencode/projects/conversations' && init?.method === 'POST') { conversationState = removeProjectSessionMetadata(conversationState, 'ses-archived-delete'); return { success: true, state: conversationState }; } if (path === '/api/opencode/projects/config' && init?.method === 'PUT') return { success: true, config: JSON.parse(init.body as string).config, knowledgeFiles: [] }; throw new Error(`Unexpected path ${path}`); }); render(); const deleteButton = await screen.findByRole('button', { name: '永久删除联系人 待删除伙伴' }); fireEvent.click(deleteButton); expect(await screen.findByRole('dialog', { name: '永久删除联系人“待删除伙伴”?' })).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '永久删除' })); await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/sessions/ses-archived-delete', { method: 'DELETE' })); await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/projects/conversations', expect.objectContaining({ method: 'POST' }))); await waitFor(() => { const saveCall = hostApiFetchMock.mock.calls.find(([path, init]) => path === '/api/opencode/projects/config' && (init as RequestInit | undefined)?.method === 'PUT'); expect(saveCall).toBeTruthy(); expect(JSON.parse((saveCall?.[1] as RequestInit).body as string).config.agents).toEqual([]); }); }); });