/** * Logs Page - Static Log File Viewing E2E Tests * * Tests for log file listing, content display, filtering, pagination, and download. * Covers 12 test scenarios optimized for WebKit and cross-browser compatibility. * * Test Categories: * - Page Layout (3 tests): heading, file list, filter section * - Log File List (2 tests): display files with metadata, select file * - Log Content Display (2 tests): show columns, highlight error entries * - Pagination (3 tests): navigate pages, page info, button states * - Search/Filter (2 tests): text search, level filter * - Download (2 tests): download file, error handling * * Route: /tasks/logs * Component: Logs.tsx * Updated: 2024-02-10 for full WebKit support */ import { test, expect, loginUser, TEST_PASSWORD } from '../fixtures/auth-fixtures'; import { waitForLoadingComplete, waitForAPIResponse } from '../utils/wait-helpers'; import type { Page } from '@playwright/test'; /** * Mock log files for testing */ const mockLogFiles = [ { 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 = [ { level: 'info', ts: Math.floor(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: Math.floor(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: Math.floor(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, }, ]; /** * Generate mock log entries for pagination testing */ function generateMockEntries(count: number, startOffset: number = 0) { return Array.from({ length: count }, (_, i) => ({ level: i % 3 === 0 ? 'error' : i % 3 === 1 ? 'warn' : 'info', ts: Math.floor(Date.now() / 1000) - i * 10, logger: 'http.log.access', msg: `request ${startOffset + i}`, request: { remote_ip: `192.168.1.${100 + (i % 100)}`, method: ['GET', 'POST', 'PUT'][i % 3], host: 'example.com', uri: `/api/endpoint-${i}`, proto: 'HTTP/2', }, status: 200 + (i % 4) * 100, duration: Math.random() * 1, size: Math.random() * 1000, })); } /** * Selectors and helpers for WebKit compatibility */ function getLogFileButton(page: Page, fileName: string) { return page.getByTestId(`log-file-${fileName}`); } function getPrevButton(page: Page) { return page.getByTestId('prev-page-button'); } function getNextButton(page: Page) { return page.getByTestId('next-page-button'); } /** * Helper to set up log API mocks */ async function setupLogMocks( page: Page, files = mockLogFiles, entries = mockLogEntries, totalCount?: number ) { // Mock log files list endpoint 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 endpoint 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') || ''; // Apply filters let filtered = [...entries]; if (search) { filtered = filtered.filter( (e) => e.msg.toLowerCase().includes(search.toLowerCase()) || e.request.uri.toLowerCase().includes(search.toLowerCase()) ); } if (level) { filtered = filtered.filter((e) => e.level.toLowerCase() === level.toLowerCase()); } const paginated = filtered.slice(offset, offset + limit); const total = totalCount || filtered.length; await route.fulfill({ status: 200, json: { filename: file.name, logs: paginated, total, limit, offset, }, }); }); } } test.describe('Logs Page - WebKit Compatible Tests', () => { test.describe('Page Layout', () => { test('should display logs page with file selector', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); await setupLogMocks(page); await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); // Verify page title contains "logs" await expect(page.getByRole('heading', { level: 1 })).toContainText(/logs/i); // Verify file list sidebar is visible await expect(page.getByTestId('log-file-list')).toBeVisible(); }); test('should show list of available log files', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); await setupLogMocks(page); const logFilesPromise = waitForAPIResponse(page, '/api/v1/logs', { status: 200 }); await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); await logFilesPromise; // 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 setupLogMocks(page); await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); // Wait for filters (appear when first log file is auto-selected) await expect(page.getByTestId('search-input')).toBeVisible({ timeout: 5000 }); // Verify filter controls are present await expect(page.getByTestId('refresh-button')).toBeVisible(); await expect(page.getByTestId('download-button')).toBeVisible(); }); }); test.describe('Log File List', () => { test('should list all available log files with metadata', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); await setupLogMocks(page); await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); // Verify files are listed with size information (in MB format) await expect(page.getByText('access.log')).toBeVisible(); await expect(page.getByText('1.00 MB')).toBeVisible(); await expect(page.getByText('error.log')).toBeVisible(); await expect(page.getByText('0.24 MB')).toBeVisible(); }); test('should load log content when file selected', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); await setupLogMocks(page); const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 }); await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); // The first file (access.log) is auto-selected - wait for content await initialContentPromise; // Verify log table is displayed await expect(page.getByTestId('log-table')).toBeVisible(); }); }); test.describe('Log Content Display', () => { test('should display log entries in table format', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); await setupLogMocks(page); const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 }); await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); // Wait for auto-selected log content to load await initialContentPromise; // Verify table structure const logTable = page.getByTestId('log-table'); 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 setupLogMocks(page); const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 }); await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); // Wait for content to load await initialContentPromise; // Verify log entry content is displayed // The mock data includes 192.168.1.100 as remote_ip in first entry const entryRow = page.getByRole('row').filter({ hasText: '192.168.1.100' }).first(); await expect(entryRow).toBeVisible(); await expect(entryRow.getByRole('cell', { name: 'GET' })).toBeVisible(); await expect(entryRow.getByText('/api/v1/users')).toBeVisible(); await expect(entryRow.getByTestId('status-200')).toBeVisible(); }); test('should highlight error entries with distinct styling', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); await setupLogMocks(page); const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 }); await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); await initialContentPromise; // Find the 502 error status badge - should have red styling class const errorStatus = page.getByTestId('status-502'); await expect(errorStatus).toBeVisible(); // Verify error has red styling (bg-red or similar) await expect(errorStatus).toHaveClass(/red/); }); }); 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); 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, }, }); }); // Set up the response wait BEFORE navigation to avoid missing fast mocked responses. const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 }); await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); await initialContentPromise; // Initial state - page 1 expect(capturedOffset).toBe(0); // Click next page button const nextButton = getNextButton(page); await expect(nextButton).toBeEnabled(); // Set up listener BEFORE clicking to capture the request 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); 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, }, }); }); const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 }); await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); await initialContentPromise; // Verify page info displays correctly const pageInfo = page.getByTestId('page-info'); await expect(pageInfo).toBeVisible(); // Should show "Showing 1 - 50 of 150" or similar format 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); // 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, }, }); }); const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 }); await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); await initialContentPromise; const prevButton = getPrevButton(page); const nextButton = getNextButton(page); // On first page, prev should be disabled await expect(prevButton).toBeDisabled(); await expect(nextButton).toBeEnabled(); // Navigate to last page await Promise.all([ page.waitForResponse((resp) => resp.url().includes('/api/v1/logs/access.log')), nextButton.click(), ]); // On last page, next should be disabled await expect(prevButton).toBeEnabled(); await expect(nextButton).toBeDisabled(); }); }); 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, }, }); }); const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 }); await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); await initialContentPromise; // Type in search input const searchInput = page.getByTestId('search-input'); // 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, }, }); }); const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 }); await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); await initialContentPromise; // Select Error level from dropdown using data-testid const levelSelect = page.getByTestId('level-select'); await expect(levelSelect).toBeVisible(); // Set up response listener BEFORE selecting const filterResponsePromise = page.waitForResponse((resp) => resp.url().includes('/api/v1/logs/access.log') ); await levelSelect.selectOption('ERROR'); await filterResponsePromise; // Verify level parameter was sent expect(capturedLevel.toLowerCase()).toBe('error'); }); }); test.describe('Download', () => { test('should download log file successfully', async ({ page, authenticatedUser }) => { await loginUser(page, authenticatedUser); await setupLogMocks(page); const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 }); await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); await initialContentPromise; // Verify download button is visible and enabled const downloadButton = page.getByTestId('download-button'); await expect(downloadButton).toBeVisible(); await expect(downloadButton).toBeEnabled(); // The download button clicking will use window.location.href for download // Verify the button state is correct for a successful download await expect(downloadButton).not.toHaveAttribute('disabled'); }); 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(); } }); const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 }); await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); await initialContentPromise; // Verify download button is present and properly rendered const downloadButton = page.getByTestId('download-button'); await expect(downloadButton).toBeVisible(); // Button should be in a clickable state even if download endpoint fails await expect(downloadButton).toBeEnabled(); }); }); 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, }, }); }); const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 }); await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); await initialContentPromise; // 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); 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', { waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); // Navigate to page 2 const nextButton = getNextButton(page); await nextButton.click(); // Wait briefly for state update await page.waitForTimeout(500); expect(lastOffset).toBe(50); // Switch to different log file using the new data-testid const errorLogButton = getLogFileButton(page, 'error.log'); await Promise.all([ page.waitForResponse((resp) => resp.url().includes('/api/v1/logs/')), errorLogButton.click(), ]); // Should reset to offset 0 when switching files expect(lastOffset).toBe(0); }); }); });