Files
makelore/tests/unit/sidebar-opencode-projects.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

934 lines
45 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 { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ImageWorkspaceSidebar } from '@/components/layout/ImageWorkspaceSidebar';
import { Sidebar } from '@/components/layout/Sidebar';
import { useOpencodeStore } from '@/stores/opencode';
import { useProjectConfigStore } from '@/stores/project-config';
import { useSettingsStore } from '@/stores/settings';
import { useUpdateStore } from '@/stores/update';
import { useAuthStore } from '@/stores/auth';
import { useImageWorkspaceStore } from '@/stores/image-workspace';
import { useUserProfileStore } from '@/stores/user-profile';
import { getAgentAvatarSrc } from '@/lib/agent-avatars';
import { createProjectConfig } from '../../shared/project-config';
import type { DesignConversationSummary, DesignWorkspace } from '../../shared/image-workspace';
const hostApiFetchMock = vi.fn();
const invokeIpcMock = vi.fn();
const navigateMock = vi.fn();
const prepareUserAvatarMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/host-api', () => ({ hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), ensureHostApiToken: vi.fn().mockResolvedValue('test-token') }));
vi.mock('@/lib/api-client', () => ({ invokeIpc: (...args: unknown[]) => invokeIpcMock(...args) }));
vi.mock('@/lib/user-avatar', async (importOriginal) => ({
...await importOriginal<typeof import('@/lib/user-avatar')>(),
prepareUserAvatar: (...args: unknown[]) => prepareUserAvatarMock(...args),
}));
vi.mock('react-router-dom', async (importOriginal) => ({ ...(await importOriginal<typeof import('react-router-dom')>()), useNavigate: () => navigateMock }));
describe('Sidebar project initialization flow', () => {
beforeEach(() => {
vi.clearAllMocks();
prepareUserAvatarMock.mockReset();
window.electron.imageWorkspaceLocalDevelopment = false;
useSettingsStore.setState({ sidebarCollapsed: false });
useAuthStore.setState({ initialized: true, loading: false, error: null, accessToken: null, refreshToken: null, tokenType: null, expiresAt: null, user: null });
useUserProfileStore.setState({ profilesByUserId: {} });
useImageWorkspaceStore.getState().reset();
useUpdateStore.setState({
status: 'idle',
currentVersion: '0.9.1',
updateInfo: null,
progress: null,
error: null,
isInitialized: true,
isInitializing: false,
autoInstallCountdown: null,
init: vi.fn().mockResolvedValue(undefined),
downloadUpdate: vi.fn().mockResolvedValue(undefined),
installUpdate: vi.fn(),
});
useProjectConfigStore.setState({ configsByProjectId: {}, knowledgeByProjectId: {}, errorsByProjectId: {}, loadingProjectId: null });
useOpencodeStore.setState({ projects: [], activeProject: null, status: { state: 'stopped', port: 4096 }, sessions: [], sessionsByProjectId: {}, selectedSessionId: null, loading: false, error: null });
invokeIpcMock.mockResolvedValue({ canceled: true, filePaths: [] });
});
it('requires a personal profile name after login and saves optional details', async () => {
useAuthStore.setState({
user: { username: 'login@example.com', userId: 'user-1', tenantId: null, deptId: null, authorities: [] },
accessToken: 'token',
});
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
if (path === '/api/opencode/projects') return { projects: [], activeProject: null };
if (path === '/api/works/billing/token-usage') return {};
if (path === '/api/works/user/agent-profile') {
if (init?.method === 'PUT') {
return {
success: true,
profile: {
display_name: '小明',
age: 16,
gender: 'undisclosed',
share_age_with_agents: true,
share_gender_with_agents: false,
analysis_enabled: true,
completed: true,
version: 1,
updated_at: '2026-07-12T00:00:00Z',
},
};
}
return {
success: true,
profile: {
display_name: null,
age: null,
gender: null,
share_age_with_agents: false,
share_gender_with_agents: false,
analysis_enabled: true,
completed: false,
version: 0,
updated_at: null,
},
};
}
return {};
});
render(<MemoryRouter><Sidebar /></MemoryRouter>);
expect(await screen.findByText('首次使用前请先填写名字。个人资料与登录账号信息相互独立。')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '关闭个人资料' })).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/名字/), { target: { value: '小明' } });
fireEvent.change(screen.getByLabelText('年龄(选填)'), { target: { value: '16' } });
fireEvent.click(screen.getByRole('button', { name: '保存个人资料' }));
await waitFor(() => expect(useUserProfileStore.getState().profilesByUserId['user-1']).toMatchObject({ displayName: '小明', age: 16, version: 1 }));
});
it('renders the synced cloud avatar in the account surfaces and profile dialog', async () => {
useAuthStore.setState({
user: { username: 'login@example.com', userId: 'user-avatar', tenantId: null, deptId: null, authorities: [] },
accessToken: 'token',
});
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/opencode/projects') return { projects: [], activeProject: null };
if (path === '/api/works/billing/token-usage') return {};
if (path === '/api/works/user/agent-profile') {
return {
success: true,
profile: {
display_name: '头像用户',
age: null,
gender: null,
avatar_url: 'https://cdn.example/avatar.webp',
share_age_with_agents: false,
share_gender_with_agents: false,
analysis_enabled: true,
completed: true,
version: 1,
updated_at: '2026-08-13T00:00:00Z',
},
};
}
return {};
});
render(<MemoryRouter><Sidebar /></MemoryRouter>);
const accountAvatar = await screen.findByTestId('sidebar-account-avatar');
await waitFor(() => expect(accountAvatar.querySelector('img')).toHaveAttribute('src', 'https://cdn.example/avatar.webp'));
fireEvent.error(accountAvatar.querySelector('img')!);
expect(accountAvatar).toHaveTextContent('L');
fireEvent.click(screen.getByTestId('sidebar-member-menu-trigger'));
expect(screen.getByTestId('sidebar-account-menu-avatar').querySelector('img')).toHaveAttribute('src', 'https://cdn.example/avatar.webp');
fireEvent.click(screen.getByRole('menuitem', { name: '个人资料' }));
await waitFor(() => expect(hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/works/user/agent-profile')).toHaveLength(2));
expect(await screen.findByTestId('profile-avatar-preview')).toContainElement(
screen.getByRole('img', { name: '个人头像预览' }),
);
expect(screen.getByRole('img', { name: '个人头像预览' })).toHaveAttribute('src', 'https://cdn.example/avatar.webp');
});
it('stages a new avatar and uploads it together with the profile save', async () => {
useAuthStore.setState({
user: { username: 'login@example.com', userId: 'user-upload', tenantId: null, deptId: null, authorities: [] },
accessToken: 'token',
});
prepareUserAvatarMock.mockResolvedValue({
fileName: 'avatar.webp',
mimeType: 'image/webp',
dataBase64: 'QUJD',
previewUrl: 'data:image/webp;base64,QUJD',
width: 512,
height: 512,
bytes: 3,
});
let profilePutAttempts = 0;
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
if (path === '/api/opencode/projects') return { projects: [], activeProject: null };
if (path === '/api/works/billing/token-usage') return {};
if (path === '/api/works/user/agent-profile') {
if (init?.method === 'PUT') {
profilePutAttempts += 1;
if (profilePutAttempts === 1) {
return {
success: false,
status: 503,
code: 'profile_storage_unavailable',
error: 'Profile service unavailable',
};
}
return {
success: true,
profile: {
display_name: '头像用户',
age: null,
gender: null,
avatar_url: 'https://cdn.example/uploaded.webp',
share_age_with_agents: false,
share_gender_with_agents: false,
analysis_enabled: true,
completed: true,
version: 2,
updated_at: '2026-08-13T00:00:00Z',
},
};
}
return {
success: true,
profile: {
display_name: '头像用户',
age: null,
gender: null,
avatar_url: null,
share_age_with_agents: false,
share_gender_with_agents: false,
analysis_enabled: true,
completed: true,
version: 1,
updated_at: '2026-08-13T00:00:00Z',
},
};
}
if (path === '/api/works/user/avatar' && init?.method === 'POST') {
return { success: true, avatar_url: 'https://cdn.example/uploaded.webp' };
}
return {};
});
render(<MemoryRouter><Sidebar /></MemoryRouter>);
fireEvent.click(await screen.findByTestId('sidebar-member-menu-trigger'));
fireEvent.click(screen.getByRole('menuitem', { name: '个人资料' }));
const input = screen.getByTestId('profile-avatar-upload');
fireEvent.change(input, { target: { files: [new File(['source'], 'portrait.png', { type: 'image/png' })] } });
await waitFor(() => expect(screen.getByRole('img', { name: '个人头像预览' })).toHaveAttribute('src', 'data:image/webp;base64,QUJD'));
expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/works/user/avatar')).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '保存个人资料' }));
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/user/avatar', {
method: 'POST',
body: JSON.stringify({ fileName: 'avatar.webp', mimeType: 'image/webp', dataBase64: 'QUJD' }),
headers: { 'X-NianCode-Access-Token': 'token' },
}));
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('个人资料服务暂时不可用'));
expect(screen.getByRole('dialog', { name: '个人资料' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '保存个人资料' }));
await waitFor(() => expect(screen.queryByRole('dialog', { name: '个人资料' })).not.toBeInTheDocument());
expect(profilePutAttempts).toBe(2);
expect(hostApiFetchMock.mock.calls.filter(([path, init]) => path === '/api/works/user/avatar' && init?.method === 'POST')).toHaveLength(1);
expect(useUserProfileStore.getState().profilesByUserId['user-upload']?.avatarUrl).toBe('https://cdn.example/uploaded.webp');
});
it('stages avatar removal until the profile is saved', async () => {
useAuthStore.setState({
user: { username: 'login@example.com', userId: 'user-remove', tenantId: null, deptId: null, authorities: [] },
accessToken: 'token',
});
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
if (path === '/api/opencode/projects') return { projects: [], activeProject: null };
if (path === '/api/works/billing/token-usage') return {};
if (path === '/api/works/user/avatar' && init?.method === 'DELETE') return { success: true, avatar_url: null };
if (path === '/api/works/user/agent-profile') {
if (init?.method === 'PUT') {
return {
success: true,
profile: {
display_name: '头像用户',
age: null,
gender: null,
avatar_url: null,
share_age_with_agents: false,
share_gender_with_agents: false,
analysis_enabled: true,
completed: true,
version: 2,
updated_at: '2026-08-13T00:00:00Z',
},
};
}
return {
success: true,
profile: {
display_name: '头像用户',
age: null,
gender: null,
avatar_url: 'https://cdn.example/avatar.webp',
share_age_with_agents: false,
share_gender_with_agents: false,
analysis_enabled: true,
completed: true,
version: 1,
updated_at: '2026-08-13T00:00:00Z',
},
};
}
return {};
});
render(<MemoryRouter><Sidebar /></MemoryRouter>);
fireEvent.click(await screen.findByTestId('sidebar-member-menu-trigger'));
fireEvent.click(screen.getByRole('menuitem', { name: '个人资料' }));
fireEvent.click(await screen.findByTestId('profile-avatar-remove'));
expect(screen.getByTestId('profile-avatar-preview')).not.toContainElement(screen.queryByRole('img', { name: '个人头像预览' }));
expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/works/user/avatar')).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '保存个人资料' }));
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/user/avatar', {
method: 'DELETE',
headers: { 'X-NianCode-Access-Token': 'token' },
}));
await waitFor(() => expect(screen.queryByRole('dialog', { name: '个人资料' })).not.toBeInTheDocument());
expect(useUserProfileStore.getState().profilesByUserId['user-remove']?.avatarUrl).toBeNull();
});
it('shows create project first and removes redundant project navigation actions', async () => {
hostApiFetchMock.mockResolvedValue({ projects: [], activeProject: null });
render(<MemoryRouter><Sidebar /></MemoryRouter>);
const switcher = screen.getByTestId('sidebar-module-switcher');
expect(switcher).toHaveTextContent('编程 Code');
expect(screen.queryByRole('img', { name: 'Makelore logo' })).not.toBeInTheDocument();
expect(screen.queryByText('一念成光,万物可创。')).not.toBeInTheDocument();
const createProject = screen.getByTestId('sidebar-create-project');
expect(createProject).toHaveTextContent('新建项目');
expect(createProject).toHaveClass('bg-transparent', 'border-0', 'shadow-none');
expect(screen.getByTestId('sidebar')).toHaveClass('border-r', 'sidebar-glass-surface');
expect(screen.queryByTestId('sidebar-nav-asset-square')).not.toBeInTheDocument();
expect(screen.queryByTestId('sidebar-nav-project-config')).not.toBeInTheDocument();
expect(screen.queryByTestId('sidebar-nav-agent-chat')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '添加已有项目' })).not.toBeInTheDocument();
expect(screen.queryByText('课程路径')).not.toBeInTheDocument();
expect(screen.queryByText('伙伴广场')).not.toBeInTheDocument();
const moduleTrigger = screen.getByTestId('sidebar-module-switcher-trigger');
expect(moduleTrigger).toHaveAttribute('aria-label', '返回首页');
expect(moduleTrigger).toHaveClass('bg-transparent');
expect(moduleTrigger).toHaveClass('w-fit', 'justify-start', 'p-2');
expect(moduleTrigger).not.toHaveClass('bg-brand-soft');
expect(moduleTrigger).toHaveTextContent('编程 Code');
expect(screen.queryByTestId('sidebar-module-switcher-menu')).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('sidebar-module-switcher-trigger'));
expect(navigateMock).toHaveBeenCalledWith('/module-select');
expect(screen.queryByTestId('sidebar-nav-image-canvas')).not.toBeInTheDocument();
expect(await screen.findByText(/暂无真实项目/)).toBeInTheDocument();
});
it('keeps new project and personal profile as dismissible modal dialogs', async () => {
hostApiFetchMock.mockResolvedValue({ projects: [], activeProject: null });
render(<MemoryRouter><Sidebar /></MemoryRouter>);
fireEvent.click(screen.getByTestId('sidebar-create-project'));
const createDialog = screen.getByRole('dialog', { name: '新建项目' });
expect(createDialog).toHaveAttribute('aria-modal', 'true');
act(() => fireEvent.keyDown(document, { key: 'Escape' }));
await waitFor(() => expect(screen.queryByRole('dialog', { name: '新建项目' })).not.toBeInTheDocument());
fireEvent.click(screen.getByTestId('sidebar-member-menu-trigger'));
fireEvent.click(screen.getByRole('menuitem', { name: '个人资料' }));
const profileDialog = screen.getByRole('dialog', { name: '个人资料' });
expect(profileDialog).toHaveAttribute('aria-modal', 'true');
act(() => fireEvent.keyDown(document, { key: 'Escape' }));
await waitFor(() => expect(screen.queryByRole('dialog', { name: '个人资料' })).not.toBeInTheDocument());
});
it('keeps the expanded account menu aligned and scrollable within the sidebar', async () => {
hostApiFetchMock.mockResolvedValue({ projects: [], activeProject: null });
render(<MemoryRouter><Sidebar /></MemoryRouter>);
const account = screen.getByTestId('sidebar-member-menu-trigger');
expect(account).toHaveClass('min-h-9', 'px-2', 'py-1');
fireEvent.click(account);
const menu = screen.getByTestId('sidebar-account-menu');
expect(menu).toHaveAttribute('data-state', 'open');
expect(menu).toHaveClass('absolute', 'left-0', 'w-full');
expect(menu.querySelector('.codex-disclosure-inner')).toHaveClass('max-h-[calc(100vh-7rem)]', 'overflow-y-auto');
expect(menu).not.toHaveClass('fixed');
const usageItem = screen.getByTestId('sidebar-account-usage-menuitem');
expect(usageItem).toHaveClass('min-h-8');
expect(screen.getByTestId('sidebar-account-usage-drawer')).toHaveAttribute('data-state', 'closed');
fireEvent.click(usageItem);
const usageDrawer = screen.getByTestId('sidebar-account-usage-drawer');
expect(usageDrawer).toHaveTextContent('订阅等级');
expect(usageDrawer).toHaveTextContent('5 小时');
expect(usageDrawer).toHaveTextContent('1 周');
});
it('places the module switcher in the sidebar header above project actions', async () => {
hostApiFetchMock.mockResolvedValue({ projects: [], activeProject: null });
render(<MemoryRouter><Sidebar /></MemoryRouter>);
const switcher = screen.getByTestId('sidebar-module-switcher');
const createProject = screen.getByTestId('sidebar-create-project');
expect(Boolean(switcher.compareDocumentPosition(createProject) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true);
});
it('fully hides the collapsed sidebar and exposes a full hover preview', () => {
useSettingsStore.setState({ sidebarCollapsed: true });
const onSidebarPeekChange = vi.fn();
const { rerender } = render(
<MemoryRouter>
<Sidebar sidebarPeekOpen={false} onSidebarPeekChange={onSidebarPeekChange} />
</MemoryRouter>,
);
const sidebar = screen.getByTestId('sidebar');
expect(sidebar).toHaveClass('w-0', 'pointer-events-none', 'opacity-0', 'fixed', 'inset-y-0', 'left-0', 'z-[350]');
expect(sidebar).not.toHaveClass('sidebar-peek-surface', 'pt-10');
rerender(
<MemoryRouter>
<Sidebar sidebarPeekOpen onSidebarPeekChange={onSidebarPeekChange} />
</MemoryRouter>,
);
expect(sidebar).toHaveClass('w-64', 'fixed', 'inset-y-0', 'z-[350]', 'sidebar-peek-surface', 'pt-10', 'shadow-float');
expect(screen.getByTestId('sidebar-create-project')).toHaveTextContent('新建项目');
fireEvent.pointerEnter(sidebar);
expect(onSidebarPeekChange).toHaveBeenCalledWith(true, 'sidebar');
fireEvent.pointerLeave(sidebar);
expect(onSidebarPeekChange).toHaveBeenCalledWith(false, 'sidebar');
});
it('keeps a clicked-open sidebar on the translucent blurred material', () => {
useSettingsStore.setState({ sidebarCollapsed: false });
render(
<MemoryRouter>
<Sidebar />
</MemoryRouter>,
);
const sidebar = screen.getByTestId('sidebar');
expect(sidebar).toHaveClass('w-64', 'sidebar-glass-surface');
expect(sidebar).not.toHaveClass('fixed', 'sidebar-peek-surface', 'pt-10');
});
it('shows an available update beside the lower-left account control', async () => {
hostApiFetchMock.mockResolvedValue({ projects: [], activeProject: null });
render(<MemoryRouter><Sidebar /></MemoryRouter>);
const account = screen.getByTestId('sidebar-member-menu-trigger');
expect(screen.queryByTestId('sidebar-update-button')).not.toBeInTheDocument();
act(() => {
useUpdateStore.setState({
status: 'available',
updateInfo: { version: '0.9.2' },
});
});
const update = await screen.findByTestId('sidebar-update-button');
expect(account.parentElement).toBe(update.parentElement);
expect(Boolean(account.compareDocumentPosition(update) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true);
expect(update).toHaveAccessibleName('下载新版本 0.9.2');
});
it('defaults to a publishable mini-game project and opens configuration', async () => {
const project = { id: 'prj_game', path: 'D:/repo/星星收集器', name: '星星收集器', createdAt: '2026-07-11T00:00:00.000Z', updatedAt: '2026-07-11T00:00:00.000Z', lastOpenedAt: '2026-07-11T00:00:00.000Z' };
const config = createProjectConfig(undefined, 'mini_game');
invokeIpcMock.mockResolvedValue({ canceled: false, filePaths: ['D:/repo/星星收集器'] });
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
if (path === '/api/opencode/projects') return { projects: [], activeProject: null };
if (path === '/api/opencode/projects/create') return { success: true, project, projects: [project], activeProject: null, config };
if (path === `/api/opencode/projects/config?projectId=${project.id}`) return { status: 'valid', config, knowledgeFiles: [] };
if (path === '/api/opencode/projects/active' && init?.method === 'POST') return { success: true, project, projects: [project], activeProject: project };
throw new Error(`Unexpected path ${path}`);
});
render(<MemoryRouter><Sidebar /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '新建项目' }));
expect(screen.getByRole('radio', { name: '小游戏' })).toBeChecked();
expect(screen.getByRole('radio', { name: '小程序' })).not.toBeChecked();
expect(screen.getByRole('radio', { name: '自定义项目' })).not.toBeChecked();
expect(screen.getByRole('radio', { name: '直接使用所选文件夹(默认)' })).toBeChecked();
expect(screen.queryByLabelText('项目名称')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '选择路径' }));
await waitFor(() => expect(screen.getByLabelText('项目路径')).toHaveValue('D:/repo/星星收集器'));
expect(screen.getByText('将直接接入此文件夹,项目名称使用“星星收集器”,不会重命名目录。')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '确认创建' }));
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/projects/create', { method: 'POST', body: JSON.stringify({ projectPath: 'D:/repo/星星收集器', projectType: 'mini_game' }) }));
await waitFor(() => expect(navigateMock).toHaveBeenCalledWith('/project-config'));
});
it('keeps creating a named child folder when that directory mode is selected', async () => {
const project = { id: 'prj_child', path: 'D:/repo/星星收集器', name: '星星收集器', createdAt: '', updatedAt: '', lastOpenedAt: '' };
const config = createProjectConfig(undefined, 'mini_program');
invokeIpcMock.mockResolvedValue({ canceled: false, filePaths: ['D:/repo'] });
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
if (path === '/api/opencode/projects') return { projects: [], activeProject: null };
if (path === '/api/opencode/projects/create') return { success: true, project, projects: [project], activeProject: null, config };
if (path === `/api/opencode/projects/config?projectId=${project.id}`) return { status: 'valid', config, knowledgeFiles: [] };
if (path === '/api/opencode/projects/active' && init?.method === 'POST') return { success: true, project, projects: [project], activeProject: project };
throw new Error(`Unexpected path ${path}`);
});
render(<MemoryRouter><Sidebar /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '新建项目' }));
fireEvent.click(screen.getByRole('radio', { name: '小程序' }));
fireEvent.click(screen.getByRole('radio', { name: '在所选位置新建下级文件夹' }));
fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '星星收集器' } });
fireEvent.click(screen.getByRole('button', { name: '选择路径' }));
await waitFor(() => expect(screen.getByLabelText('项目路径')).toHaveValue('D:/repo'));
fireEvent.click(screen.getByRole('button', { name: '确认创建' }));
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/projects/create', {
method: 'POST',
body: JSON.stringify({
parentPath: 'D:/repo',
projectName: '星星收集器',
projectType: 'mini_program',
}),
}));
});
it('opens initialized projects directly in Agent collaboration', async () => {
const project = { id: 'prj_ready', path: 'D:/repo/ready', name: 'ready', createdAt: '', updatedAt: '', lastOpenedAt: '' };
const config = { ...createProjectConfig(), initialized: true };
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/opencode/projects') return { projects: [project], activeProject: null };
if (path === `/api/opencode/projects/config?projectId=${project.id}`) return { status: 'valid', config, knowledgeFiles: [] };
if (path === '/api/opencode/projects/active') return { success: true, project, projects: [project], activeProject: project };
throw new Error(`Unexpected path ${path}`);
});
render(<MemoryRouter><Sidebar /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: `进入项目 ${project.name}` }));
await waitFor(() => expect(navigateMock).toHaveBeenCalledWith('/opencode-chat'));
});
it('removes a project card when its local project configuration is missing', async () => {
const project = { id: 'prj_missing', path: 'D:/repo/missing', name: 'missing', createdAt: '', updatedAt: '', lastOpenedAt: '' };
const staleConfig = createProjectConfig();
useProjectConfigStore.setState({ configsByProjectId: { [project.id]: staleConfig }, knowledgeByProjectId: {}, errorsByProjectId: {}, loadingProjectId: null });
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
if (path === '/api/opencode/projects') return { projects: [project], activeProject: null };
if (path === `/api/opencode/projects/config?projectId=${project.id}`) return { status: 'missing', 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}`);
});
render(<MemoryRouter><Sidebar /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: `进入项目 ${project.name}` }));
const projectConfigError = await screen.findByTestId('project-config-error');
expect(projectConfigError).toBeInTheDocument();
expect(screen.getByTestId('project-config-error-overlay')).toHaveClass('fixed', 'inset-0', 'backdrop-blur-md');
expect(projectConfigError.closest('[data-testid="sidebar"]')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '移除项目卡片' }));
const removeDialog = screen.getByRole('dialog', { name: `移除项目“${project.name}”?` });
expect(removeDialog).toBeInTheDocument();
expect(removeDialog).toHaveAttribute('aria-modal', 'true');
expect(removeDialog.closest('[data-testid="sidebar"]')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '确认移除' }));
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/projects/remove', {
method: 'POST',
body: JSON.stringify({ projectId: project.id }),
}));
await waitFor(() => expect(screen.queryByTestId(`sidebar-course-project-${project.id}`)).not.toBeInTheDocument());
expect(useProjectConfigStore.getState().configsByProjectId[project.id]).toBeUndefined();
});
it('uses the whole project card as the enter action', async () => {
const project = { id: 'prj_config', path: 'D:/repo/config', name: 'config', createdAt: '', updatedAt: '', lastOpenedAt: '' };
const config = {
...createProjectConfig(),
initialized: true,
agents: ['小策', '小设', '小码', '小发'].map((name, index) => ({
id: `manual-agent-${index}`,
name,
builtIn: false,
model: null,
skills: [],
responsibility: '',
avatarId: `avatar-${16 - index}`,
})),
};
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/opencode/projects') return { projects: [project], activeProject: null };
if (path === `/api/opencode/projects/config?projectId=${project.id}`) return { status: 'valid', config, knowledgeFiles: [] };
if (path === '/api/opencode/projects/active') return { success: true, project, projects: [project], activeProject: project };
throw new Error(`Unexpected path ${path}`);
});
render(<MemoryRouter><Sidebar /></MemoryRouter>);
const card = await screen.findByTestId(`sidebar-course-project-${project.id}`);
expect(card).toHaveTextContent(project.name);
expect(card).not.toHaveTextContent('普通开发项目');
expect(card).not.toHaveTextContent(project.path);
expect(card).not.toHaveTextContent('已初始化');
expect(card).not.toHaveTextContent('最后活跃');
expect(card).not.toHaveTextContent('移除项目');
expect(screen.getByLabelText('项目 Agent').children).toHaveLength(4);
expect(screen.getByTitle('小策').querySelector('img')).toHaveAttribute('src', getAgentAvatarSrc('avatar-16'));
expect(screen.getByTitle('小设').querySelector('img')).toHaveAttribute('src', getAgentAvatarSrc('avatar-15'));
expect(screen.getByRole('button', { name: `进入项目 ${project.name}` })).toBe(card);
expect(card.querySelectorAll('button')).toHaveLength(0);
expect(screen.queryByRole('button', { name: `配置项目 ${project.name}` })).not.toBeInTheDocument();
});
it('switches the sidebar to titled Design Workspaces inside the AI painting module', async () => {
useAuthStore.setState({
accessToken: 'cloud-token',
expiresAt: Date.now() + 60_000,
});
const summary = {
workspaceId: 'cloud-project',
title: '云端概念设计',
viewRevision: 0,
conversationCount: 1,
phase: 'shaping',
updatedAt: '2026-07-31T10:00:00Z',
};
const conversation = {
conversationId: 'cloud-conversation',
workspaceId: 'cloud-project',
title: '主会话',
latestMessagePreview: '第一条历史消息',
turnRevision: 0,
phase: 'shaping',
brief: {
version: 0,
status: 'draft',
medium: null,
summary: '正在建立作品的视觉方向',
ready: false,
missingDecision: '作品形式',
},
createdAt: '2026-07-31T10:00:00Z',
updatedAt: '2026-07-31T10:00:00Z',
messages: [],
};
const { messages: _messages, ...conversationSummary } = conversation;
const secondConversation = {
...conversation,
conversationId: 'cloud-conversation-two',
title: '第二会话',
latestMessagePreview: '第二条历史消息',
messages: [],
createdAt: '2026-07-31T10:05:00Z',
updatedAt: '2026-07-31T10:05:00Z',
};
const { messages: _secondMessages, ...secondConversationSummary } = secondConversation;
let hasSecondConversation = false;
const bootstrap = {
capabilities: {
conversation: true,
generation: true,
image: true,
video: true,
},
workspaces: [summary],
};
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
if (path === '/api/works/image-workspace') {
return { success: true, data: bootstrap };
}
if (path === '/api/works/image-workspace/workspaces/cloud-project' && !init?.method) {
return {
success: true,
data: {
...summary,
conversationCount: hasSecondConversation ? 2 : 1,
conversations: hasSecondConversation
? [secondConversationSummary, conversationSummary]
: [conversationSummary],
},
};
}
if (path === '/api/works/image-workspace/workspaces/cloud-project/conversations/cloud-conversation') {
return { success: true, data: conversation };
}
if (path === '/api/works/image-workspace/workspaces/cloud-project-two/conversations/cloud-conversation-two') {
return {
success: true,
data: {
...conversation,
workspaceId: 'cloud-project-two',
conversationId: 'cloud-conversation-two',
},
};
}
if (path === '/api/works/image-workspace/workspaces/cloud-project/tasks') {
return { success: true, data: [] };
}
if (path === '/api/works/image-workspace/workspaces/cloud-project/conversations'
&& init?.method === 'POST') {
hasSecondConversation = true;
return { success: true, data: secondConversation };
}
if (path === '/api/works/image-workspace/workspaces' && init?.method === 'POST') {
return {
success: true,
data: {
...summary,
workspaceId: 'cloud-project-two',
title: '角色设定',
conversations: [{
...conversationSummary,
workspaceId: 'cloud-project-two',
conversationId: 'cloud-conversation-two',
}],
},
};
}
throw new Error(`Unexpected path ${path}`);
});
render(<MemoryRouter initialEntries={['/image-canvas']}><Sidebar /></MemoryRouter>);
const projectCard = await screen.findByTestId('sidebar-image-project-cloud-project');
expect(projectCard).toHaveTextContent('云端概念设计');
expect(projectCard).not.toHaveTextContent('1 个会话');
expect(projectCard).not.toHaveTextContent('设计会话');
expect(screen.getByRole('button', { name: '收起 云端概念设计 的会话' })).toBeVisible();
expect(screen.getByRole('button', { name: '重命名 云端概念设计' })).toBeVisible();
const projectHeader = screen.getByTestId('sidebar-image-project-header-cloud-project');
const projectActions = [
screen.getByRole('button', { name: '收起 云端概念设计 的会话' }),
screen.getByRole('button', { name: '重命名 云端概念设计' }),
within(projectHeader).getByRole('button', { name: '新建设计会话' }),
];
projectActions.forEach((action) => {
expect(action).toHaveClass('opacity-0', 'pointer-events-none', 'group-hover/project:opacity-100', 'focus-visible:opacity-100');
});
expect(projectHeader).toHaveClass('items-center');
expect(projectHeader).not.toHaveClass('justify-center');
expect(projectHeader).toHaveClass('min-h-9');
expect(within(screen.getByTestId('sidebar-image-conversations-cloud-project')).queryByRole('button', { name: '新建设计会话' }))
.not.toBeInTheDocument();
expect(screen.getByTestId('sidebar-image-project-toggle-icon-cloud-project')).toHaveClass('rotate-180');
expect(screen.getByTestId('sidebar-image-conversations-cloud-project'))
.not.toHaveClass('border-t');
expect(screen.getByTestId('sidebar-module-switcher-trigger')).toBeVisible();
const createImageProject = screen.getByTestId('sidebar-create-image-project');
expect(createImageProject).toBeEnabled();
expect(createImageProject).toHaveClass('bg-transparent', 'border-0', 'shadow-none');
expect(screen.queryByTestId('sidebar-create-project')).not.toBeInTheDocument();
expect(screen.queryByText('项目路径')).not.toBeInTheDocument();
expect(screen.queryByText('项目模板')).not.toBeInTheDocument();
expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/opencode/projects')).toBe(false);
expect(screen.queryByText(/添加 Agent/)).not.toBeInTheDocument();
expect(await screen.findByTestId('sidebar-image-conversation-cloud-conversation'))
.toHaveAttribute('aria-current', 'page');
const firstConversation = screen.getByTestId('sidebar-image-conversation-cloud-conversation');
expect(firstConversation).toHaveTextContent('第一条历史消息');
expect(firstConversation).not.toHaveTextContent('主会话');
expect(screen.getByRole('button', { name: '新建设计会话' })).toBeEnabled();
fireEvent.click(screen.getByRole('button', { name: '新建设计会话' }));
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith(
'/api/works/image-workspace/workspaces/cloud-project/conversations',
expect.objectContaining({ method: 'POST' }),
));
expect(await screen.findByTestId('sidebar-image-conversation-cloud-conversation-two'))
.toHaveAttribute('aria-current', 'page');
const secondConversationButton = screen.getByTestId('sidebar-image-conversation-cloud-conversation-two');
expect(secondConversationButton).toHaveTextContent('第二条历史消息');
expect(secondConversationButton).not.toHaveTextContent('第二会话');
fireEvent.click(screen.getByTestId('sidebar-image-conversation-cloud-conversation'));
await waitFor(() => expect(screen.getByTestId('sidebar-image-conversation-cloud-conversation'))
.toHaveAttribute('aria-current', 'page'));
fireEvent.click(screen.getByTestId('sidebar-create-image-project'));
expect(screen.getByRole('dialog', { name: '新建设计项目' })).toBeInTheDocument();
expect(screen.getByLabelText('项目名称')).toBeInTheDocument();
expect(screen.queryByLabelText('项目路径')).not.toBeInTheDocument();
expect(screen.queryByText('项目模板')).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '角色设定' } });
fireEvent.click(screen.getByRole('button', { name: '创建项目' }));
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith(
'/api/works/image-workspace/workspaces',
expect.objectContaining({ method: 'POST' }),
));
const createCall = hostApiFetchMock.mock.calls.find(
([path, init]) => path === '/api/works/image-workspace/workspaces'
&& (init as RequestInit | undefined)?.method === 'POST',
);
expect(JSON.parse(String((createCall?.[1] as RequestInit).body))).toMatchObject({
title: '角色设定',
clientWorkspaceId: expect.stringMatching(/^workspace-/),
});
});
it('keeps identical project copy when Main injects the local development adapter', async () => {
window.electron.imageWorkspaceLocalDevelopment = true;
const summary = {
workspaceId: 'local-project',
title: '概念设计',
viewRevision: 0,
conversationCount: 1,
phase: 'shaping',
updatedAt: '2026-07-31T10:00:00Z',
};
const conversation = {
conversationId: 'local-conversation',
workspaceId: 'local-project',
title: '主会话',
turnRevision: 0,
phase: 'shaping',
brief: {
version: 0,
status: 'draft',
medium: null,
summary: '正在建立作品的视觉方向',
ready: false,
missingDecision: '作品形式',
},
createdAt: '2026-07-31T10:00:00Z',
updatedAt: '2026-07-31T10:00:00Z',
messages: [],
};
const { messages: _messages, ...conversationSummary } = conversation;
let bootstrap = {
capabilities: {
conversation: true,
generation: true,
image: true,
video: true,
},
workspaces: [summary],
};
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
if (path === '/api/works/image-workspace' && !init?.method) {
return { success: true, data: bootstrap };
}
if (path === '/api/works/image-workspace/workspaces/local-project' && !init?.method) {
return { success: true, data: { ...summary, conversations: [conversationSummary] } };
}
if (path === '/api/works/image-workspace/workspaces/local-project/conversations/local-conversation') {
return { success: true, data: conversation };
}
if (path === '/api/works/image-workspace/workspaces/local-project/tasks') {
return { success: true, data: [] };
}
if (path === '/api/works/image-workspace/workspaces' && init?.method === 'POST') {
const created = {
...summary,
workspaceId: 'local-project-two',
title: '角色设计',
conversations: [{
...conversationSummary,
workspaceId: 'local-project-two',
conversationId: 'local-conversation-two',
}],
};
bootstrap = { ...bootstrap, workspaces: [created, ...bootstrap.workspaces] };
return { success: true, data: created };
}
if (path === '/api/works/image-workspace/workspaces/local-project-two/conversations/local-conversation-two') {
return {
success: true,
data: {
...conversation,
workspaceId: 'local-project-two',
conversationId: 'local-conversation-two',
},
};
}
throw new Error(`Unexpected path ${path}`);
});
const { container } = render(<MemoryRouter initialEntries={['/image-canvas']}><Sidebar /></MemoryRouter>);
expect(await screen.findByTestId('sidebar-image-project-local-project')).toHaveTextContent('概念设计');
expect(screen.getByTestId('sidebar-create-image-project')).toHaveTextContent('新建设计项目');
expect(screen.getByTestId('sidebar-image-projects')).toBeInTheDocument();
expect(container).not.toHaveTextContent('本地开发');
expect(container).not.toHaveTextContent('本地项目');
expect(screen.queryByTestId('sidebar-reset-local-image-workspace')).not.toBeInTheDocument();
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/image-workspace', {});
fireEvent.click(screen.getByTestId('sidebar-create-image-project'));
expect(screen.getByRole('dialog', { name: '新建设计项目' })).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '角色设计' } });
fireEvent.click(screen.getByRole('button', { name: '创建项目' }));
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith(
'/api/works/image-workspace/workspaces',
expect.objectContaining({ method: 'POST' }),
));
expect(await screen.findByText('角色设计')).toBeInTheDocument();
});
it('keeps older design conversations behind a more-conversations disclosure', () => {
const conversations: DesignConversationSummary[] = Array.from({ length: 7 }, (_, index) => ({
conversationId: `history-conversation-${index + 1}`,
workspaceId: 'history-project',
title: `历史会话 ${index + 1}`,
latestMessagePreview: `历史消息 ${index + 1}`,
turnRevision: index,
phase: 'shaping',
brief: {
version: index,
status: 'draft',
medium: null,
summary: '正在建立作品的视觉方向',
ready: false,
missingDecision: '作品形式',
},
createdAt: `2026-07-31T10:${String(6 - index).padStart(2, '0')}:00Z`,
updatedAt: `2026-07-31T10:${String(6 - index).padStart(2, '0')}:00Z`,
}));
const workspace: DesignWorkspace = {
workspaceId: 'history-project',
title: '历史设计项目',
viewRevision: 7,
conversationCount: conversations.length,
phase: 'shaping',
updatedAt: '2026-07-31T10:06:00Z',
conversations,
};
useImageWorkspaceStore.setState({
status: 'ready',
bootstrap: {
capabilities: { conversation: true, generation: true, image: true, video: true },
workspaces: [{
workspaceId: workspace.workspaceId,
title: workspace.title,
viewRevision: workspace.viewRevision,
conversationCount: workspace.conversationCount,
phase: workspace.phase,
updatedAt: workspace.updatedAt,
}],
},
activeWorkspaceId: workspace.workspaceId,
activeConversationId: conversations[0].conversationId,
workspace,
});
render(<MemoryRouter><ImageWorkspaceSidebar sidebarCollapsed={false} /></MemoryRouter>);
expect(screen.getByTestId('sidebar-image-workspace')).toHaveClass('font-sans');
expect(screen.getByTestId('sidebar-image-conversation-history-conversation-1'))
.toHaveTextContent('历史消息 1');
expect(screen.getByTestId('sidebar-image-more-conversations-history-project'))
.toHaveAttribute('data-state', 'closed');
fireEvent.click(screen.getByRole('button', { name: '更多会话2' }));
expect(screen.getByTestId('sidebar-image-more-conversations-history-project'))
.toHaveAttribute('data-state', 'open');
expect(screen.getByTestId('sidebar-image-conversation-history-conversation-7'))
.toHaveTextContent('历史消息 7');
});
});