kin: KIN-UI-008 Логировать ошибки в polling-цикле ChatView
This commit is contained in:
parent
bd9fbfbbcb
commit
300b44a3a4
1 changed files with 310 additions and 0 deletions
310
web/frontend/src/__tests__/chat-view-polling-errors.test.ts
Normal file
310
web/frontend/src/__tests__/chat-view-polling-errors.test.ts
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
/**
|
||||||
|
* KIN-UI-008: ChatView — логирование ошибок в polling-цикле
|
||||||
|
*
|
||||||
|
* Проверяет:
|
||||||
|
* 1. console.warn вызывается при каждой ошибке polling
|
||||||
|
* 2. После 3 последовательных ошибок — error.value устанавливается
|
||||||
|
* 3. После 3 ошибок — polling останавливается
|
||||||
|
* 4. Счётчик сбрасывается при успешном ответе (error.value тоже сбрасывается)
|
||||||
|
* 5. Менее 3 ошибок — error.value не устанавливается
|
||||||
|
* 6. load() сбрасывает consecutiveErrors до начала загрузки
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
|
import ChatView from '../views/ChatView.vue'
|
||||||
|
|
||||||
|
vi.mock('../api', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../api')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
api: {
|
||||||
|
chatHistory: vi.fn(),
|
||||||
|
project: vi.fn(),
|
||||||
|
sendChatMessage: vi.fn(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import { api } from '../api'
|
||||||
|
|
||||||
|
const Stub = { template: '<div />' }
|
||||||
|
|
||||||
|
function makeRouter() {
|
||||||
|
return createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', component: Stub },
|
||||||
|
{ path: '/project/:id', component: Stub },
|
||||||
|
{ path: '/chat/:projectId', component: ChatView, props: true },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_MESSAGES_IDLE = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
project_id: 'KIN',
|
||||||
|
role: 'user',
|
||||||
|
content: 'Привет',
|
||||||
|
message_type: 'text',
|
||||||
|
task_stub: null,
|
||||||
|
created_at: '2024-01-01T00:00:00',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const MOCK_MESSAGES_WITH_RUNNING_TASK = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
project_id: 'KIN',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Работаю...',
|
||||||
|
message_type: 'task_created',
|
||||||
|
task_stub: { id: 'KIN-001', status: 'in_progress' },
|
||||||
|
created_at: '2024-01-01T00:00:00',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const MOCK_PROJECT = {
|
||||||
|
id: 'KIN',
|
||||||
|
name: 'Kin',
|
||||||
|
path: '/projects/kin',
|
||||||
|
status: 'active',
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.mocked(api.project).mockResolvedValue(MOCK_PROJECT as any)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function mountChatView(projectId = 'KIN') {
|
||||||
|
const router = makeRouter()
|
||||||
|
await router.push(`/chat/${projectId}`)
|
||||||
|
const wrapper = mount(ChatView, {
|
||||||
|
props: { projectId },
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('KIN-UI-008: ChatView — polling error handling', () => {
|
||||||
|
describe('console.warn при ошибках polling', () => {
|
||||||
|
it('console.warn вызывается при первой ошибке polling', async () => {
|
||||||
|
// Первый вызов (load) — успех, второй (polling) — ошибка
|
||||||
|
vi.mocked(api.chatHistory)
|
||||||
|
.mockResolvedValueOnce(MOCK_MESSAGES_WITH_RUNNING_TASK as any)
|
||||||
|
.mockRejectedValue(new Error('Network error'))
|
||||||
|
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
const wrapper = await mountChatView()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// Триггерим первый тик polling
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// Фильтруем только polling-предупреждения (игнорируем Vue Router warn)
|
||||||
|
const pollingCalls = warnSpy.mock.calls.filter(
|
||||||
|
args => typeof args[0] === 'string' && args[0].includes('[polling]'),
|
||||||
|
)
|
||||||
|
expect(pollingCalls).toHaveLength(1)
|
||||||
|
expect(pollingCalls[0][0]).toContain('[polling] ошибка #1:')
|
||||||
|
expect(pollingCalls[0][1]).toBeInstanceOf(Error)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('console.warn содержит нарастающий номер ошибки при нескольких сбоях', async () => {
|
||||||
|
vi.mocked(api.chatHistory)
|
||||||
|
.mockResolvedValueOnce(MOCK_MESSAGES_WITH_RUNNING_TASK as any)
|
||||||
|
.mockRejectedValue(new Error('Server down'))
|
||||||
|
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
const wrapper = await mountChatView()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
await flushPromises()
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// Фильтруем только polling-предупреждения
|
||||||
|
const pollingCalls = warnSpy.mock.calls.filter(
|
||||||
|
args => typeof args[0] === 'string' && args[0].includes('[polling]'),
|
||||||
|
)
|
||||||
|
expect(pollingCalls).toHaveLength(2)
|
||||||
|
expect(pollingCalls[0][0]).toContain('#1:')
|
||||||
|
expect(pollingCalls[1][0]).toContain('#2:')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('error.value после 3 последовательных ошибок', () => {
|
||||||
|
it('error.value устанавливается после 3 ошибок подряд', async () => {
|
||||||
|
vi.mocked(api.chatHistory)
|
||||||
|
.mockResolvedValueOnce(MOCK_MESSAGES_WITH_RUNNING_TASK as any)
|
||||||
|
.mockRejectedValue(new Error('Server down'))
|
||||||
|
|
||||||
|
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
const wrapper = await mountChatView()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// Ошибки 1, 2, 3
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
await flushPromises()
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
await flushPromises()
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Сервер недоступен')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('error.value НЕ устанавливается после 1 ошибки', async () => {
|
||||||
|
vi.mocked(api.chatHistory)
|
||||||
|
.mockResolvedValueOnce(MOCK_MESSAGES_WITH_RUNNING_TASK as any)
|
||||||
|
.mockRejectedValueOnce(new Error('Transient error'))
|
||||||
|
.mockResolvedValue(MOCK_MESSAGES_IDLE as any)
|
||||||
|
|
||||||
|
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
const wrapper = await mountChatView()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// Только 1 ошибка, потом успех
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain('Сервер недоступен')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('error.value НЕ устанавливается после 2 ошибок подряд', async () => {
|
||||||
|
vi.mocked(api.chatHistory)
|
||||||
|
.mockResolvedValueOnce(MOCK_MESSAGES_WITH_RUNNING_TASK as any)
|
||||||
|
.mockRejectedValueOnce(new Error('err 1'))
|
||||||
|
.mockRejectedValueOnce(new Error('err 2'))
|
||||||
|
.mockResolvedValue(MOCK_MESSAGES_IDLE as any)
|
||||||
|
|
||||||
|
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
const wrapper = await mountChatView()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
await flushPromises()
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain('Сервер недоступен')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('сброс счётчика при успешном ответе', () => {
|
||||||
|
it('после успешного ответа (reload через смену projectId) error.value очищается', async () => {
|
||||||
|
// Первый проект: 3 ошибки → error.value устанавливается
|
||||||
|
vi.mocked(api.chatHistory)
|
||||||
|
.mockResolvedValueOnce(MOCK_MESSAGES_WITH_RUNNING_TASK as any)
|
||||||
|
.mockRejectedValueOnce(new Error('err 1'))
|
||||||
|
.mockRejectedValueOnce(new Error('err 2'))
|
||||||
|
.mockRejectedValueOnce(new Error('err 3'))
|
||||||
|
|
||||||
|
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
const wrapper = await mountChatView('KIN')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
await flushPromises()
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
await flushPromises()
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Сервер недоступен')
|
||||||
|
|
||||||
|
// Переключаемся на другой проект — вызывает load(), который сбрасывает error.value
|
||||||
|
vi.mocked(api.chatHistory).mockResolvedValue(MOCK_MESSAGES_IDLE as any)
|
||||||
|
await wrapper.setProps({ projectId: 'KIN2' })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain('Сервер недоступен')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('после 2 ошибок и 1 успеха — следующие 3 ошибки снова вызывают error.value', async () => {
|
||||||
|
vi.mocked(api.chatHistory)
|
||||||
|
// load()
|
||||||
|
.mockResolvedValueOnce(MOCK_MESSAGES_WITH_RUNNING_TASK as any)
|
||||||
|
// 2 ошибки
|
||||||
|
.mockRejectedValueOnce(new Error('err'))
|
||||||
|
.mockRejectedValueOnce(new Error('err'))
|
||||||
|
// успех — сброс счётчика
|
||||||
|
.mockResolvedValueOnce(MOCK_MESSAGES_WITH_RUNNING_TASK as any)
|
||||||
|
// ещё 3 ошибки — снова должна появиться ошибка
|
||||||
|
.mockRejectedValueOnce(new Error('err'))
|
||||||
|
.mockRejectedValueOnce(new Error('err'))
|
||||||
|
.mockRejectedValue(new Error('err'))
|
||||||
|
|
||||||
|
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
const wrapper = await mountChatView()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// 2 ошибки (счётчик = 2, ошибки нет)
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
await flushPromises()
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.text()).not.toContain('Сервер недоступен')
|
||||||
|
|
||||||
|
// Успех (счётчик = 0)
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// Ещё 3 ошибки — счётчик начинается с 0, достигает 3
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
await flushPromises()
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
await flushPromises()
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Сервер недоступен')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('остановка polling после 3 ошибок', () => {
|
||||||
|
it('polling останавливается после 3 ошибок — дополнительные тики не вызывают api', async () => {
|
||||||
|
vi.mocked(api.chatHistory)
|
||||||
|
.mockResolvedValueOnce(MOCK_MESSAGES_WITH_RUNNING_TASK as any)
|
||||||
|
.mockRejectedValue(new Error('Server down'))
|
||||||
|
|
||||||
|
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
const wrapper = await mountChatView()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// 3 тика — достигаем лимита
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
await flushPromises()
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
await flushPromises()
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const callCountAfterStop = vi.mocked(api.chatHistory).mock.calls.length
|
||||||
|
|
||||||
|
// Ещё 3 тика — polling должен быть остановлен, новых вызовов нет
|
||||||
|
await vi.advanceTimersByTimeAsync(9000)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(vi.mocked(api.chatHistory).mock.calls.length).toBe(callCountAfterStop)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue