feat: improve project agent onboarding
This commit is contained in:
1 parent
a4fc3a5251
commit
df02680f9d
15 files changed
+826
-74
No files matched your search
@@ -0,0 +1,211 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ProjectConfiguration } from '@/pages/ProjectConfiguration';
|
||||
import { codingWorkspaceStore } from '@/stores/coding-workspace';
|
||||
import { useProjectConfigStore } from '@/stores/project-config';
|
||||
import { useProviderStore } from '@/stores/providers';
|
||||
import type { ProviderAccount } from '@/lib/providers';
|
||||
import type { CodingProjectAgent, CodingProjectConfig, CodingProjectSummary } from '@/types/coding-project';
|
||||
import { createCodingProjectConfigV2 } from '@electron/coding-projects/project-config';
|
||||
|
||||
const getCodingSkillsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/coding-product-tools', () => ({
|
||||
getCodingSkills: (...args: unknown[]) => getCodingSkillsMock(...args),
|
||||
}));
|
||||
|
||||
const NOW = '2026-09-06T00:00:00.000Z';
|
||||
const project: CodingProjectSummary = {
|
||||
id: 'project-1',
|
||||
path: 'D:/projects/project-1',
|
||||
name: 'Onboarding project',
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
lastOpenedAt: NOW,
|
||||
};
|
||||
|
||||
const configuredAccount: ProviderAccount = {
|
||||
id: 'account-1',
|
||||
vendorId: 'custom',
|
||||
label: 'Test account',
|
||||
authMode: 'api_key',
|
||||
model: 'model-1',
|
||||
fallbackModels: [],
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
};
|
||||
|
||||
const readyAgent: CodingProjectAgent = {
|
||||
id: 'agent-1',
|
||||
avatarId: 'avatar-01',
|
||||
roleName: '项目智能体',
|
||||
name: 'Builder',
|
||||
builtIn: false,
|
||||
enabled: true,
|
||||
model: { accountId: configuredAccount.id, modelId: configuredAccount.model!, thinkingLevel: 'off' },
|
||||
modelResolution: 'resolved',
|
||||
skillIds: [],
|
||||
responsibility: {
|
||||
mission: '完成项目工作',
|
||||
owns: [],
|
||||
boundaries: [],
|
||||
collaborators: [],
|
||||
principles: [],
|
||||
},
|
||||
prompt: '',
|
||||
archivedAt: null,
|
||||
pinned: false,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
};
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <output data-testid="location-path">{location.pathname}</output>;
|
||||
}
|
||||
|
||||
function renderConfiguration(config: CodingProjectConfig) {
|
||||
useProjectConfigStore.setState({
|
||||
configsByProjectId: { [project.id]: config },
|
||||
knowledgeByProjectId: { [project.id]: [] },
|
||||
});
|
||||
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/project-config']}>
|
||||
<Routes>
|
||||
<Route path="/project-config" element={<ProjectConfiguration />} />
|
||||
<Route path="/models" element={<LocationProbe />} />
|
||||
<Route path="/chat" element={<LocationProbe />} />
|
||||
<Route path="/module-select" element={<LocationProbe />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
function setProviderModels() {
|
||||
useProviderStore.setState({
|
||||
accounts: [configuredAccount],
|
||||
vendors: [],
|
||||
});
|
||||
}
|
||||
|
||||
describe('project configuration onboarding', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getCodingSkillsMock.mockResolvedValue({ skills: [] });
|
||||
|
||||
const load = vi.fn().mockResolvedValue(undefined);
|
||||
const reloadWorkspace = vi.fn().mockResolvedValue(undefined);
|
||||
const refreshProviderSnapshot = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
codingWorkspaceStore.setState({
|
||||
activeProject: project,
|
||||
projects: [project],
|
||||
conversations: [],
|
||||
load: reloadWorkspace,
|
||||
});
|
||||
useProjectConfigStore.setState({ load });
|
||||
useProviderStore.setState({
|
||||
accounts: [],
|
||||
vendors: [],
|
||||
refreshProviderSnapshot,
|
||||
});
|
||||
});
|
||||
|
||||
it('strongly guides a project without models to model settings', async () => {
|
||||
renderConfiguration(createCodingProjectConfigV2(NOW));
|
||||
|
||||
expect(await screen.findByTestId('project-agent-setup-callout')).toHaveTextContent('必需');
|
||||
expect(screen.getByTestId('project-agent-setup-primary')).toHaveTextContent('先配置模型');
|
||||
expect(screen.getByTestId('project-configuration-back-button')).toHaveAccessibleName('稍后设置');
|
||||
|
||||
fireEvent.click(screen.getByTestId('project-agent-setup-primary'));
|
||||
|
||||
expect(await screen.findByTestId('location-path')).toHaveTextContent('/models');
|
||||
});
|
||||
|
||||
it('saves the first Agent as initialized and enters chat after creation', async () => {
|
||||
setProviderModels();
|
||||
const savedConfig = vi.fn((_projectId: string, nextConfig: CodingProjectConfig) => Promise.resolve(nextConfig));
|
||||
const reloadWorkspace = vi.fn().mockResolvedValue(undefined);
|
||||
useProjectConfigStore.setState({ save: savedConfig });
|
||||
codingWorkspaceStore.setState({ load: reloadWorkspace });
|
||||
|
||||
renderConfiguration(createCodingProjectConfigV2(NOW));
|
||||
fireEvent.click(await screen.findByTestId('project-agent-setup-primary'));
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '创建项目智能体' });
|
||||
fireEvent.change(within(dialog).getByLabelText(/智能体名称/), { target: { value: 'Builder' } });
|
||||
fireEvent.change(within(dialog).getByLabelText(/职责说明/), { target: { value: '完成项目工作' } });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '创建并进入对话' }));
|
||||
|
||||
await waitFor(() => expect(savedConfig).toHaveBeenCalledTimes(1));
|
||||
expect(savedConfig).toHaveBeenCalledWith(project.id, expect.objectContaining({
|
||||
initialized: true,
|
||||
agents: [expect.objectContaining({
|
||||
name: 'Builder',
|
||||
modelResolution: 'resolved',
|
||||
})],
|
||||
}));
|
||||
expect(reloadWorkspace).toHaveBeenCalledTimes(1);
|
||||
expect(await screen.findByTestId('location-path')).toHaveTextContent('/chat');
|
||||
});
|
||||
|
||||
it('labels the back action and returns a complete project to chat', async () => {
|
||||
setProviderModels();
|
||||
|
||||
renderConfiguration({
|
||||
...createCodingProjectConfigV2(NOW),
|
||||
initialized: true,
|
||||
agents: [readyAgent],
|
||||
});
|
||||
|
||||
const backButton = await screen.findByTestId('project-configuration-back-button');
|
||||
expect(backButton).toHaveAccessibleName('返回对话');
|
||||
|
||||
fireEvent.click(backButton);
|
||||
|
||||
expect(await screen.findByTestId('location-path')).toHaveTextContent('/chat');
|
||||
});
|
||||
|
||||
it('offers a new Agent when the project contains only disabled Agents', async () => {
|
||||
setProviderModels();
|
||||
|
||||
renderConfiguration({
|
||||
...createCodingProjectConfigV2(NOW),
|
||||
initialized: false,
|
||||
agents: [{ ...readyAgent, enabled: false }],
|
||||
});
|
||||
|
||||
const setupButton = await screen.findByTestId('project-agent-setup-primary');
|
||||
expect(setupButton).toHaveTextContent('创建项目智能体');
|
||||
fireEvent.click(setupButton);
|
||||
|
||||
expect(await screen.findByRole('dialog', { name: '创建项目智能体' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('confirms before the labelled back action discards Agent edits', async () => {
|
||||
setProviderModels();
|
||||
|
||||
renderConfiguration({
|
||||
...createCodingProjectConfigV2(NOW),
|
||||
initialized: true,
|
||||
agents: [readyAgent],
|
||||
});
|
||||
|
||||
fireEvent.click(await screen.findByTestId(`project-agent-${readyAgent.id}`));
|
||||
const editor = await screen.findByRole('dialog', { name: `${readyAgent.name} · 编辑智能体` });
|
||||
fireEvent.change(within(editor).getByLabelText(/智能体名称/), { target: { value: 'Builder 2' } });
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '保存智能体' }));
|
||||
await waitFor(() => expect(editor).not.toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByTestId('project-configuration-back-button'));
|
||||
const confirm = await screen.findByRole('dialog', { name: '放弃未保存的项目配置?' });
|
||||
fireEvent.click(within(confirm).getByRole('button', { name: '返回对话' }));
|
||||
|
||||
expect(await screen.findByTestId('location-path')).toHaveTextContent('/chat');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user