/** * Uptime Monitoring Page - E2E Tests * * Tests for uptime monitor display, CRUD operations, manual checks, and sync functionality. * Covers 22 test scenarios as defined in phase5-implementation.md. * * Test Categories: * - Page Layout (3 tests): heading, monitor list/empty state, summary * - Monitor List Display (5 tests): status indicators, uptime %, last check, states, heartbeat bar * - Monitor CRUD (6 tests): create HTTP, create TCP, update, delete, URL validation, interval validation * - Manual Check (3 tests): trigger check, status refresh, loading state * - Monitor History (3 tests): history chart, incident timeline, date range filter * - Sync with Proxy Hosts (2 tests): sync button, preserve manual monitors */ import { test, expect, loginUser } from '../fixtures/auth-fixtures'; import { waitForToast, waitForLoadingComplete, waitForAPIResponse, } from '../utils/wait-helpers'; /** * TypeScript interfaces matching the API */ interface UptimeMonitor { id: string; upstream_host?: string; proxy_host_id?: number; remote_server_id?: number; name: string; type: string; url: string; interval: number; enabled: boolean; status: string; last_check?: string | null; latency: number; max_retries: number; } interface UptimeHeartbeat { id: number; monitor_id: string; status: string; latency: number; message: string; created_at: string; } /** * Mock monitor data for testing */ const mockMonitors: UptimeMonitor[] = [ { id: '1', name: 'API Server', type: 'http', url: 'https://api.example.com', interval: 60, enabled: true, status: 'up', latency: 45, max_retries: 3, last_check: '2024-01-15T12:00:00Z', }, { id: '2', name: 'Database', type: 'tcp', url: 'tcp://db:5432', interval: 30, enabled: true, status: 'down', latency: 0, max_retries: 3, last_check: '2024-01-15T11:59:00Z', }, { id: '3', name: 'Cache', type: 'tcp', url: 'tcp://redis:6379', interval: 60, enabled: false, status: 'paused', latency: 0, max_retries: 3, last_check: null, }, ]; /** * Generate mock heartbeat history */ const generateMockHistory = ( monitorId: string, count: number = 60, latestStatus: 'up' | 'down' = 'up' ): UptimeHeartbeat[] => { return Array.from({ length: count }, (_, i) => ({ id: i, monitor_id: monitorId, // Keep the newest heartbeat aligned with the monitor's expected current state. status: i === 0 ? latestStatus : i % 5 === 0 ? 'down' : 'up', latency: Math.floor(Math.random() * 100), message: 'OK', created_at: new Date(Date.now() - i * 60000).toISOString(), })); }; /** * UI Selectors for the Uptime page */ const SELECTORS = { // Page layout pageTitle: 'h1', summaryCard: '[data-testid="uptime-summary"]', emptyState: 'text=No monitors found', // Monitor cards monitorCard: '[data-testid="monitor-card"]', statusBadge: '[data-testid="status-badge"]', lastCheck: '[data-testid="last-check"]', heartbeatBar: '[data-testid="heartbeat-bar"]', // Actions syncButton: '[data-testid="sync-button"]', addMonitorButton: '[data-testid="add-monitor-button"]', settingsButton: 'button[aria-haspopup="menu"]', refreshButton: 'button[title*="Check"], button[title*="health"]', // Modal editModal: '.fixed.inset-0', nameInput: 'input#create-monitor-name, input#monitor-name', urlInput: 'input#create-monitor-url', typeSelect: 'select#create-monitor-type', intervalInput: 'input#create-monitor-interval', saveButton: 'button[type="submit"]', cancelButton: 'button:has-text("Cancel")', closeButton: 'button[aria-label*="Close"], button:has-text("×")', // Menu items configureOption: 'button:has-text("Configure")', pauseOption: 'button:has-text("Pause"), button:has-text("Unpause")', deleteOption: 'button:has-text("Delete")', }; /** * Helper: Setup mock monitors API response */ async function setupMonitorsAPI( page: import('@playwright/test').Page, monitors: UptimeMonitor[] = mockMonitors ) { await page.route('**/api/v1/uptime/monitors', async (route) => { if (route.request().method() === 'GET') { await route.fulfill({ status: 200, json: monitors }); } else { await route.continue(); } }); } /** * Helper: Setup mock history API response */ async function setupHistoryAPI( page: import('@playwright/test').Page, monitorId: string, history: UptimeHeartbeat[] ) { await page.route(`**/api/v1/uptime/monitors/${monitorId}/history*`, async (route) => { await route.fulfill({ status: 200, json: history }); }); } /** * Helper: Setup all monitors with their history */ async function setupMonitorsWithHistory( page: import('@playwright/test').Page, monitors: UptimeMonitor[] = mockMonitors ) { await setupMonitorsAPI(page, monitors); for (const monitor of monitors) { const latestStatus = monitor.status === 'down' ? 'down' : 'up'; const history = generateMockHistory(monitor.id, 60, latestStatus); await setupHistoryAPI(page, monitor.id, history); } } test.describe('Uptime Monitoring Page', () => { // ========================================================================= // Page Layout Tests (3 tests) // ========================================================================= test.describe('Page Layout', () => { test('should display uptime monitoring page with correct heading', async ({ page, authenticatedUser, }) => { await loginUser(page, authenticatedUser); await setupMonitorsWithHistory(page); await page.goto('/uptime'); await waitForLoadingComplete(page); await expect(page.locator(SELECTORS.pageTitle)).toContainText(/uptime/i); await expect(page.locator(SELECTORS.summaryCard)).toBeVisible(); }); test('should show monitor list or empty state', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); // Test empty state await setupMonitorsAPI(page, []); await page.goto('/uptime'); await waitForLoadingComplete(page); await expect(page.locator(SELECTORS.emptyState)).toBeVisible(); // Test with monitors await setupMonitorsWithHistory(page, mockMonitors); await page.reload(); await waitForLoadingComplete(page); await expect(page.locator(SELECTORS.monitorCard)).toHaveCount(mockMonitors.length); }); test('should display overall uptime summary with action buttons', async ({ page, authenticatedUser, }) => { await loginUser(page, authenticatedUser); await setupMonitorsWithHistory(page); await page.goto('/uptime'); await waitForLoadingComplete(page); // Summary card should be visible const summary = page.locator(SELECTORS.summaryCard); await expect(summary).toBeVisible(); // Action buttons should be present await expect(page.locator(SELECTORS.syncButton)).toBeVisible(); await expect(page.locator(SELECTORS.addMonitorButton)).toBeVisible(); }); }); // ========================================================================= // Monitor List Display Tests (5 tests) // ========================================================================= test.describe('Monitor List Display', () => { test('should display all monitors with status indicators', async ({ page, authenticatedUser, }) => { await loginUser(page, authenticatedUser); await setupMonitorsWithHistory(page); await page.goto('/uptime'); await waitForLoadingComplete(page); // Verify all monitors displayed await expect(page.getByText('API Server')).toBeVisible(); await expect(page.getByText('Database')).toBeVisible(); await expect(page.getByText('Cache')).toBeVisible(); // Verify status badges exist const statusBadges = page.locator(SELECTORS.statusBadge); await expect(statusBadges).toHaveCount(mockMonitors.length); }); test('should show uptime percentage or latency for each monitor', async ({ page, authenticatedUser, }) => { await loginUser(page, authenticatedUser); await setupMonitorsWithHistory(page); await page.goto('/uptime'); await waitForLoadingComplete(page); // API Server should show latency (45ms) const apiCard = page.locator(SELECTORS.monitorCard).filter({ hasText: 'API Server' }); await expect(apiCard).toContainText('45ms'); }); test('should show last check timestamp', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); await setupMonitorsWithHistory(page); await page.goto('/uptime'); await waitForLoadingComplete(page); // Last check sections should be visible const lastCheckElements = page.locator(SELECTORS.lastCheck); await expect(lastCheckElements.first()).toBeVisible(); // Cache monitor with no last check should show "never" or similar const cacheCard = page.locator(SELECTORS.monitorCard).filter({ hasText: 'Cache' }); await expect(cacheCard.locator(SELECTORS.lastCheck)).toBeVisible(); }); test('should differentiate up/down/paused states visually', async ({ page, authenticatedUser, }) => { await loginUser(page, authenticatedUser); await setupMonitorsWithHistory(page); await page.goto('/uptime'); await waitForLoadingComplete(page); // Check for different status badges const upBadge = page.locator('[data-testid="status-badge"][data-status="up"]'); const downBadge = page.locator('[data-testid="status-badge"][data-status="down"]'); const pausedBadge = page.locator('[data-testid="status-badge"][data-status="paused"]'); await expect(upBadge).toBeVisible(); await expect(downBadge).toBeVisible(); await expect(pausedBadge).toBeVisible(); // Verify badge text await expect(upBadge).toContainText(/up/i); await expect(downBadge).toContainText(/down/i); await expect(pausedBadge).toContainText(/pause/i); }); test('should show heartbeat history bar for each monitor', async ({ page, authenticatedUser, }) => { await loginUser(page, authenticatedUser); await setupMonitorsWithHistory(page); await page.goto('/uptime'); await waitForLoadingComplete(page); // Each monitor card should have a heartbeat bar const heartbeatBars = page.locator(SELECTORS.heartbeatBar); await expect(heartbeatBars).toHaveCount(mockMonitors.length); // First monitor's heartbeat bar should be visible const firstBar = heartbeatBars.first(); await expect(firstBar).toBeVisible(); }); }); // ========================================================================= // Monitor CRUD Tests (6 tests) // ========================================================================= test.describe('Monitor CRUD Operations', () => { test('should create new HTTP monitor', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); let createPayload: Partial | null = null; await page.route('**/api/v1/uptime/monitors', async (route) => { if (route.request().method() === 'POST') { createPayload = await route.request().postDataJSON(); await route.fulfill({ status: 201, json: { id: 'new-id', ...createPayload, status: 'unknown', latency: 0, enabled: true, }, }); } else { await route.fulfill({ status: 200, json: [] }); } }); // Setup history for new monitor await page.route('**/api/v1/uptime/monitors/new-id/history*', async (route) => { await route.fulfill({ status: 200, json: [] }); }); await page.goto('/uptime'); await waitForLoadingComplete(page); // Click add monitor button await page.click(SELECTORS.addMonitorButton); // Fill form await page.fill('input#create-monitor-name', 'New API Monitor'); await page.fill('input#create-monitor-url', 'https://api.newservice.com/health'); await page.selectOption('select#create-monitor-type', 'http'); await page.fill('input#create-monitor-interval', '60'); // Set up response listener BEFORE clicking submit to avoid race condition const createResponsePromise = page.waitForResponse( (response) => response.url().includes('/api/v1/uptime/monitors') && response.request().method() === 'POST' && response.status() === 201 ); // Submit await page.click('button[type="submit"]'); await createResponsePromise; expect(createPayload).not.toBeNull(); expect(createPayload?.name).toBe('New API Monitor'); expect(createPayload?.url).toBe('https://api.newservice.com/health'); expect(createPayload?.type).toBe('http'); }); test('should create new TCP monitor', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); let createPayload: Partial | null = null; await page.route('**/api/v1/uptime/monitors', async (route) => { if (route.request().method() === 'POST') { createPayload = await route.request().postDataJSON(); await route.fulfill({ status: 201, json: { id: 'tcp-id', ...createPayload, status: 'unknown', latency: 0, enabled: true, }, }); } else { await route.fulfill({ status: 200, json: [] }); } }); await page.route('**/api/v1/uptime/monitors/tcp-id/history*', async (route) => { await route.fulfill({ status: 200, json: [] }); }); await page.goto('/uptime'); await waitForLoadingComplete(page); await page.click(SELECTORS.addMonitorButton); await page.fill('input#create-monitor-name', 'Redis Cache'); await page.fill('input#create-monitor-url', 'tcp://redis.local:6379'); await page.selectOption('select#create-monitor-type', 'tcp'); await page.fill('input#create-monitor-interval', '30'); // Set up response listener BEFORE clicking submit to avoid race condition const createTcpResponsePromise = page.waitForResponse( (response) => response.url().includes('/api/v1/uptime/monitors') && response.request().method() === 'POST' && response.status() === 201 ); await page.click('button[type="submit"]'); await createTcpResponsePromise; expect(createPayload).not.toBeNull(); expect(createPayload?.type).toBe('tcp'); expect(createPayload?.url).toBe('tcp://redis.local:6379'); }); test('should update existing monitor', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); await setupMonitorsWithHistory(page); let updatePayload: Partial | null = null; await page.route('**/api/v1/uptime/monitors/1', async (route) => { if (route.request().method() === 'PUT') { updatePayload = await route.request().postDataJSON(); await route.fulfill({ status: 200, json: { ...mockMonitors[0], ...updatePayload }, }); } else { await route.continue(); } }); await page.goto('/uptime'); await waitForLoadingComplete(page); // Open settings menu on first monitor const firstCard = page.locator(SELECTORS.monitorCard).first(); await firstCard.locator(SELECTORS.settingsButton).click(); // Wait for menu to appear and click configure await page.waitForSelector(SELECTORS.configureOption, { state: 'visible' }); await page.click(SELECTORS.configureOption); // In Firefox, the modal form might take time to render // Wait for the form input to be available instead of waiting for dialog role const nameInput = page.locator('input#monitor-name'); await nameInput.waitFor({ state: 'visible', timeout: 10000 }); // Update name (use specific id selector) await page.fill('input#monitor-name', 'Updated API Server'); // Set up response listener BEFORE clicking submit to avoid race condition const responsePromise = page.waitForResponse( (response) => response.url().includes('/api/v1/uptime/monitors/1') && response.request().method() === 'PUT' && response.status() === 200 ); // Save - find the submit button within the modal await page.click('button[type="submit"]'); await responsePromise; expect(updatePayload).not.toBeNull(); expect(updatePayload?.name).toBe('Updated API Server'); }); test('should delete monitor with confirmation', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); await setupMonitorsWithHistory(page); let deleteRequested = false; await page.route('**/api/v1/uptime/monitors/1', async (route) => { if (route.request().method() === 'DELETE') { deleteRequested = true; await route.fulfill({ status: 204 }); } else { await route.continue(); } }); // Handle confirm dialog page.on('dialog', async (dialog) => { expect(dialog.type()).toBe('confirm'); await dialog.accept(); }); await page.goto('/uptime'); await waitForLoadingComplete(page); // Open settings menu on first monitor const firstCard = page.locator(SELECTORS.monitorCard).first(); await firstCard.locator(SELECTORS.settingsButton).click(); // Set up response listener BEFORE clicking delete to avoid race condition const deleteResponsePromise = page.waitForResponse( (response) => response.url().includes('/api/v1/uptime/monitors/1') && response.request().method() === 'DELETE' && response.status() === 204 ); // Click delete await page.click(SELECTORS.deleteOption); await deleteResponsePromise; expect(deleteRequested).toBe(true); }); test('should validate monitor URL format', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); await setupMonitorsAPI(page, []); await page.goto('/uptime'); await waitForLoadingComplete(page); await page.click(SELECTORS.addMonitorButton); // Fill with valid name but empty URL await page.fill('input#create-monitor-name', 'Test Monitor'); // Submit button should be disabled when URL is empty const submitButton = page.locator('button[type="submit"]'); await expect(submitButton).toBeDisabled(); // Fill URL await page.fill('input#create-monitor-url', 'https://valid.url.com'); // Now should be enabled await expect(submitButton).toBeEnabled(); }); test('should validate check interval range', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); await setupMonitorsAPI(page, []); await page.goto('/uptime'); await waitForLoadingComplete(page); await page.click(SELECTORS.addMonitorButton); // Fill required fields await page.fill('input#create-monitor-name', 'Test Monitor'); await page.fill('input#create-monitor-url', 'https://test.com'); // Interval input should have min/max attributes const intervalInput = page.locator('input#create-monitor-interval'); await expect(intervalInput).toHaveAttribute('min', '10'); await expect(intervalInput).toHaveAttribute('max', '3600'); // Set a valid interval await page.fill('input#create-monitor-interval', '60'); const submitButton = page.locator('button[type="submit"]'); await expect(submitButton).toBeEnabled(); }); }); // ========================================================================= // Manual Check Tests (3 tests) // ========================================================================= test.describe('Manual Health Check', () => { test('should trigger manual health check', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); await setupMonitorsWithHistory(page); let checkRequested = false; await page.route('**/api/v1/uptime/monitors/1/check', async (route) => { checkRequested = true; await route.fulfill({ status: 200, json: { message: 'Check completed: UP' }, }); }); await page.goto('/uptime'); await waitForLoadingComplete(page); // Click refresh button on first monitor and wait for API response concurrently const firstCard = page.locator(SELECTORS.monitorCard).first(); const refreshButton = firstCard.locator('button').filter({ has: page.locator('svg') }).first(); await Promise.all([ page.waitForResponse(r => r.url().includes('/api/v1/uptime/monitors/1/check') && r.status() === 200), refreshButton.click(), ]); expect(checkRequested).toBe(true); }); test('should update status after manual check', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); await setupMonitorsWithHistory(page); let checkRequested = false; await page.route('**/api/v1/uptime/monitors/1/check', async (route) => { checkRequested = true; await route.fulfill({ status: 200, json: { message: 'Check completed: UP' }, }); }); await page.goto('/uptime'); await waitForLoadingComplete(page); // Trigger check using Promise.all to avoid race condition const firstCard = page.locator(SELECTORS.monitorCard).first(); const refreshButton = firstCard.locator('button').filter({ has: page.locator('svg') }).first(); await Promise.all([ page.waitForResponse( resp => resp.url().includes('/api/v1/uptime/monitors/1/check') && resp.status() === 200 ), refreshButton.click(), ]); // Verify check was requested - mocked routes don't trigger toasts expect(checkRequested).toBe(true); }); test('should show check in progress indicator', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); await setupMonitorsWithHistory(page); await page.route('**/api/v1/uptime/monitors/1/check', async (route) => { // Delay response to observe loading state await new Promise((resolve) => setTimeout(resolve, 500)); await route.fulfill({ status: 200, json: { message: 'Check completed: UP' }, }); }); await page.goto('/uptime'); await waitForLoadingComplete(page); // Click refresh button const firstCard = page.locator(SELECTORS.monitorCard).first(); const refreshButton = firstCard.locator('button').filter({ has: page.locator('svg') }).first(); await refreshButton.click(); // Should show spinning animation (animate-spin class) const spinningIcon = firstCard.locator('svg.animate-spin'); await expect(spinningIcon).toBeVisible(); // Wait for completion await waitForAPIResponse(page, '/api/v1/uptime/monitors/1/check', { status: 200 }); }); }); // ========================================================================= // Monitor History Tests (3 tests) // ========================================================================= test.describe('Monitor History', () => { test('should display uptime history in heartbeat bar', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); const history = generateMockHistory('1', 60); await setupMonitorsAPI(page, [mockMonitors[0]]); await setupHistoryAPI(page, '1', history); await page.goto('/uptime'); await waitForLoadingComplete(page); // Heartbeat bar should be visible const heartbeatBar = page.locator(SELECTORS.heartbeatBar).first(); await expect(heartbeatBar).toBeVisible(); // Bar should contain segments (divs for each heartbeat) const segments = heartbeatBar.locator('div.rounded-sm'); const segmentCount = await segments.count(); expect(segmentCount).toBeGreaterThan(0); }); test('should show incident indicators in heartbeat bar', async ({ page, authenticatedUser, }) => { await loginUser(page, authenticatedUser); // Create history with some failures const history = generateMockHistory('1', 60); await setupMonitorsAPI(page, [mockMonitors[0]]); await setupHistoryAPI(page, '1', history); await page.goto('/uptime'); await waitForLoadingComplete(page); const heartbeatBar = page.locator(SELECTORS.heartbeatBar).first(); await expect(heartbeatBar).toBeVisible(); // Wait for history to be loaded - segments should appear // The history contains both 'up' and 'down' statuses (every 5th is down) const segments = heartbeatBar.locator('div.rounded-sm'); await expect(segments.first()).toBeVisible({ timeout: 5000 }); // Should have both up (green) and down (red) segments // Use class*= to match partial class names as Tailwind includes both light and dark mode classes const greenSegments = heartbeatBar.locator('[class*="bg-green"]'); const redSegments = heartbeatBar.locator('[class*="bg-red"]'); const greenCount = await greenSegments.count(); const redCount = await redSegments.count(); expect(greenCount).toBeGreaterThan(0); expect(redCount).toBeGreaterThan(0); }); test('should show tooltip with heartbeat details on hover', async ({ page, authenticatedUser, }) => { await loginUser(page, authenticatedUser); const history = generateMockHistory('1', 60); await setupMonitorsAPI(page, [mockMonitors[0]]); await setupHistoryAPI(page, '1', history); await page.goto('/uptime'); await waitForLoadingComplete(page); const heartbeatBar = page.locator(SELECTORS.heartbeatBar).first(); await expect(heartbeatBar).toBeVisible(); // Wait for segments to load and find one with a title (not empty placeholder) // History segments have bg-green or bg-red classes and a title attribute const historySegment = heartbeatBar.locator('[class*="bg-green"], [class*="bg-red"]').first(); await expect(historySegment).toBeVisible({ timeout: 5000 }); // Each history segment should have a title attribute with details const title = await historySegment.getAttribute('title'); expect(title).toBeTruthy(); expect(title).toContain('Status:'); }); }); // ========================================================================= // Sync with Proxy Hosts Tests (2 tests) // ========================================================================= test.describe('Sync with Proxy Hosts', () => { test('should sync monitors from proxy hosts', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); await setupMonitorsWithHistory(page); let syncRequested = false; await page.route('**/api/v1/uptime/sync', async (route) => { syncRequested = true; await route.fulfill({ status: 200, json: { message: '3 monitors synced from proxy hosts' }, }); }); await page.goto('/uptime'); await waitForLoadingComplete(page); // Use Promise.all to avoid race condition await Promise.all([ page.waitForResponse( resp => resp.url().includes('/api/v1/uptime/sync') && resp.status() === 200 ), page.click(SELECTORS.syncButton), ]); // Verify sync was requested - mocked routes don't trigger toasts reliably expect(syncRequested).toBe(true); }); test('should preserve manually added monitors after sync', async ({ page, authenticatedUser, }) => { await loginUser(page, authenticatedUser); // Setup monitors: one synced from proxy, one manual const monitorsWithTypes: UptimeMonitor[] = [ { ...mockMonitors[0], proxy_host_id: 1 }, // From proxy host { ...mockMonitors[1], proxy_host_id: undefined }, // Manual ]; await setupMonitorsAPI(page, monitorsWithTypes); for (const m of monitorsWithTypes) { await setupHistoryAPI(page, m.id, generateMockHistory(m.id, 30)); } // After sync, both should still exist let syncCalled = false; await page.route('**/api/v1/uptime/sync', async (route) => { syncCalled = true; await route.fulfill({ status: 200, json: { message: '1 monitors synced from proxy hosts' }, }); }); await page.goto('/uptime'); await waitForLoadingComplete(page); // Verify both monitors are visible before sync await expect(page.getByText('API Server')).toBeVisible(); await expect(page.getByText('Database')).toBeVisible(); // Set up response listener BEFORE clicking sync to avoid race condition const preserveSyncResponsePromise = page.waitForResponse( (response) => response.url().includes('/api/v1/uptime/sync') && response.status() === 200 ); // Trigger sync await page.click(SELECTORS.syncButton); await preserveSyncResponsePromise; expect(syncCalled).toBe(true); // Both should still be visible (UI doesn't remove manual monitors) await expect(page.getByText('API Server')).toBeVisible(); await expect(page.getByText('Database')).toBeVisible(); }); }); });