chore: clean .gitignore cache
This commit is contained in:
@@ -1,833 +0,0 @@
|
||||
/**
|
||||
* Real-Time Logs Viewer - E2E Tests
|
||||
*
|
||||
* Tests for WebSocket-based real-time log streaming, mode switching, filtering, and controls.
|
||||
* Covers 20 test scenarios as defined in phase5-implementation.md.
|
||||
*
|
||||
* Test Categories:
|
||||
* - Page Layout (3 tests): heading, connection status, mode toggle
|
||||
* - WebSocket Connection (4 tests): initial connection, reconnection, indicator, disconnect
|
||||
* - Log Display (4 tests): receive logs, formatting, log count, auto-scroll
|
||||
* - Filtering (4 tests): level filter, search filter, clear filters, filter persistence
|
||||
* - Mode Toggle (3 tests): app vs security logs, endpoint switch, mode persistence
|
||||
* - Performance (2 tests): high volume logs, buffer limits
|
||||
*/
|
||||
|
||||
import { test, expect, loginUser } from '../fixtures/auth-fixtures';
|
||||
import { waitForToast, waitForLoadingComplete } from '../utils/wait-helpers';
|
||||
|
||||
/**
|
||||
* TypeScript interfaces matching the API
|
||||
*/
|
||||
interface LiveLogEntry {
|
||||
level: string;
|
||||
timestamp: string;
|
||||
message: string;
|
||||
source?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface SecurityLogEntry {
|
||||
timestamp: string;
|
||||
level: string;
|
||||
logger: string;
|
||||
client_ip: string;
|
||||
method: string;
|
||||
uri: string;
|
||||
status: number;
|
||||
duration: number;
|
||||
size: number;
|
||||
user_agent: string;
|
||||
host: string;
|
||||
source: 'waf' | 'crowdsec' | 'ratelimit' | 'acl' | 'normal';
|
||||
blocked: boolean;
|
||||
block_reason?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock log entries for testing
|
||||
*/
|
||||
const mockLogEntry: LiveLogEntry = {
|
||||
timestamp: '2024-01-15T12:00:00Z',
|
||||
level: 'INFO',
|
||||
message: 'Server request processed',
|
||||
source: 'api',
|
||||
};
|
||||
|
||||
const mockSecurityEntry: SecurityLogEntry = {
|
||||
timestamp: '2024-01-15T12:00:01Z',
|
||||
level: 'WARN',
|
||||
logger: 'http',
|
||||
client_ip: '192.168.1.100',
|
||||
method: 'GET',
|
||||
uri: '/api/users',
|
||||
status: 200,
|
||||
duration: 0.045,
|
||||
size: 1234,
|
||||
user_agent: 'Mozilla/5.0',
|
||||
host: 'api.example.com',
|
||||
source: 'normal',
|
||||
blocked: false,
|
||||
};
|
||||
|
||||
const mockBlockedEntry: SecurityLogEntry = {
|
||||
timestamp: '2024-01-15T12:00:02Z',
|
||||
level: 'WARN',
|
||||
logger: 'security',
|
||||
client_ip: '10.0.0.50',
|
||||
method: 'POST',
|
||||
uri: '/admin/login',
|
||||
status: 403,
|
||||
duration: 0.002,
|
||||
size: 0,
|
||||
user_agent: 'curl/7.68.0',
|
||||
host: 'admin.example.com',
|
||||
source: 'waf',
|
||||
blocked: true,
|
||||
block_reason: 'SQL injection attempt',
|
||||
};
|
||||
|
||||
/**
|
||||
* UI Selectors for the LiveLogViewer component
|
||||
*/
|
||||
const SELECTORS = {
|
||||
// Connection status
|
||||
connectionStatus: '[data-testid="connection-status"]',
|
||||
connectionError: '[data-testid="connection-error"]',
|
||||
|
||||
// Mode toggle
|
||||
modeToggle: '[data-testid="mode-toggle"]',
|
||||
appModeButton: '[data-testid="mode-toggle"] button:first-child',
|
||||
securityModeButton: '[data-testid="mode-toggle"] button:last-child',
|
||||
|
||||
// Controls
|
||||
pauseButton: 'button[title="Pause"]',
|
||||
resumeButton: 'button[title="Resume"]',
|
||||
clearButton: 'button[title="Clear logs"]',
|
||||
|
||||
// Filters
|
||||
textFilter: 'input[placeholder*="Filter"]',
|
||||
levelSelect: '[data-testid="level-filter"], select[aria-label*="level" i], select:has(option[value="info"])',
|
||||
sourceSelect: '[data-testid="source-filter"], select[aria-label*="source" i], select:has(option[value="waf"])',
|
||||
blockedOnlyCheckbox: 'input[type="checkbox"]',
|
||||
|
||||
// Log display
|
||||
logContainer: '.font-mono.text-xs',
|
||||
logEntry: '[data-testid="log-entry"]',
|
||||
logCount: '[data-testid="log-count"]',
|
||||
emptyState: 'text=No logs yet',
|
||||
noMatchState: 'text=No logs match',
|
||||
pausedIndicator: 'text=Paused',
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper: Navigate to logs page and switch to live logs tab
|
||||
* Returns true if LiveLogViewer is visible (Cerberus enabled), false otherwise
|
||||
*/
|
||||
async function navigateToLiveLogs(page: import('@playwright/test').Page): Promise<boolean> {
|
||||
await page.goto('/security');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
// The LiveLogViewer is only visible when Cerberus is enabled
|
||||
// Check if the connection-status element exists (indicates LiveLogViewer is rendered)
|
||||
const liveLogViewer = page.locator('[data-testid="connection-status"]');
|
||||
const isVisible = await liveLogViewer.isVisible({ timeout: 3000 }).catch(() => false);
|
||||
|
||||
if (!isVisible) {
|
||||
// Cerberus is not enabled, LiveLogViewer is not available
|
||||
return false;
|
||||
}
|
||||
|
||||
// Click the live logs tab if it exists
|
||||
const liveTab = page.locator('[data-testid="live-logs-tab"], button:has-text("Live")');
|
||||
if (await liveTab.isVisible()) {
|
||||
await liveTab.click();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Wait for WebSocket connection to establish
|
||||
*/
|
||||
async function waitForWebSocketConnection(page: import('@playwright/test').Page) {
|
||||
await expect(page.locator(SELECTORS.connectionStatus)).toContainText('Connected', {
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Create a mock WebSocket message handler
|
||||
*/
|
||||
function createMockWebSocketHandler(
|
||||
page: import('@playwright/test').Page,
|
||||
messages: Array<LiveLogEntry | SecurityLogEntry>
|
||||
) {
|
||||
let messageIndex = 0;
|
||||
|
||||
page.on('websocket', (ws) => {
|
||||
ws.on('framereceived', () => {
|
||||
// Log frame received for debugging
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
sendNextMessage: async () => {
|
||||
if (messageIndex < messages.length) {
|
||||
// Simulate a log entry being received via evaluate
|
||||
await page.evaluate((entry) => {
|
||||
// Dispatch a custom event that the component can listen to
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('mock-log-entry', { detail: entry })
|
||||
);
|
||||
}, messages[messageIndex]);
|
||||
messageIndex++;
|
||||
}
|
||||
},
|
||||
reset: () => {
|
||||
messageIndex = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Generate multiple mock log entries
|
||||
*/
|
||||
function generateMockLogs(count: number, options?: { blocked?: boolean }): SecurityLogEntry[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
timestamp: new Date(Date.now() - i * 1000).toISOString(),
|
||||
level: ['INFO', 'WARN', 'ERROR', 'DEBUG'][i % 4],
|
||||
logger: 'http',
|
||||
client_ip: `192.168.1.${i % 255}`,
|
||||
method: ['GET', 'POST', 'PUT', 'DELETE'][i % 4],
|
||||
uri: `/api/resource/${i}`,
|
||||
status: options?.blocked ? 403 : [200, 201, 404, 500][i % 4],
|
||||
duration: Math.random() * 0.5,
|
||||
size: Math.floor(Math.random() * 5000),
|
||||
user_agent: 'Mozilla/5.0',
|
||||
host: 'api.example.com',
|
||||
source: (['normal', 'waf', 'crowdsec', 'ratelimit', 'acl'] as const)[i % 5],
|
||||
blocked: options?.blocked ?? i % 10 === 0,
|
||||
block_reason: options?.blocked || i % 10 === 0 ? 'Rate limit exceeded' : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
test.describe('Real-Time Logs Viewer', () => {
|
||||
// Note: These tests require Cerberus (security module) to be enabled.
|
||||
// The LiveLogViewer component is only rendered when Cerberus is active.
|
||||
// Tests will be skipped if the component is not visible on the /security page.
|
||||
|
||||
// Track if LiveLogViewer is available (Cerberus enabled)
|
||||
let cerberusEnabled = false;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
// Check once at the start if Cerberus is enabled
|
||||
// Use stored auth state from playwright/.auth/user.json
|
||||
const context = await browser.newContext({
|
||||
storageState: 'playwright/.auth/user.json',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
// Navigate to security page
|
||||
await page.goto('/security');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Check if LiveLogViewer is visible (only shown when Cerberus is enabled)
|
||||
const connectionStatus = page.locator('[data-testid="connection-status"]');
|
||||
cerberusEnabled = await connectionStatus.isVisible({ timeout: 3000 }).catch(() => false);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Page Layout Tests (3 tests)
|
||||
// =========================================================================
|
||||
test.describe('Page Layout', () => {
|
||||
test('should display live logs viewer with correct heading', async ({
|
||||
page,
|
||||
authenticatedUser,
|
||||
}) => {
|
||||
test.skip(!cerberusEnabled, 'LiveLogViewer not available - Cerberus security module is disabled');
|
||||
await loginUser(page, authenticatedUser);
|
||||
await navigateToLiveLogs(page);
|
||||
|
||||
// Verify the viewer is displayed
|
||||
await expect(page.locator('h3, h2, h1').filter({ hasText: /log/i })).toBeVisible();
|
||||
|
||||
// Connection status should be visible
|
||||
await expect(page.locator(SELECTORS.connectionStatus)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show connection status indicator', async ({ page, authenticatedUser }) => {
|
||||
test.skip(!cerberusEnabled, 'LiveLogViewer not available - Cerberus security module is disabled');
|
||||
await loginUser(page, authenticatedUser);
|
||||
await navigateToLiveLogs(page);
|
||||
|
||||
// Connection status badge should exist
|
||||
const statusBadge = page.locator(SELECTORS.connectionStatus);
|
||||
await expect(statusBadge).toBeVisible();
|
||||
|
||||
// Should show either Connected or Disconnected
|
||||
await expect(statusBadge).toContainText(/Connected|Disconnected/);
|
||||
});
|
||||
|
||||
test('should show mode toggle between App and Security logs', async ({
|
||||
page,
|
||||
authenticatedUser,
|
||||
}) => {
|
||||
test.skip(!cerberusEnabled, 'LiveLogViewer not available - Cerberus security module is disabled');
|
||||
await loginUser(page, authenticatedUser);
|
||||
await navigateToLiveLogs(page);
|
||||
|
||||
// Mode toggle should be visible
|
||||
const modeToggle = page.locator(SELECTORS.modeToggle);
|
||||
await expect(modeToggle).toBeVisible();
|
||||
|
||||
// Both mode buttons should exist
|
||||
await expect(page.locator(SELECTORS.appModeButton)).toBeVisible();
|
||||
await expect(page.locator(SELECTORS.securityModeButton)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// WebSocket Connection Tests (4 tests)
|
||||
// =========================================================================
|
||||
test.describe('WebSocket Connection', () => {
|
||||
test('should establish WebSocket connection on load', async ({ page, authenticatedUser }) => {
|
||||
test.skip(!cerberusEnabled, 'LiveLogViewer not available - Cerberus security module is disabled');
|
||||
await loginUser(page, authenticatedUser);
|
||||
|
||||
let wsConnected = false;
|
||||
|
||||
page.on('websocket', (ws) => {
|
||||
if (
|
||||
ws.url().includes('/api/v1/cerberus/logs/ws') ||
|
||||
ws.url().includes('/api/v1/logs/live')
|
||||
) {
|
||||
wsConnected = true;
|
||||
}
|
||||
});
|
||||
|
||||
await navigateToLiveLogs(page);
|
||||
await waitForWebSocketConnection(page);
|
||||
|
||||
expect(wsConnected).toBe(true);
|
||||
});
|
||||
|
||||
test('should show connected status indicator when connected', async ({
|
||||
page,
|
||||
authenticatedUser,
|
||||
}) => {
|
||||
test.skip(!cerberusEnabled, 'LiveLogViewer not available - Cerberus security module is disabled');
|
||||
await loginUser(page, authenticatedUser);
|
||||
await navigateToLiveLogs(page);
|
||||
|
||||
// Wait for connection
|
||||
await waitForWebSocketConnection(page);
|
||||
|
||||
// Status should show connected with green styling
|
||||
const statusBadge = page.locator(SELECTORS.connectionStatus);
|
||||
await expect(statusBadge).toContainText('Connected');
|
||||
// Verify green indicator - could be bg-green, text-green, or via CSS variables
|
||||
const hasGreenStyle = await statusBadge.evaluate((el) => {
|
||||
const classes = el.className;
|
||||
const computedColor = getComputedStyle(el).color;
|
||||
const computedBg = getComputedStyle(el).backgroundColor;
|
||||
return classes.includes('green') ||
|
||||
classes.includes('success') ||
|
||||
computedColor.includes('rgb(34, 197, 94)') || // green-500
|
||||
computedBg.includes('rgb(34, 197, 94)');
|
||||
});
|
||||
expect(hasGreenStyle).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should handle connection failure gracefully', async ({ page, authenticatedUser }) => {
|
||||
test.skip(!cerberusEnabled, 'LiveLogViewer not available - Cerberus security module is disabled');
|
||||
await loginUser(page, authenticatedUser);
|
||||
|
||||
// Block WebSocket endpoints to simulate failure
|
||||
await page.route('**/api/v1/cerberus/logs/ws', (route) => route.abort('connectionrefused'));
|
||||
await page.route('**/api/v1/logs/live', (route) => route.abort('connectionrefused'));
|
||||
|
||||
await navigateToLiveLogs(page);
|
||||
|
||||
// Should show disconnected status
|
||||
const statusBadge = page.locator(SELECTORS.connectionStatus);
|
||||
await expect(statusBadge).toContainText('Disconnected');
|
||||
await expect(statusBadge).toHaveClass(/bg-red/);
|
||||
|
||||
// Error message should be visible
|
||||
await expect(page.locator(SELECTORS.connectionError)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show disconnect handling and recovery UI', async ({
|
||||
page,
|
||||
authenticatedUser,
|
||||
}) => {
|
||||
test.skip(!cerberusEnabled, 'LiveLogViewer not available - Cerberus security module is disabled');
|
||||
await loginUser(page, authenticatedUser);
|
||||
await navigateToLiveLogs(page);
|
||||
|
||||
// Initially connected
|
||||
await waitForWebSocketConnection(page);
|
||||
|
||||
// Block the WebSocket to simulate disconnect
|
||||
await page.route('**/api/v1/cerberus/logs/ws', (route) => route.abort());
|
||||
await page.route('**/api/v1/logs/live', (route) => route.abort());
|
||||
|
||||
// Trigger a reconnect by switching modes
|
||||
await page.click(SELECTORS.appModeButton);
|
||||
|
||||
// Should show disconnected after failed reconnect
|
||||
await expect(page.locator(SELECTORS.connectionStatus)).toContainText('Disconnected', {
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Log Display Tests (4 tests)
|
||||
// =========================================================================
|
||||
test.describe('Log Display', () => {
|
||||
test('should display incoming log entries in real-time', async ({
|
||||
page,
|
||||
authenticatedUser,
|
||||
}) => {
|
||||
test.skip(!cerberusEnabled, 'LiveLogViewer not available - Cerberus security module is disabled');
|
||||
await loginUser(page, authenticatedUser);
|
||||
|
||||
// Setup mock WebSocket response
|
||||
await page.route('**/api/v1/cerberus/logs/ws', async (route) => {
|
||||
// Allow the WebSocket to connect
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await navigateToLiveLogs(page);
|
||||
await waitForWebSocketConnection(page);
|
||||
|
||||
// Verify log container is visible
|
||||
const logContainer = page.locator(SELECTORS.logContainer);
|
||||
await expect(logContainer).toBeVisible();
|
||||
|
||||
// Initially should show empty state or waiting message
|
||||
await expect(page.locator(SELECTORS.emptyState).or(page.locator(SELECTORS.logEntry))).toBeVisible();
|
||||
});
|
||||
|
||||
test('should format log entries with timestamp and source', async ({
|
||||
page,
|
||||
authenticatedUser,
|
||||
}) => {
|
||||
test.skip(!cerberusEnabled, 'LiveLogViewer not available - Cerberus security module is disabled');
|
||||
await loginUser(page, authenticatedUser);
|
||||
await navigateToLiveLogs(page);
|
||||
await waitForWebSocketConnection(page);
|
||||
|
||||
// Wait for any logs to appear or check structure is ready
|
||||
const logContainer = page.locator(SELECTORS.logContainer);
|
||||
await expect(logContainer).toBeVisible();
|
||||
|
||||
// Check that log count is displayed in footer
|
||||
const logCountFooter = page.locator(SELECTORS.logCount);
|
||||
await expect(logCountFooter).toBeVisible();
|
||||
await expect(logCountFooter).toContainText(/logs/i);
|
||||
});
|
||||
|
||||
test('should display log count in footer', async ({ page, authenticatedUser }) => {
|
||||
test.skip(!cerberusEnabled, 'LiveLogViewer not available - Cerberus security module is disabled');
|
||||
await loginUser(page, authenticatedUser);
|
||||
await navigateToLiveLogs(page);
|
||||
await waitForWebSocketConnection(page);
|
||||
|
||||
// Log count footer should be visible
|
||||
const logCount = page.locator(SELECTORS.logCount);
|
||||
await expect(logCount).toBeVisible();
|
||||
|
||||
// Should show format like "Showing X of Y logs"
|
||||
await expect(logCount).toContainText(/Showing \d+ of \d+ logs/);
|
||||
});
|
||||
|
||||
test('should auto-scroll to latest logs', async ({ page, authenticatedUser }) => {
|
||||
test.skip(!cerberusEnabled, 'LiveLogViewer not available - Cerberus security module is disabled');
|
||||
await loginUser(page, authenticatedUser);
|
||||
await navigateToLiveLogs(page);
|
||||
await waitForWebSocketConnection(page);
|
||||
|
||||
// Get log container
|
||||
const logContainer = page.locator(SELECTORS.logContainer);
|
||||
await expect(logContainer).toBeVisible();
|
||||
|
||||
// Container should be scrollable
|
||||
const scrollHeight = await logContainer.evaluate((el) => el.scrollHeight);
|
||||
const clientHeight = await logContainer.evaluate((el) => el.clientHeight);
|
||||
|
||||
// Verify container has proper scroll setup
|
||||
expect(clientHeight).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Filtering Tests (4 tests)
|
||||
// =========================================================================
|
||||
test.describe('Filtering', () => {
|
||||
test('should filter logs by level', async ({ page, authenticatedUser }) => {
|
||||
test.skip(!cerberusEnabled, 'LiveLogViewer not available - Cerberus security module is disabled');
|
||||
await loginUser(page, authenticatedUser);
|
||||
await navigateToLiveLogs(page);
|
||||
await waitForWebSocketConnection(page);
|
||||
|
||||
// Level filter should be visible - try multiple selectors
|
||||
const levelSelect = page.locator(SELECTORS.levelSelect).first();
|
||||
|
||||
// Skip if level filter not implemented
|
||||
const isVisible = await levelSelect.isVisible({ timeout: 3000 }).catch(() => false);
|
||||
if (!isVisible) {
|
||||
test.skip(true, 'Level filter not visible in current UI implementation');
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(levelSelect).toBeVisible();
|
||||
|
||||
// Get available options and select one
|
||||
const options = await levelSelect.locator('option').allTextContents();
|
||||
expect(options.length).toBeGreaterThan(1);
|
||||
|
||||
// Select the second option (first non-"all" option)
|
||||
await levelSelect.selectOption({ index: 1 });
|
||||
|
||||
// Verify a selection was made
|
||||
const selectedValue = await levelSelect.inputValue();
|
||||
expect(selectedValue).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should filter logs by search text', async ({ page, authenticatedUser }) => {
|
||||
test.skip(!cerberusEnabled, 'LiveLogViewer not available - Cerberus security module is disabled');
|
||||
await loginUser(page, authenticatedUser);
|
||||
await navigateToLiveLogs(page);
|
||||
await waitForWebSocketConnection(page);
|
||||
|
||||
// Text filter input should be visible
|
||||
const textFilter = page.locator(SELECTORS.textFilter);
|
||||
await expect(textFilter).toBeVisible();
|
||||
|
||||
// Type search text
|
||||
await textFilter.fill('api/users');
|
||||
|
||||
// Verify input has the value
|
||||
await expect(textFilter).toHaveValue('api/users');
|
||||
|
||||
// Log count should update (may show filtered results)
|
||||
await expect(page.locator(SELECTORS.logCount)).toContainText(/logs/);
|
||||
});
|
||||
|
||||
test('should clear all filters', async ({ page, authenticatedUser }) => {
|
||||
test.skip(!cerberusEnabled, 'LiveLogViewer not available - Cerberus security module is disabled');
|
||||
await loginUser(page, authenticatedUser);
|
||||
await navigateToLiveLogs(page);
|
||||
await waitForWebSocketConnection(page);
|
||||
|
||||
// Apply filters
|
||||
const textFilter = page.locator(SELECTORS.textFilter);
|
||||
const levelSelect = page.locator(SELECTORS.levelSelect);
|
||||
|
||||
await textFilter.fill('test');
|
||||
await levelSelect.selectOption('error');
|
||||
|
||||
// Verify filters applied
|
||||
await expect(textFilter).toHaveValue('test');
|
||||
await expect(levelSelect).toHaveValue('error');
|
||||
|
||||
// Clear text filter
|
||||
await textFilter.clear();
|
||||
await expect(textFilter).toHaveValue('');
|
||||
|
||||
// Reset level filter
|
||||
await levelSelect.selectOption('');
|
||||
await expect(levelSelect).toHaveValue('');
|
||||
});
|
||||
|
||||
test('should filter by source in security mode', async ({ page, authenticatedUser }) => {
|
||||
test.skip(!cerberusEnabled, 'LiveLogViewer not available - Cerberus security module is disabled');
|
||||
await loginUser(page, authenticatedUser);
|
||||
await navigateToLiveLogs(page);
|
||||
|
||||
// Ensure we're in security mode
|
||||
await page.click(SELECTORS.securityModeButton);
|
||||
await waitForWebSocketConnection(page);
|
||||
|
||||
// Source filter should be visible in security mode - try multiple selectors
|
||||
const sourceSelect = page.locator(SELECTORS.sourceSelect).first();
|
||||
|
||||
// Skip if source filter not implemented
|
||||
const isVisible = await sourceSelect.isVisible({ timeout: 3000 }).catch(() => false);
|
||||
if (!isVisible) {
|
||||
test.skip(true, 'Source filter not visible in current UI implementation');
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(sourceSelect).toBeVisible();
|
||||
|
||||
// Get available options
|
||||
const options = await sourceSelect.locator('option').allTextContents();
|
||||
expect(options.length).toBeGreaterThan(1);
|
||||
|
||||
// Select a non-default option
|
||||
await sourceSelect.selectOption({ index: 1 });
|
||||
const selectedValue = await sourceSelect.inputValue();
|
||||
expect(selectedValue).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Mode Toggle Tests (3 tests)
|
||||
// =========================================================================
|
||||
test.describe('Mode Toggle', () => {
|
||||
test('should toggle between App and Security log modes', async ({
|
||||
page,
|
||||
authenticatedUser,
|
||||
}) => {
|
||||
test.skip(!cerberusEnabled, 'LiveLogViewer not available - Cerberus security module is disabled');
|
||||
await loginUser(page, authenticatedUser);
|
||||
await navigateToLiveLogs(page);
|
||||
|
||||
// Default should be security mode - check for active state
|
||||
const securityButton = page.locator(SELECTORS.securityModeButton);
|
||||
const isSecurityActive = await securityButton.evaluate((el) => {
|
||||
return el.getAttribute('data-state') === 'active' ||
|
||||
el.classList.contains('bg-blue-600') ||
|
||||
el.classList.contains('active') ||
|
||||
el.getAttribute('aria-pressed') === 'true';
|
||||
});
|
||||
expect(isSecurityActive).toBeTruthy();
|
||||
|
||||
// Click App mode
|
||||
await page.click(SELECTORS.appModeButton);
|
||||
await page.waitForTimeout(200); // Wait for state transition
|
||||
|
||||
// App button should now be active
|
||||
const appButton = page.locator(SELECTORS.appModeButton);
|
||||
const isAppActive = await appButton.evaluate((el) => {
|
||||
return el.getAttribute('data-state') === 'active' ||
|
||||
el.classList.contains('bg-blue-600') ||
|
||||
el.classList.contains('active') ||
|
||||
el.getAttribute('aria-pressed') === 'true';
|
||||
});
|
||||
expect(isAppActive).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should switch WebSocket endpoint when mode changes', async ({
|
||||
page,
|
||||
authenticatedUser,
|
||||
}) => {
|
||||
test.skip(!cerberusEnabled, 'LiveLogViewer not available - Cerberus security module is disabled');
|
||||
await loginUser(page, authenticatedUser);
|
||||
|
||||
const connectedEndpoints: string[] = [];
|
||||
|
||||
page.on('websocket', (ws) => {
|
||||
connectedEndpoints.push(ws.url());
|
||||
});
|
||||
|
||||
await navigateToLiveLogs(page);
|
||||
await waitForWebSocketConnection(page);
|
||||
|
||||
// Should have connected to security endpoint
|
||||
expect(connectedEndpoints.some((url) => url.includes('/cerberus/logs/ws'))).toBe(true);
|
||||
|
||||
// Switch to app mode
|
||||
await page.click(SELECTORS.appModeButton);
|
||||
|
||||
// Wait for new connection
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should have connected to live logs endpoint
|
||||
expect(
|
||||
connectedEndpoints.some(
|
||||
(url) => url.includes('/logs/live') || url.includes('/cerberus/logs/ws')
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('should clear logs when switching modes', async ({ page, authenticatedUser }) => {
|
||||
test.skip(!cerberusEnabled, 'LiveLogViewer not available - Cerberus security module is disabled');
|
||||
await loginUser(page, authenticatedUser);
|
||||
await navigateToLiveLogs(page);
|
||||
await waitForWebSocketConnection(page);
|
||||
|
||||
// Get initial log count text
|
||||
const logCount = page.locator(SELECTORS.logCount);
|
||||
await expect(logCount).toBeVisible();
|
||||
|
||||
// Switch mode
|
||||
await page.click(SELECTORS.appModeButton);
|
||||
|
||||
// Logs should be cleared - count should show 0 of 0
|
||||
await expect(logCount).toContainText('0 of 0');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Playback Controls Tests (2 tests from Performance category)
|
||||
// =========================================================================
|
||||
test.describe('Playback Controls', () => {
|
||||
test('should pause and resume log streaming', async ({ page, authenticatedUser }) => {
|
||||
test.skip(!cerberusEnabled, 'LiveLogViewer not available - Cerberus security module is disabled');
|
||||
await loginUser(page, authenticatedUser);
|
||||
await navigateToLiveLogs(page);
|
||||
await waitForWebSocketConnection(page);
|
||||
|
||||
// Click pause button
|
||||
const pauseButton = page.locator(SELECTORS.pauseButton);
|
||||
await expect(pauseButton).toBeVisible();
|
||||
await pauseButton.click();
|
||||
|
||||
// Should show paused indicator
|
||||
await expect(page.locator(SELECTORS.pausedIndicator)).toBeVisible();
|
||||
|
||||
// Pause button should become resume button
|
||||
await expect(page.locator(SELECTORS.resumeButton)).toBeVisible();
|
||||
|
||||
// Click resume
|
||||
await page.locator(SELECTORS.resumeButton).click();
|
||||
|
||||
// Paused indicator should be hidden
|
||||
await expect(page.locator(SELECTORS.pausedIndicator)).not.toBeVisible();
|
||||
|
||||
// Should be back to pause button
|
||||
await expect(page.locator(SELECTORS.pauseButton)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should clear all logs', async ({ page, authenticatedUser }) => {
|
||||
test.skip(!cerberusEnabled, 'LiveLogViewer not available - Cerberus security module is disabled');
|
||||
await loginUser(page, authenticatedUser);
|
||||
await navigateToLiveLogs(page);
|
||||
await waitForWebSocketConnection(page);
|
||||
|
||||
// Click clear button
|
||||
const clearButton = page.locator(SELECTORS.clearButton);
|
||||
await expect(clearButton).toBeVisible();
|
||||
await clearButton.click();
|
||||
|
||||
// Logs should be cleared
|
||||
await expect(page.locator(SELECTORS.logCount)).toContainText('0 of 0');
|
||||
|
||||
// Should show empty state
|
||||
await expect(page.locator(SELECTORS.emptyState)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Performance Tests (2 tests)
|
||||
// =========================================================================
|
||||
test.describe('Performance', () => {
|
||||
test('should handle high volume of incoming logs', async ({ page, authenticatedUser }) => {
|
||||
test.skip(!cerberusEnabled, 'LiveLogViewer not available - Cerberus security module is disabled');
|
||||
await loginUser(page, authenticatedUser);
|
||||
await navigateToLiveLogs(page);
|
||||
await waitForWebSocketConnection(page);
|
||||
|
||||
// Verify the component can render without errors
|
||||
const logContainer = page.locator(SELECTORS.logContainer);
|
||||
await expect(logContainer).toBeVisible();
|
||||
|
||||
// Component should remain responsive
|
||||
const pauseButton = page.locator(SELECTORS.pauseButton);
|
||||
await expect(pauseButton).toBeEnabled();
|
||||
|
||||
// Filters should still work
|
||||
const textFilter = page.locator(SELECTORS.textFilter);
|
||||
await textFilter.fill('test');
|
||||
await expect(textFilter).toHaveValue('test');
|
||||
});
|
||||
|
||||
test('should respect maximum log buffer limit of 500 entries', async ({
|
||||
page,
|
||||
authenticatedUser,
|
||||
}) => {
|
||||
test.skip(!cerberusEnabled, 'LiveLogViewer not available - Cerberus security module is disabled');
|
||||
await loginUser(page, authenticatedUser);
|
||||
await navigateToLiveLogs(page);
|
||||
await waitForWebSocketConnection(page);
|
||||
|
||||
// The component has maxLogs prop defaulting to 500
|
||||
// Verify the log count display exists and functions
|
||||
const logCount = page.locator(SELECTORS.logCount);
|
||||
await expect(logCount).toBeVisible();
|
||||
|
||||
// The count format should be "Showing X of Y logs"
|
||||
await expect(logCount).toContainText(/Showing \d+ of \d+ logs/);
|
||||
|
||||
// Even with many logs, the displayed count should not exceed maxLogs
|
||||
// This is a structural test - the actual buffer limiting is tested implicitly
|
||||
const countText = await logCount.textContent();
|
||||
const match = countText?.match(/of (\d+) logs/);
|
||||
if (match) {
|
||||
const totalLogs = parseInt(match[1], 10);
|
||||
expect(totalLogs).toBeLessThanOrEqual(500);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Security Mode Specific Tests (2 additional tests)
|
||||
// =========================================================================
|
||||
test.describe('Security Mode Features', () => {
|
||||
test('should show blocked only filter in security mode', async ({
|
||||
page,
|
||||
authenticatedUser,
|
||||
}) => {
|
||||
test.skip(!cerberusEnabled, 'LiveLogViewer not available - Cerberus security module is disabled');
|
||||
await loginUser(page, authenticatedUser);
|
||||
await navigateToLiveLogs(page);
|
||||
|
||||
// Ensure security mode
|
||||
await page.click(SELECTORS.securityModeButton);
|
||||
await waitForWebSocketConnection(page);
|
||||
|
||||
// Blocked only checkbox should be visible - use label text to locate
|
||||
const blockedLabel = page.getByText(/blocked.*only/i);
|
||||
const isVisible = await blockedLabel.isVisible({ timeout: 3000 }).catch(() => false);
|
||||
|
||||
if (!isVisible) {
|
||||
test.skip(true, 'Blocked only filter not visible in current UI implementation');
|
||||
return;
|
||||
}
|
||||
|
||||
const blockedCheckbox = page.locator('input[type="checkbox"]').filter({
|
||||
has: page.locator('xpath=ancestor::label[contains(., "Blocked")]'),
|
||||
}).or(blockedLabel.locator('..').locator('input[type="checkbox"]')).first();
|
||||
|
||||
// Toggle the checkbox
|
||||
await blockedCheckbox.click({ force: true });
|
||||
await page.waitForTimeout(100);
|
||||
const isChecked = await blockedCheckbox.isChecked();
|
||||
expect(isChecked).toBe(true);
|
||||
|
||||
// Uncheck
|
||||
await blockedCheckbox.click({ force: true });
|
||||
await page.waitForTimeout(100);
|
||||
const isUnchecked = await blockedCheckbox.isChecked();
|
||||
expect(isUnchecked).toBe(false);
|
||||
});
|
||||
|
||||
test('should hide source filter in app mode', async ({ page, authenticatedUser }) => {
|
||||
test.skip(!cerberusEnabled, 'LiveLogViewer not available - Cerberus security module is disabled');
|
||||
await loginUser(page, authenticatedUser);
|
||||
await navigateToLiveLogs(page);
|
||||
|
||||
// Start in security mode - source filter visible
|
||||
await page.click(SELECTORS.securityModeButton);
|
||||
await waitForWebSocketConnection(page);
|
||||
await expect(page.locator(SELECTORS.sourceSelect)).toBeVisible();
|
||||
|
||||
// Switch to app mode
|
||||
await page.click(SELECTORS.appModeButton);
|
||||
|
||||
// Source filter should be hidden
|
||||
await expect(page.locator(SELECTORS.sourceSelect)).not.toBeVisible();
|
||||
|
||||
// Blocked only checkbox should also be hidden
|
||||
await expect(page.locator('text=Blocked only')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,872 +0,0 @@
|
||||
/**
|
||||
* 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): UptimeHeartbeat[] => {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: i,
|
||||
monitor_id: monitorId,
|
||||
status: 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 history = generateMockHistory(monitor.id, 60);
|
||||
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<UptimeMonitor> | 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<UptimeMonitor> | 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<UptimeMonitor> | 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);
|
||||
|
||||
// Wait for modal to be visible
|
||||
const modal = page.locator('[role="dialog"], .fixed.inset-0');
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user