312 lines
11 KiB
TypeScript
312 lines
11 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import { MemoryRouter } from 'react-router-dom';
|
|
import { Tasks } from '@/pages/Tasks';
|
|
import { useCronStore } from '@/stores/cron';
|
|
import { useGatewayStore } from '@/stores/gateway';
|
|
import { useSkillsStore } from '@/stores/skills';
|
|
import { useTaskCenterStore } from '@/stores/task-center';
|
|
import { useYinianStore } from '@/stores/yinian';
|
|
import { useChatStore } from '@/stores/chat';
|
|
import i18n from '@/i18n';
|
|
import type { YinianConfigSnapshot } from '../../shared/yinian';
|
|
|
|
const hostApiFetchMock = vi.fn();
|
|
const invokeIpcMock = vi.fn();
|
|
|
|
vi.mock('@/lib/host-api', () => ({
|
|
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
|
|
}));
|
|
|
|
vi.mock('@/lib/api-client', () => ({
|
|
invokeIpc: (...args: unknown[]) => invokeIpcMock(...args),
|
|
}));
|
|
|
|
const hotelHangzhou = {
|
|
id: 'workspace_hangzhou_ops',
|
|
name: '智念企业组织空间',
|
|
city: '杭州',
|
|
role: 'manager' as const,
|
|
permissions: ['skills.sync'],
|
|
ota: [],
|
|
};
|
|
|
|
function createConfig(overrides: Partial<YinianConfigSnapshot> = {}): YinianConfigSnapshot {
|
|
return {
|
|
serverTime: 1,
|
|
user: { id: 'user_1', name: '王管理员' },
|
|
hotel: hotelHangzhou,
|
|
hotels: [hotelHangzhou],
|
|
entitlements: [
|
|
{
|
|
skillId: 'daily-report',
|
|
name: '日报生成助手',
|
|
version: '1.0.0',
|
|
enabled: true,
|
|
category: 'reporting',
|
|
triggers: ['manual', 'scheduled'],
|
|
},
|
|
{
|
|
skillId: 'weekly-report',
|
|
name: '周报分析助手',
|
|
version: '1.0.0',
|
|
enabled: true,
|
|
category: 'reporting',
|
|
triggers: ['manual', 'scheduled'],
|
|
},
|
|
],
|
|
notificationChannels: [],
|
|
featureFlags: {},
|
|
uiPolicy: { defaultPage: 'today', showAdvancedSettings: false },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function renderTasks() {
|
|
return render(
|
|
<MemoryRouter>
|
|
<Tasks />
|
|
</MemoryRouter>,
|
|
);
|
|
}
|
|
|
|
describe('Tasks page', () => {
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
await i18n.changeLanguage('zh');
|
|
invokeIpcMock.mockResolvedValue({ success: true, result: { messages: [] } });
|
|
hostApiFetchMock.mockImplementation(async (path: string, options?: { method?: string; body?: string }) => {
|
|
if (path === '/api/channels/accounts') {
|
|
return {
|
|
success: true,
|
|
channels: [
|
|
{
|
|
channelType: 'feishu',
|
|
defaultAccountId: 'feishu-main',
|
|
accounts: [{ accountId: 'feishu-main', name: '飞书主账号', isDefault: true }],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
if (path.startsWith('/api/channels/targets')) {
|
|
return {
|
|
success: true,
|
|
targets: [{ value: 'user:ou_daily', label: '运营负责人', kind: 'user' }],
|
|
};
|
|
}
|
|
if (path === '/api/cron/jobs' && options?.method === 'POST') {
|
|
const body = JSON.parse(options.body || '{}');
|
|
return {
|
|
id: 'cron-daily',
|
|
createdAt: '2026-05-13T00:00:00.000Z',
|
|
updatedAt: '2026-05-13T00:00:00.000Z',
|
|
nextRun: '2026-05-14T01:00:00.000Z',
|
|
lastRun: undefined,
|
|
agentId: body.agentId || 'main',
|
|
...body,
|
|
};
|
|
}
|
|
if (path === '/api/cron/jobs') {
|
|
return { jobs: [] };
|
|
}
|
|
return { success: true };
|
|
});
|
|
useYinianStore.setState({
|
|
status: 'authenticated',
|
|
session: {
|
|
authenticated: true,
|
|
user: { id: 'user_1', name: '王管理员' },
|
|
hotels: [hotelHangzhou],
|
|
currentHotelId: hotelHangzhou.id,
|
|
accessTokenExpiresAt: 100,
|
|
},
|
|
config: createConfig(),
|
|
error: null,
|
|
});
|
|
useGatewayStore.setState({
|
|
status: {
|
|
state: 'running',
|
|
port: 18789,
|
|
gatewayReady: true,
|
|
},
|
|
});
|
|
useSkillsStore.setState({
|
|
skills: [],
|
|
loading: false,
|
|
error: null,
|
|
fetchSkills: vi.fn().mockResolvedValue(undefined),
|
|
});
|
|
useCronStore.setState({
|
|
jobs: [],
|
|
loading: false,
|
|
error: null,
|
|
});
|
|
useTaskCenterStore.setState({
|
|
scheduledBindings: {},
|
|
runRecords: [],
|
|
pinnedTaskIds: [],
|
|
});
|
|
useChatStore.setState({
|
|
currentSessionKey: 'agent:main:main',
|
|
currentAgentId: 'main',
|
|
sessions: [{ key: 'agent:main:main', displayName: 'main' }],
|
|
messages: [],
|
|
sessionLabels: {},
|
|
sessionLastActivity: {},
|
|
sending: false,
|
|
activeRunId: null,
|
|
activeRunSessionKey: null,
|
|
streamingText: '',
|
|
streamingMessage: null,
|
|
streamingTools: [],
|
|
pendingFinal: false,
|
|
lastUserMessageAt: null,
|
|
pendingToolImages: [],
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
it('offers quick task templates that prefill the scheduled task dialog', async () => {
|
|
renderTasks();
|
|
|
|
expect(screen.getByTestId('tasks-quick-templates')).toHaveTextContent('快捷任务');
|
|
fireEvent.click(screen.getByTestId('tasks-template-daily-brief'));
|
|
|
|
expect(screen.getByDisplayValue('每日经营日报')).toBeInTheDocument();
|
|
expect(screen.getByDisplayValue(/昨日经营情况/)).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
|
|
|
await waitFor(() => {
|
|
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/cron/jobs', expect.objectContaining({ method: 'POST' }));
|
|
});
|
|
const createCall = hostApiFetchMock.mock.calls.find(([path, options]) => (
|
|
path === '/api/cron/jobs' && (options as { method?: string } | undefined)?.method === 'POST'
|
|
));
|
|
const payload = JSON.parse((createCall?.[1] as { body: string }).body);
|
|
expect(payload).toMatchObject({
|
|
name: '每日经营日报',
|
|
schedule: '0 9 * * *',
|
|
enabled: true,
|
|
});
|
|
expect(payload.message).toContain('昨日经营情况');
|
|
});
|
|
|
|
it('pins task center cards to the sidebar quick trigger list', async () => {
|
|
useCronStore.setState({
|
|
jobs: [{
|
|
id: 'cron-daily',
|
|
name: '每日经营日报',
|
|
message: '使用日报生成助手 skill 生成日报',
|
|
schedule: '0 9 * * *',
|
|
delivery: { mode: 'none' },
|
|
enabled: true,
|
|
createdAt: '2026-05-13T00:00:00.000Z',
|
|
updatedAt: '2026-05-13T00:00:00.000Z',
|
|
agentId: 'main',
|
|
}],
|
|
loading: false,
|
|
error: null,
|
|
});
|
|
|
|
renderTasks();
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: '钉到侧边栏' }));
|
|
expect(useTaskCenterStore.getState().pinnedTaskIds).toEqual(['cron-daily']);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '取消固定' }));
|
|
expect(useTaskCenterStore.getState().pinnedTaskIds).toEqual([]);
|
|
});
|
|
|
|
it('runs a task in its fixed conversation and switches there immediately', async () => {
|
|
useCronStore.setState({
|
|
jobs: [{
|
|
id: 'cron-daily',
|
|
name: '每日经营日报',
|
|
message: '使用日报生成助手 skill 生成日报',
|
|
schedule: '0 9 * * *',
|
|
delivery: { mode: 'none' },
|
|
enabled: true,
|
|
createdAt: '2026-05-13T00:00:00.000Z',
|
|
updatedAt: '2026-05-13T00:00:00.000Z',
|
|
agentId: 'main',
|
|
}],
|
|
loading: false,
|
|
error: null,
|
|
});
|
|
|
|
renderTasks();
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: '立即执行' }));
|
|
|
|
await waitFor(() => {
|
|
expect(useChatStore.getState().currentSessionKey).toBe('agent:main:cron:cron-daily');
|
|
});
|
|
expect(useChatStore.getState().sessionLabels['agent:main:cron:cron-daily']).toBe('每日经营日报');
|
|
expect(useChatStore.getState().sessions.find((session) => session.key === 'agent:main:cron:cron-daily')).toMatchObject({
|
|
label: '每日经营日报',
|
|
displayName: '每日经营日报',
|
|
});
|
|
expect(useTaskCenterStore.getState().runRecords[0]).toMatchObject({
|
|
cronJobId: 'cron-daily',
|
|
sessionKey: 'agent:main:cron:cron-daily',
|
|
});
|
|
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/cron/trigger', expect.objectContaining({ method: 'POST' }));
|
|
});
|
|
|
|
it('creates task center tasks with keyboard @ skill insertion, structured custom time, and delivery channel payload', async () => {
|
|
renderTasks();
|
|
|
|
expect(screen.queryByTestId('tasks-tab-quick')).not.toBeInTheDocument();
|
|
fireEvent.click(await screen.findByRole('button', { name: '新建任务' }));
|
|
expect(screen.queryByText('快捷能力模板(可选)')).not.toBeInTheDocument();
|
|
fireEvent.change(screen.getByPlaceholderText('例如:每日经营日报'), {
|
|
target: { value: '每日经营日报' },
|
|
});
|
|
|
|
const contentBox = screen.getByPlaceholderText('写清楚每次执行时需要完成的具体内容');
|
|
fireEvent.change(contentBox, { target: { value: '@' } });
|
|
await screen.findByRole('option', { name: /日报生成助手/ });
|
|
fireEvent.keyDown(contentBox, { key: 'ArrowDown' });
|
|
fireEvent.keyDown(contentBox, { key: 'Enter' });
|
|
expect(contentBox).toHaveValue('使用周报分析助手 skill');
|
|
|
|
const middleText = '请先检查@日报后继续';
|
|
fireEvent.change(contentBox, {
|
|
target: {
|
|
value: middleText,
|
|
selectionStart: '请先检查@日报'.length,
|
|
},
|
|
});
|
|
fireEvent.mouseDown(await screen.findByRole('option', { name: /日报生成助手/ }));
|
|
expect(contentBox).toHaveValue('请先检查 使用日报生成助手 skill 后继续');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '自定义时间' }));
|
|
fireEvent.change(screen.getByLabelText('重复方式'), { target: { value: 'weekly' } });
|
|
fireEvent.change(screen.getByLabelText('执行时刻'), { target: { value: '10:30' } });
|
|
fireEvent.change(screen.getByLabelText('星期'), { target: { value: '3' } });
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /发送到外部通道/ }));
|
|
fireEvent.change(screen.getByLabelText('通道'), { target: { value: 'feishu' } });
|
|
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/channels/targets?channelType=feishu&accountId=feishu-main'));
|
|
fireEvent.change(screen.getByLabelText('接收目标'), { target: { value: 'user:ou_daily' } });
|
|
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
|
|
|
await waitFor(() => {
|
|
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/cron/jobs', expect.objectContaining({ method: 'POST' }));
|
|
});
|
|
const createCall = hostApiFetchMock.mock.calls.find(([path, options]) => (
|
|
path === '/api/cron/jobs' && (options as { method?: string } | undefined)?.method === 'POST'
|
|
));
|
|
const payload = JSON.parse((createCall?.[1] as { body: string }).body);
|
|
expect(payload.message).toContain('请先检查 使用日报生成助手 skill 后继续');
|
|
expect(payload.schedule).toBe('30 10 * * 3');
|
|
expect(payload.delivery).toEqual({
|
|
mode: 'announce',
|
|
channel: 'feishu',
|
|
accountId: 'feishu-main',
|
|
to: 'user:ou_daily',
|
|
});
|
|
});
|
|
});
|