/** * Logs Page - Static Log File Viewing E2E Tests * * Tests for log file listing, content display, filtering, pagination, and download. * Covers 18 test scenarios as defined in phase5-implementation.md. * * Test Categories: * - Page Layout (3 tests): heading, file list, empty state * - Log File List (4 tests): display files, file sizes, last modified, sorting * - Log Content Display (4 tests): select file, display content, line numbers, syntax highlighting * - Pagination (3 tests): page navigation, page size, page info * - Search/Filter (2 tests): text search, filter by level * - Download (2 tests): download file, download error * * Route: /tasks/logs * Component: Logs.tsx */ import { test, expect, loginUser, TEST_PASSWORD } from '../fixtures/auth-fixtures'; import { setupLogFiles, generateMockEntries, LogFile, CaddyAccessLog, LOG_SELECTORS, } from '../utils/phase5-helpers'; import { waitForToast, waitForLoadingComplete, waitForAPIResponse } from '../utils/wait-helpers'; /** * Mock log files for testing */ const mockLogFiles: LogFile[] = [ { name: 'access.log', size: 1048576, modified: '2024-01-15T12:00:00Z' }, { name: 'error.log', size: 256000, modified: '2024-01-15T11:30:00Z' }, { name: 'caddy.log', size: 512000, modified: '2024-01-14T10:00:00Z' }, ]; /** * Mock log entries for content display testing */ const mockLogEntries: CaddyAccessLog[] = [ { level: 'info', ts: Date.now() / 1000, logger: 'http.log.access', msg: 'handled request', request: { remote_ip: '192.168.1.100', method: 'GET', host: 'api.example.com', uri: '/api/v1/users', proto: 'HTTP/2', }, status: 200, duration: 0.045, size: 1234, }, { level: 'error', ts: Date.now() / 1000 - 60, logger: 'http.log.access', msg: 'connection refused', request: { remote_ip: '192.168.1.101', method: 'POST', host: 'api.example.com', uri: '/api/v1/auth/login', proto: 'HTTP/2', }, status: 502, duration: 5.023, size: 0, }, { level: 'warn', ts: Date.now() / 1000 - 120, logger: 'http.log.access', msg: 'rate limit exceeded', request: { remote_ip: '10.0.0.50', method: 'GET', host: 'web.example.com', uri: '/dashboard', proto: 'HTTP/1.1', }, status: 429, duration: 0.001, size: 256, }, ]; /** * Selectors for the Logs page */ const SELECTORS = { pageTitle: 'h1', logFileList: '[data-testid="log-file-list"]', logTable: '[data-testid="log-table"]', pageInfo: '[data-testid="page-info"]', searchInput: 'input[placeholder*="Search"]', hostFilter: 'input[placeholder*="Host"]', levelSelect: 'select', statusSelect: 'select', sortSelect: 'select', refreshButton: 'button:has-text("Refresh")', downloadButton: 'button:has-text("Download")', // Pagination buttons - scope to content area by looking for sibling showing text // The pagination buttons are next to "Showing x - y of z" text prevPageButton: '.flex.gap-2 button:has(.lucide-chevron-left), [data-testid="prev-page"], button[aria-label*="Previous"]', nextPageButton: '.flex.gap-2 button:has(.lucide-chevron-right), [data-testid="next-page"], button[aria-label*="Next"]', emptyState: '[class*="EmptyState"], [data-testid="empty-state"]', loadingSkeleton: '[class*="Skeleton"], [data-testid="skeleton"]', }; /** * Helper to set up log files and content mocking */ async function setupLogFilesWithContent( page: import('@playwright/test').Page, files: LogFile[] = mockLogFiles, entries: CaddyAccessLog[] = mockLogEntries, total?: number ) { // Mock log files list await page.route('**/api/v1/logs', async (route) => { if (route.request().method() === 'GET') { await route.fulfill({ status: 200, json: files }); } else { await route.continue(); } }); // Mock log content for each file for (const file of files) { await page.route(`**/api/v1/logs/${file.name}*`, async (route) => { const url = new URL(route.request().url()); const offset = parseInt(url.searchParams.get('offset') || '0'); const limit = parseInt(url.searchParams.get('limit') || '50'); const search = url.searchParams.get('search') || ''; const level = url.searchParams.get('level') || ''; const host = url.searchParams.get('host') || ''; // Apply filters let filteredEntries = [...entries]; if (search) { filteredEntries = filteredEntries.filter( (e) => e.msg.toLowerCase().includes(search.toLowerCase()) || e.request.uri.toLowerCase().includes(search.toLowerCase()) ); } if (level) { filteredEntries = filteredEntries.filter( (e) => e.level.toLowerCase() === level.toLowerCase() ); } if (host) { filteredEntries = filteredEntries.filter((e) => e.request.host.toLowerCase().includes(host.toLowerCase()) ); } const paginatedEntries = filteredEntries.slice(offset, offset + limit); const totalCount = total || filteredEntries.length; await route.fulfill({ status: 200, json: { filename: file.name, logs: paginatedEntries, total: totalCount, limit, offset, }, }); }); } } test.describe('Logs Page - Static Log File Viewing', () => { // ========================================================================= // Page Layout Tests (3 tests) // ========================================================================= test.describe('Page Layout', () => { test('should display logs page with file selector', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); await setupLogFilesWithContent(page); await page.goto('/tasks/logs'); await waitForLoadingComplete(page); // Verify page title await expect(page.locator(SELECTORS.pageTitle)).toContainText(/logs/i); // Verify file list sidebar is visible await expect(page.locator(SELECTORS.logFileList)).toBeVisible(); }); test('should show list of available log files', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); await setupLogFilesWithContent(page); await page.goto('/tasks/logs'); await waitForLoadingComplete(page); // Verify all log files are displayed in the list await expect(page.getByText('access.log')).toBeVisible(); await expect(page.getByText('error.log')).toBeVisible(); await expect(page.getByText('caddy.log')).toBeVisible(); }); test('should display log filters section', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); await setupLogFilesWithContent(page); await page.goto('/tasks/logs'); await waitForLoadingComplete(page); // Wait for filters to be visible (they appear when a log file is selected) // The component auto-selects the first log file await expect(page.locator(SELECTORS.searchInput)).toBeVisible({ timeout: 5000 }); // Verify filter controls are present await expect(page.locator(SELECTORS.refreshButton)).toBeVisible(); await expect(page.locator(SELECTORS.downloadButton)).toBeVisible(); }); }); // ========================================================================= // Log File List Tests (4 tests) // ========================================================================= test.describe('Log File List', () => { test('should list all available log files with metadata', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); await setupLogFilesWithContent(page); await page.goto('/tasks/logs'); await waitForLoadingComplete(page); // Verify files are listed with size information // The component displays size in MB format: (log.size / 1024 / 1024).toFixed(2) MB await expect(page.getByText('access.log')).toBeVisible(); await expect(page.getByText('1.00 MB')).toBeVisible(); // 1048576 bytes = 1.00 MB await expect(page.getByText('error.log')).toBeVisible(); await expect(page.getByText('0.24 MB')).toBeVisible(); // 256000 bytes ≈ 0.24 MB }); test('should load log content when file selected', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); await setupLogFilesWithContent(page); await page.goto('/tasks/logs'); await waitForLoadingComplete(page); // Set up response listener BEFORE clicking const responsePromise = page.waitForResponse((resp) => resp.url().includes('/api/v1/logs/error.log') ); // Click on error.log to select it await page.click('button:has-text("error.log")'); // Wait for content to load await responsePromise; // Verify log table is displayed with content await expect(page.locator(SELECTORS.logTable)).toBeVisible(); }); test('should show empty state for empty log files', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); // Mock empty log files list await page.route('**/api/v1/logs', async (route) => { if (route.request().method() === 'GET') { await route.fulfill({ status: 200, json: [] }); } else { await route.continue(); } }); await page.goto('/tasks/logs'); await waitForLoadingComplete(page); // Should show "No log files" message (use first() since there may be multiple matching texts) await expect(page.getByText(/no log files|select.*log/i).first()).toBeVisible(); }); test('should highlight selected log file', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); await setupLogFilesWithContent(page); await page.goto('/tasks/logs'); await waitForLoadingComplete(page); // The first file (access.log) is auto-selected // Check for visual selection indicator (brand color class) const accessLogButton = page.locator('button:has-text("access.log")'); await expect(accessLogButton).toHaveClass(/brand-500|bg-brand/); // Set up response listener BEFORE clicking const responsePromise = page.waitForResponse((resp) => resp.url().includes('/api/v1/logs/error.log') ); // Click on error.log await page.click('button:has-text("error.log")'); await responsePromise; // Error.log should now have the selected style const errorLogButton = page.locator('button:has-text("error.log")'); await expect(errorLogButton).toHaveClass(/brand-500|bg-brand/); }); }); // ========================================================================= // Log Content Display Tests (4 tests) // ========================================================================= test.describe('Log Content Display', () => { test('should display log entries in table format', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); await setupLogFilesWithContent(page); await page.goto('/tasks/logs'); await waitForLoadingComplete(page); // Wait for auto-selected log content to load await waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 }); // Verify table structure const logTable = page.locator(SELECTORS.logTable); await expect(logTable).toBeVisible(); // Verify table has expected columns await expect(page.getByRole('columnheader', { name: /time/i })).toBeVisible(); await expect(page.getByRole('columnheader', { name: /status/i })).toBeVisible(); await expect(page.getByRole('columnheader', { name: /method/i })).toBeVisible(); }); test('should show timestamp, level, method, uri, status', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); await setupLogFilesWithContent(page); await page.goto('/tasks/logs'); await waitForLoadingComplete(page); // Wait for content to load await waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 }); // Verify log entry content is displayed (use .first() where multiple matches possible) await expect(page.getByText('192.168.1.100').first()).toBeVisible(); await expect(page.getByText('GET').first()).toBeVisible(); await expect(page.getByText('/api/v1/users').first()).toBeVisible(); await expect(page.getByText('200').first()).toBeVisible(); }); test('should sort logs by timestamp', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); let capturedSort = ''; await page.route('**/api/v1/logs', async (route) => { await route.fulfill({ status: 200, json: mockLogFiles }); }); await page.route('**/api/v1/logs/access.log*', async (route) => { const url = new URL(route.request().url()); capturedSort = url.searchParams.get('sort') || 'desc'; await route.fulfill({ status: 200, json: { filename: 'access.log', logs: mockLogEntries, total: mockLogEntries.length, limit: 50, offset: 0, }, }); }); await page.goto('/tasks/logs'); await waitForLoadingComplete(page); await waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 }); // Default sort should be 'desc' (newest first) expect(capturedSort).toBe('desc'); // Change sort order via the select const sortSelect = page.locator('select').filter({ hasText: /newest|oldest/i }); if (await sortSelect.isVisible()) { await sortSelect.selectOption('asc'); await waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 }); expect(capturedSort).toBe('asc'); } }); test('should highlight error entries with distinct styling', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); await setupLogFilesWithContent(page); await page.goto('/tasks/logs'); await waitForLoadingComplete(page); await waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 }); // Find the 502 status entry (error) - use exact text match to avoid partial matches const errorStatus = page.getByText('502', { exact: true }); await expect(errorStatus).toBeVisible(); // Error status should have red/error styling class await expect(errorStatus).toHaveClass(/red|error/i); }); }); // ========================================================================= // Pagination Tests (3 tests) // ========================================================================= test.describe('Pagination', () => { test('should paginate large log files', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); // Generate 150 mock entries for pagination testing const largeEntrySet = generateMockEntries(150, 1); let capturedOffset = 0; await page.route('**/api/v1/logs', async (route) => { await route.fulfill({ status: 200, json: mockLogFiles }); }); await page.route('**/api/v1/logs/access.log*', async (route) => { const url = new URL(route.request().url()); capturedOffset = parseInt(url.searchParams.get('offset') || '0'); const limit = parseInt(url.searchParams.get('limit') || '50'); await route.fulfill({ status: 200, json: { filename: 'access.log', logs: largeEntrySet.slice(capturedOffset, capturedOffset + limit), total: 150, limit, offset: capturedOffset, }, }); }); await page.goto('/tasks/logs'); await waitForLoadingComplete(page); await waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 }); // Initial state - page 1 expect(capturedOffset).toBe(0); // Click next page button const nextButton = page.locator(SELECTORS.nextPageButton); await expect(nextButton).toBeEnabled(); // Use Promise.all to avoid race condition - set up listener BEFORE clicking await Promise.all([ page.waitForResponse( (resp) => resp.url().includes('/api/v1/logs/access.log') && resp.status() === 200 ), nextButton.click(), ]); // Should have requested offset 50 (second page) expect(capturedOffset).toBe(50); }); test('should display page info correctly', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); const largeEntrySet = generateMockEntries(150, 1); await page.route('**/api/v1/logs', async (route) => { await route.fulfill({ status: 200, json: mockLogFiles }); }); await page.route('**/api/v1/logs/access.log*', async (route) => { const url = new URL(route.request().url()); const offset = parseInt(url.searchParams.get('offset') || '0'); const limit = parseInt(url.searchParams.get('limit') || '50'); await route.fulfill({ status: 200, json: { filename: 'access.log', logs: largeEntrySet.slice(offset, offset + limit), total: 150, limit, offset, }, }); }); await page.goto('/tasks/logs'); await waitForLoadingComplete(page); await waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 }); // Verify page info displays correctly const pageInfo = page.locator(SELECTORS.pageInfo); await expect(pageInfo).toBeVisible(); // Should show "Showing 1 - 50 of 150" or similar await expect(pageInfo).toContainText(/1.*50.*150/); }); test('should disable prev button on first page and next on last', async ({ page, authenticatedUser, }) => { await loginUser(page, authenticatedUser); const entries = generateMockEntries(75, 1); // 2 pages (50 + 25) await page.route('**/api/v1/logs', async (route) => { await route.fulfill({ status: 200, json: mockLogFiles }); }); await page.route('**/api/v1/logs/access.log*', async (route) => { const url = new URL(route.request().url()); const offset = parseInt(url.searchParams.get('offset') || '0'); const limit = parseInt(url.searchParams.get('limit') || '50'); await route.fulfill({ status: 200, json: { filename: 'access.log', logs: entries.slice(offset, offset + limit), total: 75, limit, offset, }, }); }); await page.goto('/tasks/logs'); await waitForLoadingComplete(page); await waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 }); const prevButton = page.locator(SELECTORS.prevPageButton); const nextButton = page.locator(SELECTORS.nextPageButton); // On first page, prev should be disabled await expect(prevButton).toBeDisabled(); await expect(nextButton).toBeEnabled(); // Set up response listener BEFORE clicking const nextPageResponse = page.waitForResponse((resp) => resp.url().includes('/api/v1/logs/access.log') ); // Navigate to last page await nextButton.click(); await nextPageResponse; // On last page, next should be disabled await expect(prevButton).toBeEnabled(); await expect(nextButton).toBeDisabled(); }); }); // ========================================================================= // Search/Filter Tests (2 tests) // ========================================================================= test.describe('Search and Filter', () => { test('should filter logs by search text', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); let capturedSearch = ''; await page.route('**/api/v1/logs', async (route) => { await route.fulfill({ status: 200, json: mockLogFiles }); }); await page.route('**/api/v1/logs/access.log*', async (route) => { const url = new URL(route.request().url()); capturedSearch = url.searchParams.get('search') || ''; // Filter mock entries based on search const filtered = capturedSearch ? mockLogEntries.filter( (e) => e.msg.toLowerCase().includes(capturedSearch.toLowerCase()) || e.request.uri.toLowerCase().includes(capturedSearch.toLowerCase()) ) : mockLogEntries; await route.fulfill({ status: 200, json: { filename: 'access.log', logs: filtered, total: filtered.length, limit: 50, offset: 0, }, }); }); await page.goto('/tasks/logs'); await waitForLoadingComplete(page); await waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 }); // Type in search input const searchInput = page.locator(SELECTORS.searchInput); // Set up response listener BEFORE typing to catch the debounced request const searchResponsePromise = page.waitForResponse((resp) => resp.url().includes('/api/v1/logs/access.log') ); await searchInput.fill('users'); // Wait for debounced search request await searchResponsePromise; // Verify search parameter was sent expect(capturedSearch).toBe('users'); }); test('should filter logs by log level', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); let capturedLevel = ''; await page.route('**/api/v1/logs', async (route) => { await route.fulfill({ status: 200, json: mockLogFiles }); }); await page.route('**/api/v1/logs/access.log*', async (route) => { const url = new URL(route.request().url()); capturedLevel = url.searchParams.get('level') || ''; // Filter mock entries based on level const filtered = capturedLevel ? mockLogEntries.filter( (e) => e.level.toLowerCase() === capturedLevel.toLowerCase() ) : mockLogEntries; await route.fulfill({ status: 200, json: { filename: 'access.log', logs: filtered, total: filtered.length, limit: 50, offset: 0, }, }); }); await page.goto('/tasks/logs'); await waitForLoadingComplete(page); await waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 }); // Select Error level from dropdown const levelSelect = page.locator('select').filter({ hasText: /all levels/i }); if (await levelSelect.isVisible()) { await levelSelect.selectOption('ERROR'); await waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 }); // Verify level parameter was sent expect(capturedLevel.toLowerCase()).toBe('error'); } }); }); // ========================================================================= // Download Tests (2 tests) // ========================================================================= test.describe('Download', () => { test('should download log file successfully', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); await setupLogFilesWithContent(page); await page.goto('/tasks/logs'); await waitForLoadingComplete(page); await waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 }); // Verify download button is visible and enabled const downloadButton = page.locator(SELECTORS.downloadButton); await expect(downloadButton).toBeVisible(); await expect(downloadButton).toBeEnabled(); // The component uses window.location.href for downloads // We verify the button is properly rendered and clickable // In a real test, we'd track the download event, but that requires // the download endpoint to be properly mocked with Content-Disposition }); test('should handle download error gracefully', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); await page.route('**/api/v1/logs', async (route) => { await route.fulfill({ status: 200, json: mockLogFiles }); }); await page.route('**/api/v1/logs/access.log*', async (route) => { if (!route.request().url().includes('/download')) { await route.fulfill({ status: 200, json: { filename: 'access.log', logs: mockLogEntries, total: mockLogEntries.length, limit: 50, offset: 0, }, }); } else { await route.continue(); } }); // Mock download endpoint to fail await page.route('**/api/v1/logs/access.log/download', async (route) => { await route.fulfill({ status: 404, json: { error: 'Log file not found' }, }); }); await page.goto('/tasks/logs'); await waitForLoadingComplete(page); await waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 }); // Verify download button is present const downloadButton = page.locator(SELECTORS.downloadButton); await expect(downloadButton).toBeVisible(); // Note: The current implementation uses window.location.href for downloads, // which navigates the browser directly. Error handling would require // using fetch() with blob download pattern instead. // This test verifies the UI is in a valid state before download. }); }); // ========================================================================= // Additional Edge Cases // ========================================================================= test.describe('Edge Cases', () => { test('should handle empty log content gracefully', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); await page.route('**/api/v1/logs', async (route) => { await route.fulfill({ status: 200, json: mockLogFiles }); }); await page.route('**/api/v1/logs/access.log*', async (route) => { await route.fulfill({ status: 200, json: { filename: 'access.log', logs: [], total: 0, limit: 50, offset: 0, }, }); }); await page.goto('/tasks/logs'); await waitForLoadingComplete(page); await waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 }); // Should show "No logs found" or similar message await expect(page.getByText(/no logs found|no.*matching/i)).toBeVisible(); }); test('should reset to first page when changing log file', async ({ page, authenticatedUser, }) => { await loginUser(page, authenticatedUser); const largeEntrySet = generateMockEntries(150, 1); let lastOffset = 0; await page.route('**/api/v1/logs', async (route) => { await route.fulfill({ status: 200, json: mockLogFiles }); }); await page.route('**/api/v1/logs/*', async (route) => { const url = new URL(route.request().url()); lastOffset = parseInt(url.searchParams.get('offset') || '0'); const limit = parseInt(url.searchParams.get('limit') || '50'); await route.fulfill({ status: 200, json: { filename: 'test.log', logs: largeEntrySet.slice(lastOffset, lastOffset + limit), total: 150, limit, offset: lastOffset, }, }); }); await page.goto('/tasks/logs'); await waitForLoadingComplete(page); // Navigate to page 2 const nextButton = page.locator(SELECTORS.nextPageButton); await nextButton.click(); await page.waitForTimeout(500); expect(lastOffset).toBe(50); // Switch to different log file await page.click('button:has-text("error.log")'); await page.waitForTimeout(500); // Should reset to offset 0 expect(lastOffset).toBe(0); }); }); });