chore: clean .gitignore cache
This commit is contained in:
@@ -1,819 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user