Files
makelore/tests/unit/agent-conversation-sidebar.test.tsx
inman c809002180
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
feat: add bundled OpenCode skills and refine module flow
2026-08-14 18:15:55 +08:00

447 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { StrictMode, type ComponentProps } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AgentConversationSidebar } from '@/components/opencode/AgentConversationSidebar';
import type { ConfiguredModelOption } from '@/lib/model-options';
import { useSettingsStore } from '@/stores/settings';
import type { ProjectAgentConfig } from '../../shared/project-config';
import type { ProjectSessionMetadata } from '../../shared/project-conversations';
import type { OpencodeSession } from '@/types/opencode';
const hostApiFetchMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/host-api', () => ({
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
}));
const modelOption: ConfiguredModelOption = {
modelRef: 'openai/gpt-4o-mini',
label: 'gpt-4o-mini',
runtimeProviderKey: 'openai',
accountId: 'account-openai',
capability: 'text',
};
function agent(id: string, name: string, overrides: Partial<ProjectAgentConfig> = {}): ProjectAgentConfig {
return {
id,
avatarId: 'avatar-01',
roleName: '项目伙伴',
name,
builtIn: false,
enabled: true,
model: modelOption.modelRef,
skillIds: [],
responsibility: { mission: `${name}的职责`, owns: [], boundaries: [], collaborators: [], principles: [] },
prompt: '',
archivedAt: null,
pinned: false,
...overrides,
};
}
function session(id: string, title: string): OpencodeSession {
return { id, title, updatedAt: '2026-07-30T00:02:00.000Z' } as OpencodeSession;
}
function metadata(sessionId: string, agentId: string, overrides: Partial<ProjectSessionMetadata> = {}): ProjectSessionMetadata {
return {
sessionId,
agentId,
archivedAt: null,
unreadCount: 0,
createdAt: '2026-07-30T00:01:00.000Z',
updatedAt: '2026-07-30T00:02:00.000Z',
...overrides,
};
}
function renderSidebar(
overrides: Partial<ComponentProps<typeof AgentConversationSidebar>> = {},
options: { strictMode?: boolean } = {},
) {
const callbacks = {
onSelectAgent: vi.fn(),
onSelectSession: vi.fn(),
onNewSession: vi.fn(),
onCreateAgent: vi.fn().mockResolvedValue(undefined),
onArchiveAgent: vi.fn().mockResolvedValue(undefined),
onRestoreAgent: vi.fn().mockResolvedValue(undefined),
onDeleteAgent: vi.fn().mockResolvedValue(undefined),
onArchiveSession: vi.fn().mockResolvedValue(undefined),
onRestoreSession: vi.fn().mockResolvedValue(undefined),
onDeleteSession: vi.fn().mockResolvedValue(undefined),
onTogglePinAgent: vi.fn().mockResolvedValue(undefined),
onOpenProjectSettings: vi.fn(),
};
const sidebar = (
<AgentConversationSidebar
agents={[agent('agent-a', '小明'), agent('agent-b', '小红', { pinned: true })]}
sessions={[session('ses-a', '第一次聊天')]}
sessionMessagesBySessionId={{}}
conversationSessions={[metadata('ses-a', 'agent-a', { unreadCount: 2 })]}
selectedAgentId={null}
selectedSessionId={null}
sessionStatuses={{}}
pendingQuestionCountBySession={{}}
pendingPermissionCountBySession={{}}
startingAgentId={null}
modelOptions={[modelOption]}
{...callbacks}
{...overrides}
/>
);
render(options.strictMode ? <StrictMode>{sidebar}</StrictMode> : sidebar);
return callbacks;
}
describe('AgentConversationSidebar', () => {
beforeEach(() => {
useSettingsStore.setState({ sidebarCollapsed: false });
hostApiFetchMock.mockReset();
hostApiFetchMock.mockResolvedValue({ skills: [] });
});
afterEach(() => {
cleanup();
useSettingsStore.setState({ sidebarCollapsed: false });
});
it('uses a flush square sidebar surface without an outer card frame', () => {
renderSidebar();
const sidebar = screen.getByTestId('agent-conversation-sidebar');
expect(sidebar).toHaveClass('no-drag', 'pointer-events-auto', 'absolute', 'inset-y-0', 'left-0', 'w-[256px]', 'min-w-[256px]', 'max-w-[256px]', 'z-[60]', 'rounded-none', 'border-r');
expect(sidebar).not.toHaveClass('rounded-xl', 'shadow-soft');
expect(screen.getByTestId('agent-conversation-sidebar-content')).toHaveClass('p-0');
expect(screen.getByTestId('agent-conversation-project-context')).toHaveTextContent('Makelore 工作台');
});
it('keeps the conversation actions in the conversation list title rail', () => {
renderSidebar();
const sidebar = screen.getByTestId('agent-conversation-sidebar');
const header = screen.getByTestId('agent-conversation-sidebar-header');
expect(sidebar).not.toContainElement(header);
const actionPortal = screen.getByTestId('agent-conversation-sidebar-titlebar-portal');
expect(actionPortal).toContainElement(header);
expect(actionPortal).toHaveClass('border-r');
expect(actionPortal).toHaveStyle({ left: '256px' });
expect(header).toHaveClass('w-full', 'shrink-0', 'items-center');
expect(header).not.toHaveClass('py-1');
expect(header).toHaveClass('pointer-events-none');
expect(header).not.toHaveClass('drag-region');
expect(header.firstElementChild).toHaveClass('no-drag', 'pointer-events-auto');
expect(screen.getByTestId('agent-conversation-dialog-context-portal')).toHaveStyle({ left: '512px' });
expect(screen.getByTestId('agent-conversation-dialog-context')).toContainElement(screen.getByTestId('agent-conversation-project-context'));
expect(screen.queryByRole('button', { name: '真机预览' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '项目设置' })).toHaveClass('no-drag');
const createButton = screen.getByRole('button', { name: '创建伙伴' });
expect(createButton).toHaveClass('no-drag', 'text-brand', 'hover:text-white');
expect(createButton.querySelector('svg')).toHaveClass('text-brand', 'group-hover:text-white');
expect(createButton).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(createButton);
expect(createButton).toHaveClass('bg-brand', 'text-white');
expect(createButton.querySelector('svg')).toHaveClass('text-white');
expect(createButton).toHaveAttribute('aria-expanded', 'true');
});
it('keeps both title rails aligned when the main sidebar is collapsed', () => {
useSettingsStore.setState({ sidebarCollapsed: true });
renderSidebar();
const sidebar = screen.getByTestId('agent-conversation-sidebar');
const header = screen.getByTestId('agent-conversation-sidebar-header');
const actionPortal = screen.getByTestId('agent-conversation-sidebar-titlebar-portal');
const contextPortal = screen.getByTestId('agent-conversation-dialog-context-portal');
expect(sidebar).not.toContainElement(header);
expect(actionPortal).toContainElement(header);
expect(actionPortal).toHaveClass('pointer-events-none');
expect(actionPortal).toHaveStyle({ left: '0px' });
expect(contextPortal).toHaveStyle({ left: '256px' });
});
it('keeps the agent creation dialog above the conversation sidebar', () => {
renderSidebar();
fireEvent.click(screen.getByRole('button', { name: '创建伙伴' }));
expect(screen.getByRole('dialog')).toHaveClass('z-[110]');
});
it('keeps the fixed actions mounted through React StrictMode effect replay', () => {
renderSidebar({}, { strictMode: true });
expect(screen.getByRole('button', { name: '项目设置' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '创建伙伴' })).toBeInTheDocument();
});
it('opens project settings from the conversation header', () => {
const callbacks = renderSidebar();
fireEvent.click(screen.getByRole('button', { name: '项目设置' }));
expect(callbacks.onOpenProjectSettings).toHaveBeenCalledOnce();
});
it('keeps the conversation actions at the top without a redundant workspace heading', () => {
renderSidebar();
expect(screen.queryByText('项目伙伴')).not.toBeInTheDocument();
expect(screen.queryByRole('heading', { name: '我的项目空间' })).not.toBeInTheDocument();
expect(screen.getByTestId('agent-conversation-sidebar-header')).toHaveClass('justify-end', 'bg-surface-tertiary');
expect(screen.getByTestId('agent-conversation-sidebar-header')).not.toHaveClass('border-b');
});
it('uses square corners for each project contact card', () => {
renderSidebar();
const contactButton = screen.getByTestId('project-agent-chat-agent-a');
const contactCard = contactButton.parentElement?.parentElement;
expect(contactCard).toHaveClass('rounded-none');
expect(contactCard).not.toHaveClass('rounded-xl');
});
it('shows contacts, keeps pinned contacts first, and opens that contacts session list', () => {
const callbacks = renderSidebar();
const contacts = screen.getAllByTestId(/^project-agent-chat-/);
expect(contacts[0]).toHaveAttribute('data-testid', 'project-agent-chat-agent-b');
expect(screen.getByText('小明')).toBeInTheDocument();
expect(screen.getByText('有新消息')).toBeInTheDocument();
expect(within(screen.getByTestId('agent-status-row-agent-a')).getByText('2')).toBeInTheDocument();
expect(screen.queryByText('等待开始')).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('project-agent-chat-agent-a'));
expect(callbacks.onSelectAgent).toHaveBeenCalledWith('agent-a');
});
it('expands sessions directly below the selected contact and removes the back/settings actions', () => {
const callbacks = renderSidebar({ selectedAgentId: 'agent-a' });
expect(screen.getByTestId('project-agent-chat-agent-a')).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByTestId('project-agent-chat-agent-a').parentElement?.parentElement).toHaveAttribute('data-selected', 'true');
expect(screen.getByTestId('project-agent-chat-agent-b').parentElement?.parentElement).toHaveAttribute('data-selected', 'false');
expect(screen.getByLabelText('小明的会话列表')).toBeInTheDocument();
expect(screen.getByLabelText('小明的会话列表').parentElement?.parentElement).toHaveAttribute('data-state', 'open');
expect(screen.getByLabelText('小红的会话列表').parentElement?.parentElement).toHaveAttribute('data-state', 'closed');
expect(screen.getByText('第一次聊天')).toBeInTheDocument();
expect(screen.queryByText('小明的对话')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '返回伙伴列表' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '伙伴设置' })).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('project-agent-chat-agent-a'));
expect(callbacks.onSelectAgent).toHaveBeenCalledWith('agent-a');
});
it('marks the selected session so the active conversation remains visible', () => {
renderSidebar({ selectedAgentId: 'agent-a', selectedSessionId: 'ses-a' });
expect(screen.getByText('第一次聊天').closest('button')).toHaveAttribute('aria-current', 'page');
});
it('keeps the contact disabled while opening without showing a transient work status', () => {
renderSidebar({ startingAgentId: 'agent-a' });
const contact = screen.getByTestId('project-agent-chat-agent-a');
expect(screen.queryByText('处理中')).not.toBeInTheDocument();
expect(contact).toBeDisabled();
expect(contact).toHaveAttribute('aria-busy', 'true');
});
it('hides the recent conversation status after the session has been read', () => {
renderSidebar({
conversationSessions: [metadata('ses-a', 'agent-a', { unreadCount: 0 })],
});
expect(screen.queryByText('最近有对话')).not.toBeInTheDocument();
expect(screen.queryByText('等待开始')).not.toBeInTheDocument();
});
it('centers the pin and archive actions vertically on the contact card', () => {
renderSidebar();
const pinButton = screen.getByRole('button', { name: '置顶伙伴' });
const actionRail = pinButton.parentElement;
expect(actionRail).toHaveClass('top-1/2', '-translate-y-1/2', 'flex-col');
});
it('lists sessions compactly, shows five by default, and expands the rest', () => {
const longMessage = '这是最新的一条消息,内容很长,超出侧栏宽度后应该隐藏并显示省略号。';
const allSessions = Array.from({ length: 6 }, (_, index) => session(`ses-${index + 1}`, `会话${index + 1}`));
renderSidebar({
sessions: allSessions,
sessionMessagesBySessionId: {
'ses-1': [{ role: 'user', content: longMessage }],
},
conversationSessions: allSessions.map((item, index) => metadata(item.id as string, 'agent-a', {
updatedAt: `2026-07-${String(30 - index).padStart(2, '0')}T00:02:00.000Z`,
})),
selectedAgentId: 'agent-a',
});
expect(screen.getByTitle(longMessage)).toHaveTextContent(/$/u);
expect(screen.queryByText('其他会话')).not.toBeInTheDocument();
const moreSessions = screen.getByRole('button', { name: '更多会话1' });
expect(moreSessions).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(moreSessions);
expect(moreSessions).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(moreSessions);
expect(moreSessions).toHaveAttribute('aria-expanded', 'false');
});
it('removes the session bubble icon and keeps archive hidden until the card is hovered', () => {
renderSidebar({ selectedAgentId: 'agent-a' });
const preview = screen.getByText('第一次聊天');
const sessionButton = preview.closest('button');
const archiveButton = screen.getByRole('button', { name: '归档会话 第一次聊天' });
expect(sessionButton?.querySelector('svg')).toBeNull();
expect(archiveButton).toHaveClass('opacity-0', 'group-hover/session-card:opacity-100');
});
it('creates a contact from the required basics and confirms archive actions', async () => {
const callbacks = renderSidebar();
fireEvent.click(screen.getByRole('button', { name: '创建伙伴' }));
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.click(screen.getByRole('button', { name: '创建并开始对话' }));
await waitFor(() => expect(callbacks.onCreateAgent).toHaveBeenCalledWith({
name: '小李',
avatarId: 'avatar-03',
model: modelOption.modelRef,
responsibility: '负责整理资料。',
prompt: '',
skillIds: [],
}));
fireEvent.click(screen.getByLabelText('归档伙伴 小明'));
expect(screen.getByRole('dialog', { name: '归档伙伴“小明”?' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '取消' }));
expect(callbacks.onArchiveAgent).not.toHaveBeenCalled();
});
it('loads installed skills for the contact creation dialog', async () => {
hostApiFetchMock.mockResolvedValue({
skills: [{ name: 'agent-browser' }],
});
const callbacks = renderSidebar();
fireEvent.click(screen.getByRole('button', { name: '创建伙伴' }));
expect(await screen.findByLabelText('绑定技能:开发浏览器')).toBeInTheDocument();
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/skills');
fireEvent.click(screen.getByLabelText('绑定技能:开发浏览器'));
fireEvent.change(screen.getByLabelText(/伙伴名称/), { target: { value: '小李' } });
fireEvent.change(screen.getByLabelText(/职责简述/), { target: { value: '负责整理资料。' } });
fireEvent.click(screen.getByRole('button', { name: '创建并开始对话' }));
await waitFor(() => expect(callbacks.onCreateAgent).toHaveBeenCalledWith(expect.objectContaining({
skillIds: ['agent-browser'],
})));
});
it('defaults only the planning Skills and leaves presentation optional', async () => {
hostApiFetchMock.mockResolvedValue({
skills: [
{ name: 'agent-browser' },
{ name: 'frontend-slides' },
{ name: 'grilling' },
{ name: 'planning-with-files' },
],
});
const callbacks = renderSidebar();
fireEvent.click(screen.getByRole('button', { name: '创建伙伴' }));
const grilling = await screen.findByLabelText('绑定技能:方案质询');
const planning = await screen.findByLabelText('绑定技能:项目规划');
const slides = await screen.findByLabelText('绑定技能:项目演示');
const browser = await screen.findByLabelText('绑定技能:开发浏览器');
await waitFor(() => {
expect(grilling).toBeChecked();
expect(planning).toBeChecked();
expect(slides).not.toBeChecked();
expect(browser).not.toBeChecked();
});
fireEvent.click(grilling);
expect(grilling).not.toBeChecked();
fireEvent.change(screen.getByLabelText(/伙伴名称/), { target: { value: '小李' } });
fireEvent.change(screen.getByLabelText(/职责简述/), { target: { value: '负责整理资料。' } });
fireEvent.click(screen.getByRole('button', { name: '创建并开始对话' }));
await waitFor(() => expect(callbacks.onCreateAgent).toHaveBeenCalledWith(expect.objectContaining({
skillIds: ['planning-with-files'],
})));
});
it('shows advanced creation settings directly below the basics', () => {
renderSidebar();
fireEvent.click(screen.getByRole('button', { name: '创建伙伴' }));
expect(screen.getByText('高级设置(提示词与技能)')).toBeInTheDocument();
expect(screen.getByLabelText('Agent 提示词')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '绑定技能' })).toBeInTheDocument();
});
it('places archived contacts and sessions below active items and exposes restore actions', () => {
const archivedAgent = agent('agent-archived', '旧伙伴', { archivedAt: '2026-07-29T00:00:00.000Z' });
renderSidebar({
agents: [agent('agent-a', '小明'), archivedAgent],
conversationSessions: [
metadata('ses-a', 'agent-a'),
metadata('ses-old', 'agent-a', { archivedAt: '2026-07-29T00:00:00.000Z' }),
],
selectedAgentId: 'agent-a',
});
fireEvent.click(screen.getByRole('button', { name: /已归档伙伴/ }));
fireEvent.click(screen.getByRole('button', { name: '已归档会话1' }));
expect(screen.getByText('旧伙伴')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '恢复伙伴 旧伙伴' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '永久删除伙伴 旧伙伴' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '恢复会话 新对话' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '永久删除会话 新对话' })).toBeInTheDocument();
});
it('requires explicit confirmation before permanently deleting an archived session', async () => {
const callbacks = renderSidebar({
selectedAgentId: 'agent-a',
conversationSessions: [metadata('ses-old', 'agent-a', { archivedAt: '2026-07-29T00:00:00.000Z' })],
});
fireEvent.click(screen.getByText('已归档会话1'));
fireEvent.click(screen.getByRole('button', { name: '永久删除会话 新对话' }));
expect(screen.getByRole('dialog', { name: '永久删除会话“新对话”?' })).toBeInTheDocument();
expect(callbacks.onDeleteSession).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '永久删除' }));
await waitFor(() => expect(callbacks.onDeleteSession).toHaveBeenCalledWith('ses-old'));
});
it('requires explicit confirmation before permanently deleting archived records', async () => {
const archivedAgent = agent('agent-archived', '旧伙伴', { archivedAt: '2026-07-29T00:00:00.000Z' });
const callbacks = renderSidebar({
agents: [archivedAgent],
conversationSessions: [metadata('ses-old', 'agent-archived', { archivedAt: '2026-07-29T00:00:00.000Z' })],
});
fireEvent.click(screen.getByRole('button', { name: /已归档伙伴/ }));
fireEvent.click(screen.getByRole('button', { name: '永久删除伙伴 旧伙伴' }));
expect(screen.getByRole('dialog', { name: '永久删除伙伴“旧伙伴”?' })).toBeInTheDocument();
expect(callbacks.onDeleteAgent).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '永久删除' }));
await waitFor(() => expect(callbacks.onDeleteAgent).toHaveBeenCalledWith(archivedAgent));
});
it('keeps permanent deletion unavailable for archived built-in contacts', () => {
renderSidebar({
agents: [agent('built-in-archived', '内置伙伴', { builtIn: true, archivedAt: '2026-07-29T00:00:00.000Z' })],
});
fireEvent.click(screen.getByRole('button', { name: /已归档伙伴/ }));
expect(screen.queryByRole('button', { name: '永久删除伙伴 内置伙伴' })).not.toBeInTheDocument();
});
});