820 lines
28 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|