import { test, expect, type Page } from '@playwright/test' async function setup(page: Page) { const control = { hold: '', holdIface: '', fail: false, failList: false, invalidSample: undefined as unknown, mergeSample: false, metrics: {} as Record, release: () => {} } const gate = new Promise(resolve => { control.release = resolve }) const card = { id: '1', name: 'Экспортёр тест', subtitle: '', site: 'TEST', country: 'RU', status: 'online', rxNow: 2, txNow: 3, sessions: 1, rxSeries: [1, 2], txSeries: [2, 3], bytes: 200 } const data = { bpsNow: 1000000, bytes: 200, packets: 2, conversations: 1, uniqueSrc: 1, uniqueDst: 1, topProto: 'TCP', rxSeries: [1], txSeries: [2], applications: [], protocols: [], sources: [], destinations: [], interfaces: [], conversationsList: [], ifaces: [], live: false } await page.route('**/health', r => r.fulfill({ json: { ok: true } })) await page.route('**/api/**', async route => { const url = new URL(route.request().url()), path = url.pathname, range = url.searchParams.get('range') if (path === '/api/traffic/flow/monthly') return route.fulfill({ json: { month: url.searchParams.get('month'), bytes: 1500, countries: [{ id: 'RU', label: 'Россия', bytes: 1500, packets: 1, bps: 0, percent: 100 }], services: [], asns: [] } }) if (path === '/api/traffic/flow/analytics') { if (control.hold === range || (control.holdIface && url.searchParams.get('iface') === control.holdIface)) await gate if (control.fail) return route.fulfill({ status: 500, json: { error: 'Срез временно недоступен' } }) return route.fulfill({ json: { ...data, ...control.metrics, topCategory: url.searchParams.get('serverId') === '2' ? 'Второй сервер' : 'Категория ' + range } }) } if (path === '/api/traffic/flow/live') return route.fulfill({ contentType: 'text/event-stream', body: 'event: sample\ndata: ' + JSON.stringify({ ...data, live: true }) + '\n\n' + (control.invalidSample === undefined ? '' : 'event: sample\ndata: ' + JSON.stringify(control.mergeSample ? { ...data, ...(control.invalidSample as object) } : control.invalidSample) + '\n\n') }) if (path === '/api/traffic/flow/exporters' && control.failList) return route.fulfill({ status: 403, json: { error: 'Нет доступа к экспортёрам' } }) const json = path === '/api/auth/config' ? { required: false } : path === '/api/traffic/flow/exporters' ? { exporters: [card, { ...card, id: '2', name: 'Экспортёр второй' }] } : path === '/api/traffic/flow/clients' ? { clients: [{ ...card, id: 'client1', name: 'Клиент тест' }] } : path === '/api/traffic/flows' ? { exportersOnline: 1, bytesPerMin: 1, uniqueSrc: 1, uniqueDst: 1, topProto: 'TCP', talkers: [], listenerBound: true } : path === '/api/traffic/servers' ? { servers: [] } : path.endsWith('/interfaces') || path.endsWith('/bound-interfaces') ? { interfaces: [] } : path === '/api/traffic/users' ? { users: [] } : path === '/api/servers' ? [] : {} return route.fulfill({ json }) }) await page.goto('/traffic') await page.getByRole('button', { name: 'Потоки', exact: true }).click() await expect(page.getByLabel('Скорость потоков')).toBeVisible() return control } test('Flow: поздний ответ периода не заменяет текущую аналитику', async ({ page }) => { const control = await setup(page) control.hold = '24h' await page.getByRole('button', { name: '24ч', exact: true }).click() await expect(page.getByText('Загрузка аналитики…', { exact: true })).toBeVisible() await expect(page.getByLabel('Скорость потоков')).toHaveCount(0) await page.getByRole('button', { name: '15м', exact: true }).click() await expect(page.getByText('Категория 15m', { exact: true }).filter({ visible: true })).toBeVisible() const response = page.waitForResponse(r => r.url().includes('/flow/analytics?') && r.url().includes('range=24h')) control.release() await response await expect(page.getByText('Категория 15m', { exact: true }).filter({ visible: true })).toBeVisible() await expect(page.getByText('Категория 24h', { exact: true })).toHaveCount(0) }) test('Flow: ошибка аналитики и повтор сохраняют период', async ({ page }) => { const control = await setup(page) control.fail = true await page.getByRole('button', { name: '15м', exact: true }).click() await expect(page.getByRole('alert').filter({ hasText: 'Аналитика недоступна' })).toContainText('Срез временно недоступен') await expect(page.getByLabel('Скорость потоков')).toHaveCount(0) control.fail = false await page.getByRole('button', { name: 'Повторить аналитику', exact: true }).click() await expect(page.getByText('Категория 15m', { exact: true }).filter({ visible: true })).toBeVisible() }) test('Flow: ошибка списка не отображается как отсутствие экспортёров', async ({ page }) => { const control = await setup(page) control.failList = true await page.getByRole('button', { name: 'Обновить', exact: true }).click() await expect(page.getByRole('alert').filter({ hasText: 'Потоки недоступны' })).toContainText('Нет доступа') await expect(page.getByLabel('Сводка трафика')).toHaveCount(0) await expect(page.getByText('Нет экспортёров IPFIX.', { exact: false })).toHaveCount(0) control.failList = false await page.getByRole('button', { name: 'Повторить загрузку потоков', exact: true }).click() await expect(page.getByLabel('Скорость потоков')).toBeVisible() }) test('Flow: завершённый SSE не помечает последние данные как live', async ({ page }) => { await setup(page) await page.getByRole('button', { name: '5м', exact: true }).click() await expect(page.getByText('Последние данные · live недоступен', { exact: true }).filter({ visible: true })).toHaveCount(2) await expect(page.getByText('live', { exact: true })).toHaveCount(0) }) test('Flow: месяц показывает только доступные агрегаты и не применяется к клиенту', async ({ page }) => { await setup(page) const monthlyRequest = page.waitForRequest(r => r.url().includes('/flow/monthly?')) await page.getByRole('button', { name: 'Текущий месяц', exact: true }).click() const url = new URL((await monthlyRequest).url()) expect(url.searchParams.get('serverId')).toBe('1') expect(url.searchParams.get('month')).toMatch(/^\d{4}-\d{2}$/) expect([...url.searchParams.keys()].sort()).toEqual(['month', 'serverId']) const report = page.getByRole('region', { name: 'Месячный отчёт' }) await expect(report).toContainText('1.5 КБ') await expect(report.getByRole('columnheader').filter({ hasText: 'Скорость' })).toHaveCount(0) await expect(page.getByLabel('Скорость потоков')).toHaveCount(0) await expect(page.getByLabel('Сводка трафика')).toHaveCount(0) await expect(page.getByRole('switch', { name: 'Без дублей' })).toBeDisabled() await expect(page.getByRole('switch', { name: 'Без overlay' })).toBeDisabled() await expect(page.getByRole('switch', { name: 'Без mesh' })).toBeDisabled() const clientRequest = page.waitForRequest(r => r.url().includes('/flow/analytics?') && r.url().includes('userId=client1')) await page.getByRole('group', { name: 'Источник потоков' }).getByRole('button', { name: 'Клиенты', exact: true }).click() expect(new URL((await clientRequest).url()).searchParams.get('range')).toBe('1h') await expect(page.getByLabel('Скорость потоков')).toBeVisible() await expect(report).toHaveCount(0) await expect(page.getByRole('button', { name: 'Текущий месяц', exact: true })).toHaveCount(0) await expect(page.getByRole('switch', { name: 'Без дублей' })).toBeEnabled() }) for (const width of [320, 390, 1440]) { test(`Flow: адаптивность ${width}px`, async ({ page }) => { await page.setViewportSize({ width, height: 900 }) await setup(page) for (const monthly of [false, true]) { if (monthly) { await page.getByRole('button', { name: 'Текущий месяц', exact: true }).click() await expect(page.getByRole('region', { name: 'Месячный отчёт' })).toBeVisible() } const button = page.getByRole('button', { name: 'Текущий месяц', exact: true }) const box = await button.boundingBox() expect(box).not.toBeNull() expect(box!.x).toBeGreaterThanOrEqual(0) expect(box!.x + box!.width).toBeLessThanOrEqual(width + 1) const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 1) expect(overflow).toBe(false) } await page.screenshot({ path: `test-results/flow-monthly-${width}.png`, fullPage: true }) }) } for (const [name, invalid, merge] of [ ['неполный объект', { uniqueSrc: 1, destinations: [] }, false], ['отрицательная скорость', { bpsNow: -1 }, true], ['null', null, false], ['неверная строка таблицы', { destinations: [null] }, true], ['отрицательная серия', { rxSeries: [-1] }, true], ] as const) { test('Flow: повреждённый sample — ' + name, async ({ page }) => { const control = await setup(page) await page.getByRole("button", { name: "15м", exact: true }).click() await expect(page.getByText("Категория 15m", { exact: true }).filter({ visible: true })).toBeVisible() control.invalidSample = invalid control.mergeSample = merge await page.getByRole('button', { name: '5м', exact: true }).click() await expect(page.getByText('Некорректные данные live-потока', { exact: true })).toBeVisible() await expect(page.getByText('Последние данные · live недоступен', { exact: true }).filter({ visible: true })).toHaveCount(2) await expect(page.getByLabel('Скорость потоков')).toContainText('1.00 Мбит/с') await expect(page.getByText('live', { exact: true })).toHaveCount(0) }) } for (const [name, metrics, rate, bytes] of [ ['отсутствующие', {}, 'Нет данных', 'Нет данных'], ['нулевые', { bpsOverlay: 0, bpsWire: 0, bytesMesh: 0, bytesOverlay: 0 }, '0 Кбит/с', '0 Б'], ['положительные', { bpsOverlay: 2000000, bpsWire: 2000000, bytesMesh: 1500, bytesOverlay: 1500 }, '2.00 Мбит/с', '1.5 КБ'], ['некорректные REST', { bpsOverlay: -1, bpsWire: null, bytesMesh: -1 }, 'Нет данных', 'Нет данных'], ] as const) { test('Flow: optional-метрики — ' + name, async ({ page }) => { const control = await setup(page) control.metrics = metrics await page.getByRole('button', { name: '15м', exact: true }).click() await page.getByRole('switch', { name: 'Без mesh', exact: true }).click() const grid = page.getByLabel('Скорость потоков') const card = (label: string) => grid.locator('[data-slot="frame-panel"]').filter({ has: page.getByText(label, { exact: true }) }) await expect(card('JH↔EN overlay')).toContainText(rate) await expect(card('Wire GRE')).toContainText(rate) await expect(card('Mesh')).toContainText(bytes) if (name === 'нулевые') await expect(card('JH↔EN overlay')).toContainText('0 Б') }) } test('Flow: интерфейс прежнего сервера не передаётся новому; поздний ответ игнорируется', async ({ page }) => { const control = await setup(page) control.metrics = { ifaces: [{ name: 'ether1', index: '1', bps: 1 }] } await page.getByRole('button', { name: '15м', exact: true }).click() await expect(page.getByRole('button', { name: 'ether1', exact: true })).toBeVisible() control.holdIface = 'ether1' const oldRequest = page.waitForRequest(r => r.url().includes('/flow/analytics?') && r.url().includes('iface=ether1')) await page.getByRole('button', { name: 'ether1', exact: true }).click() await oldRequest const requests: URL[] = [] page.on('request', r => { if (r.url().includes('/flow/analytics?')) requests.push(new URL(r.url())) }) await page.getByRole('button').filter({ hasText: 'Экспортёр второй' }).click() await expect(page.getByText('Второй сервер', { exact: true }).filter({ visible: true })).toBeVisible() const newRequests = requests.filter(url => url.searchParams.get('serverId') === '2') expect(newRequests.length).toBeGreaterThan(0) expect(newRequests.every(url => !url.searchParams.has('iface'))).toBe(true) const late = page.waitForResponse(r => r.url().includes('/flow/analytics?') && r.url().includes('iface=ether1')) control.release() await late await expect(page.getByText('Второй сервер', { exact: true }).filter({ visible: true })).toBeVisible() await expect(page.getByText('Категория 15m', { exact: true })).toHaveCount(0) }) test('Flow: смена сервера отменяет старую SSE-подписку', async ({ page }) => { await setup(page) await page.getByRole('button', { name: '15м', exact: true }).click() await expect(page.getByText('Категория 15m', { exact: true }).filter({ visible: true })).toBeVisible() let release = () => {} const gate = new Promise(resolve => { release = resolve }) let completed = () => {} const delivered = new Promise(resolve => { completed = resolve }) await page.route('**/api/traffic/flow/live?**', async route => { const old = new URL(route.request().url()).searchParams.get('serverId') === '1' if (old) await gate const sample = { bpsNow: old ? 99000000 : 7000000, bytes: 200, packets: 2, conversations: 1, uniqueSrc: 1, uniqueDst: 1, topProto: 'TCP', topCategory: old ? 'SSE прежнего сервера' : 'SSE текущего сервера', rxSeries: [1], txSeries: [2], applications: [], protocols: [], sources: [], destinations: [], interfaces: [], conversationsList: [], ifaces: [], live: true } try { await route.fulfill({ contentType: 'text/event-stream', body: 'event: sample\ndata: ' + JSON.stringify(sample) + '\n\n' }) } finally { if (old) completed() } }) const oldRequest = page.waitForRequest(r => r.url().includes('/flow/live?') && r.url().includes('serverId=1')) await page.getByRole('button', { name: '5м', exact: true }).click() await oldRequest const aborted = page.waitForEvent('requestfailed', r => r.url().includes('/flow/live?') && r.url().includes('serverId=1')) await page.getByRole('button').filter({ hasText: 'Экспортёр второй' }).click() await aborted await expect(page.getByText('SSE текущего сервера', { exact: true }).filter({ visible: true })).toBeVisible() release() await delivered await expect(page.getByText('SSE прежнего сервера', { exact: true })).toHaveCount(0) await expect(page.getByText('SSE текущего сервера', { exact: true }).filter({ visible: true })).toBeVisible() await expect(page.getByLabel('Скорость потоков')).toContainText('7.00 Мбит/с') })