Files
Charon/tests/tasks/logs-viewing.spec.ts
2026-01-26 19:22:05 +00:00

820 lines
28 KiB
TypeScript

/**
* 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);
});
});
});