chore: clean .gitignore cache

This commit is contained in:
GitHub Actions
2026-01-26 19:21:33 +00:00
parent 1b1b3a70b1
commit e5f0fec5db
1483 changed files with 0 additions and 472793 deletions

View File

@@ -1,567 +0,0 @@
/**
* Backups Page - Creation and List E2E Tests
*
* Tests for backup creation, listing, deletion, and download functionality.
* Covers 17 test scenarios as defined in phase5-implementation.md.
*
* Test Categories:
* - Page Layout (3 tests): heading, create button visibility, role-based access
* - Backup List Display (4 tests): empty state, backup list, sorting, loading
* - Create Backup Flow (5 tests): create success, toast, list refresh, button disable, error handling
* - Delete Backup (3 tests): delete with confirmation, cancel delete, error handling
* - Download Backup (2 tests): download trigger, file handling
*/
import { test, expect, loginUser, TEST_PASSWORD } from '../fixtures/auth-fixtures';
import { setupBackupsList, BackupFile, BACKUP_SELECTORS } from '../utils/phase5-helpers';
import { waitForToast, waitForLoadingComplete, waitForAPIResponse } from '../utils/wait-helpers';
/**
* Mock backup data for testing
*/
const mockBackups: BackupFile[] = [
{ filename: 'backup_2024-01-15_120000.tar.gz', size: 1048576, time: '2024-01-15T12:00:00Z' },
{ filename: 'backup_2024-01-14_120000.tar.gz', size: 2097152, time: '2024-01-14T12:00:00Z' },
];
/**
* Selectors for the Backups page
*/
const SELECTORS = {
pageTitle: 'h1',
createBackupButton: 'button:has-text("Create Backup")',
loadingSkeleton: '[data-testid="loading-skeleton"]',
emptyState: '[data-testid="empty-state"]',
backupTable: '[data-testid="backup-table"]',
backupRow: '[data-testid="backup-row"]',
downloadBtn: '[data-testid="backup-download-btn"]',
restoreBtn: '[data-testid="backup-restore-btn"]',
deleteBtn: '[data-testid="backup-delete-btn"]',
confirmDialog: '[role="dialog"]',
confirmButton: 'button:has-text("Delete")',
cancelButton: 'button:has-text("Cancel")',
};
test.describe('Backups Page - Creation and List', () => {
// =========================================================================
// Page Layout Tests (3 tests)
// =========================================================================
test.describe('Page Layout', () => {
test('should display backups page with correct heading', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
await expect(page.locator(SELECTORS.pageTitle)).toContainText(/backups/i);
});
test('should show Create Backup button for admin users', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
const createButton = page.locator(SELECTORS.createBackupButton).first();
await expect(createButton).toBeVisible();
await expect(createButton).toBeEnabled();
});
test('should hide Create Backup button for guest users', async ({ page, guestUser }) => {
await loginUser(page, guestUser);
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Guest users should not see any Create Backup button
const createButton = page.locator(SELECTORS.createBackupButton);
await expect(createButton).toHaveCount(0);
});
});
// =========================================================================
// Backup List Display Tests (4 tests)
// =========================================================================
test.describe('Backup List Display', () => {
test('should display empty state when no backups exist', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
// Mock empty response
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: [] });
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
const emptyState = page.locator(SELECTORS.emptyState);
await expect(emptyState).toBeVisible();
});
test('should display list of existing backups', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
// Mock backup list response
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Verify both backups are displayed
await expect(page.getByText('backup_2024-01-15_120000.tar.gz')).toBeVisible();
await expect(page.getByText('backup_2024-01-14_120000.tar.gz')).toBeVisible();
// Verify size is displayed (formatted)
await expect(page.getByText('1.00 MB')).toBeVisible();
await expect(page.getByText('2.00 MB')).toBeVisible();
});
test('should sort backups by date newest first', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
// Mock backup list with specific order
const sortedBackups: BackupFile[] = [
{ filename: 'backup_2024-01-16_120000.tar.gz', size: 512000, time: '2024-01-16T12:00:00Z' },
{ filename: 'backup_2024-01-15_120000.tar.gz', size: 1048576, time: '2024-01-15T12:00:00Z' },
{ filename: 'backup_2024-01-14_120000.tar.gz', size: 2097152, time: '2024-01-14T12:00:00Z' },
];
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: sortedBackups });
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Get all backup filenames in order
const rows = page.locator('table tbody tr, [role="row"]').filter({ hasNot: page.locator('th') });
const firstRow = rows.first();
const lastRow = rows.last();
// Newest backup should appear first
await expect(firstRow).toContainText('backup_2024-01-16');
await expect(lastRow).toContainText('backup_2024-01-14');
});
test('should show loading skeleton while fetching', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
// Delay the response to observe loading state
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'GET') {
await new Promise((resolve) => setTimeout(resolve, 1000));
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
// Should show loading skeleton initially
const skeleton = page.locator(SELECTORS.loadingSkeleton);
await expect(skeleton).toBeVisible({ timeout: 2000 });
// After loading completes, skeleton should disappear
await waitForLoadingComplete(page, { timeout: 5000 });
await expect(skeleton).not.toBeVisible();
});
});
// =========================================================================
// Create Backup Flow Tests (5 tests)
// =========================================================================
test.describe('Create Backup Flow', () => {
test('should create a new backup successfully', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
const newBackup: BackupFile = {
filename: 'backup_2024-01-16_120000.tar.gz',
size: 512000,
time: new Date().toISOString(),
};
let postCalled = false;
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'POST') {
postCalled = true;
await route.fulfill({ status: 201, json: newBackup });
} else if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Click create backup button and wait for API response concurrently
await Promise.all([
page.waitForResponse(r => r.url().includes('/api/v1/backups') && r.request().method() === 'POST' && r.status() === 201),
page.click(SELECTORS.createBackupButton),
]);
// Verify POST was called
expect(postCalled).toBe(true);
});
test('should show success toast after backup creation', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
const newBackup: BackupFile = {
filename: 'backup_2024-01-16_120000.tar.gz',
size: 512000,
time: new Date().toISOString(),
};
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({ status: 201, json: newBackup });
} else if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Click create backup button
await page.click(SELECTORS.createBackupButton);
// Wait for success toast
await waitForToast(page, /success|created/i, { type: 'success' });
});
test('should update backup list with new backup', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
const newBackup: BackupFile = {
filename: 'backup_2024-01-16_120000.tar.gz',
size: 512000,
time: new Date().toISOString(),
};
let requestCount = 0;
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({ status: 201, json: newBackup });
} else if (route.request().method() === 'GET') {
requestCount++;
// Return updated list after creation
const backups = requestCount > 1 ? [newBackup, ...mockBackups] : mockBackups;
await route.fulfill({ status: 200, json: backups });
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Initial state - should not show new backup
await expect(page.getByText('backup_2024-01-16_120000.tar.gz')).not.toBeVisible();
// Click create backup button
await page.click(SELECTORS.createBackupButton);
// Wait for success toast (which indicates the backup was created)
await waitForToast(page, /success|created/i, { type: 'success' });
// New backup should now be visible after list refresh
await expect(page.getByText('backup_2024-01-16_120000.tar.gz')).toBeVisible({ timeout: 5000 });
});
test('should disable create button while in progress', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'POST') {
// Delay response to observe disabled state
await new Promise((resolve) => setTimeout(resolve, 1000));
await route.fulfill({
status: 201,
json: { filename: 'test.tar.gz', size: 100, time: new Date().toISOString() },
});
} else if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
const createButton = page.locator(SELECTORS.createBackupButton);
// Click create button
await createButton.click();
// Button should be disabled during request
await expect(createButton).toBeDisabled();
// Wait for API response
await waitForAPIResponse(page, '/api/v1/backups', { status: 201 });
// After completion, button should be enabled again
await expect(createButton).toBeEnabled({ timeout: 5000 });
});
test('should handle backup creation failure', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({
status: 500,
json: { error: 'Internal server error' },
});
} else if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Click create backup button
await page.click(SELECTORS.createBackupButton);
// Wait for error toast
await waitForToast(page, /error|failed/i, { type: 'error' });
});
});
// =========================================================================
// Delete Backup Tests (3 tests)
// =========================================================================
test.describe('Delete Backup', () => {
test('should show confirmation dialog before deleting', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Click delete on first backup
const deleteButton = page.locator(SELECTORS.deleteBtn).first();
await deleteButton.click();
// Verify dialog appears
const dialog = page.locator(SELECTORS.confirmDialog);
await expect(dialog).toBeVisible();
// Verify dialog contains confirmation text
await expect(dialog).toContainText(/delete|confirm/i);
});
test('should delete backup after confirmation', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
const filename = 'backup_2024-01-15_120000.tar.gz';
let deleteRequested = false;
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
await page.route(`**/api/v1/backups/${filename}`, async (route) => {
if (route.request().method() === 'DELETE') {
deleteRequested = true;
await route.fulfill({ status: 204 });
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Click delete on first backup
const deleteButton = page.locator(SELECTORS.deleteBtn).first();
await deleteButton.click();
// Wait for dialog
const dialog = page.locator(SELECTORS.confirmDialog);
await expect(dialog).toBeVisible();
// Click confirm button and wait for DELETE request concurrently
const confirmButton = dialog.locator(SELECTORS.confirmButton);
await Promise.all([
page.waitForResponse(r => r.url().includes(`/api/v1/backups/${filename}`) && r.request().method() === 'DELETE' && r.status() === 204),
confirmButton.click(),
]);
// Verify DELETE was called
expect(deleteRequested).toBe(true);
// Dialog should close
await expect(dialog).not.toBeVisible();
});
test('should cancel delete when clicking cancel button', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
let deleteRequested = false;
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
await page.route('**/api/v1/backups/*', async (route) => {
if (route.request().method() === 'DELETE') {
deleteRequested = true;
await route.fulfill({ status: 204 });
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Click delete on first backup
const deleteButton = page.locator(SELECTORS.deleteBtn).first();
await deleteButton.click();
// Wait for dialog
const dialog = page.locator(SELECTORS.confirmDialog);
await expect(dialog).toBeVisible();
// Click cancel button
const cancelButton = dialog.locator(SELECTORS.cancelButton);
await cancelButton.click();
// Dialog should close
await expect(dialog).not.toBeVisible();
// DELETE should not have been called
expect(deleteRequested).toBe(false);
// Backup should still be visible
await expect(page.getByText('backup_2024-01-15_120000.tar.gz')).toBeVisible();
});
});
// =========================================================================
// Download Backup Tests (2 tests)
// =========================================================================
test.describe('Download Backup', () => {
test('should download backup file successfully', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
const filename = 'backup_2024-01-15_120000.tar.gz';
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
// Mock download endpoint
await page.route(`**/api/v1/backups/${filename}/download`, async (route) => {
await route.fulfill({
status: 200,
headers: {
'Content-Type': 'application/gzip',
'Content-Disposition': `attachment; filename="${filename}"`,
},
body: Buffer.from('mock backup content'),
});
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Track download event - The component uses window.location.href for downloads
// Since Playwright can't track navigation-based downloads directly,
// we verify the download button triggers the correct action
const downloadButton = page.locator(SELECTORS.downloadBtn).first();
await expect(downloadButton).toBeVisible();
await expect(downloadButton).toBeEnabled();
// For actual download verification in a real scenario, we'd use:
// const downloadPromise = page.waitForEvent('download');
// await downloadButton.click();
// const download = await downloadPromise;
// expect(download.suggestedFilename()).toBe(filename);
// For this test, we verify the button is clickable and properly rendered
const buttonTitle = await downloadButton.getAttribute('title');
expect(buttonTitle).toBeTruthy();
});
test('should show error toast when download fails', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
const filename = 'backup_2024-01-15_120000.tar.gz';
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
// Mock download endpoint with failure
await page.route(`**/api/v1/backups/${filename}/download`, async (route) => {
await route.fulfill({
status: 404,
json: { error: 'Backup file not found' },
});
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// The download button uses window.location.href which navigates away
// For error handling tests, we verify the download button is present
// In the actual component, download errors would be handled differently
// since window.location.href navigation can't be caught by JavaScript
const downloadButton = page.locator(SELECTORS.downloadBtn).first();
await expect(downloadButton).toBeVisible();
// Note: The Backups.tsx component uses window.location.href for downloads,
// which means download errors result in browser navigation to an error page
// rather than a toast notification. This is a known limitation of the current
// implementation. A better approach would use fetch() with blob download.
});
});
});

View File

@@ -1,394 +0,0 @@
/**
* Backups Page - Restore E2E Tests
*
* Tests for backup restoration functionality including confirmation dialog,
* restore execution, progress tracking, and error handling.
* Covers 8 test scenarios as defined in phase5-implementation.md.
*
* Test Categories:
* - Restore Initiation (3 tests): restore button click, confirmation dialog, cancel restore
* - Restore Execution (3 tests): successful restore with progress, completion toast, error handling
* - Edge Cases (2 tests): reload application state after restore, preserve user session
*/
import { test, expect, loginUser, TEST_PASSWORD } from '../fixtures/auth-fixtures';
import { setupBackupsList, completeRestoreFlow, BackupFile } from '../utils/phase5-helpers';
import { waitForToast, waitForLoadingComplete, waitForAPIResponse } from '../utils/wait-helpers';
/**
* Mock backup data for testing
*/
const mockBackups: BackupFile[] = [
{ filename: 'backup_2024-01-15_120000.tar.gz', size: 1048576, time: '2024-01-15T12:00:00Z' },
{ filename: 'backup_2024-01-14_120000.tar.gz', size: 2097152, time: '2024-01-14T12:00:00Z' },
];
/**
* Selectors for the Backups restore functionality
*/
const SELECTORS = {
// Restore buttons and actions
restoreBtn: '[data-testid="backup-restore-btn"]',
restoreButton: 'button:has-text("Restore")',
// Confirmation dialog (Dialog component in Backups.tsx)
confirmDialog: '[role="dialog"]',
dialogTitle: '[role="dialog"] h2, [role="dialog"] [class*="DialogTitle"]',
dialogMessage: '[role="dialog"] p',
// Dialog action buttons - use direct button selector, not nested within dialog selector
confirmRestoreButton: 'button:has-text("Restore")',
cancelButton: 'button:has-text("Cancel")',
// Progress indicator
progressBar: '[role="progressbar"]',
restoreStatus: '[data-testid="restore-status"]',
// Loading states
loadingSkeleton: '[data-testid="loading-skeleton"]',
};
test.describe('Backups Page - Restore', () => {
// =========================================================================
// Restore Initiation Tests (3 tests)
// =========================================================================
test.describe('Restore Initiation', () => {
test('should show confirmation dialog when clicking restore button', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Click restore on first backup
const restoreButton = page.locator(SELECTORS.restoreBtn).first();
await restoreButton.click();
// Verify dialog appears
const dialog = page.locator(SELECTORS.confirmDialog);
await expect(dialog).toBeVisible();
// Verify dialog contains restore-related content
await expect(dialog).toContainText(/restore/i);
});
test('should display warning message about data replacement in restore dialog', async ({
page,
adminUser,
}) => {
await loginUser(page, adminUser);
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Click restore on first backup
const restoreButton = page.locator(SELECTORS.restoreBtn).first();
await restoreButton.click();
// Verify dialog shows warning message (from translation key backups.restoreConfirmMessage)
const dialog = page.locator(SELECTORS.confirmDialog);
await expect(dialog).toBeVisible();
// The dialog should contain a message about the restore action
const dialogMessage = dialog.locator('p');
await expect(dialogMessage).toBeVisible();
});
test('should cancel restore when clicking cancel button', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
let restoreRequested = false;
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
await page.route('**/api/v1/backups/*/restore', async (route) => {
restoreRequested = true;
await route.fulfill({ status: 200, json: { message: 'Restore completed' } });
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Click restore on first backup
const restoreButton = page.locator(SELECTORS.restoreBtn).first();
await restoreButton.click();
// Wait for dialog
const dialog = page.locator(SELECTORS.confirmDialog);
await expect(dialog).toBeVisible();
// Click cancel button
const cancelButton = dialog.locator(SELECTORS.cancelButton);
await cancelButton.click();
// Dialog should close
await expect(dialog).not.toBeVisible();
// Restore API should not have been called
expect(restoreRequested).toBe(false);
});
});
// =========================================================================
// Restore Execution Tests (3 tests)
// =========================================================================
test.describe('Restore Execution', () => {
test('should restore backup successfully after confirmation', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
const filename = 'backup_2024-01-15_120000.tar.gz';
let restoreRequested = false;
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
await page.route(`**/api/v1/backups/${filename}/restore`, async (route) => {
if (route.request().method() === 'POST') {
restoreRequested = true;
await route.fulfill({
status: 200,
json: { message: 'Restore completed successfully' },
});
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Click restore on first backup
const restoreButton = page.locator(SELECTORS.restoreBtn).first();
await restoreButton.click();
// Wait for dialog
const dialog = page.locator(SELECTORS.confirmDialog);
await expect(dialog).toBeVisible();
// Click confirm restore button
const confirmButton = dialog.locator(SELECTORS.confirmRestoreButton);
await confirmButton.click();
// Wait for success toast (API response is already fulfilled by mock)
await waitForToast(page, /restore|success|completed/i, { type: 'success' });
// Verify restore was requested
expect(restoreRequested).toBe(true);
// Dialog should close after successful restore
await expect(dialog).not.toBeVisible({ timeout: 5000 });
});
test('should show success toast after successful restoration', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
const filename = 'backup_2024-01-15_120000.tar.gz';
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
await page.route(`**/api/v1/backups/${filename}/restore`, async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({
status: 200,
json: { message: 'Restore completed successfully' },
});
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Click restore on first backup
const restoreButton = page.locator(SELECTORS.restoreBtn).first();
await restoreButton.click();
// Wait for dialog and confirm
const dialog = page.locator(SELECTORS.confirmDialog);
await expect(dialog).toBeVisible();
const confirmButton = dialog.locator(SELECTORS.confirmRestoreButton);
await confirmButton.click();
// Wait for success toast
await waitForToast(page, /success|restored|completed/i, { type: 'success' });
});
test('should handle restore failure gracefully with error toast', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
const filename = 'backup_2024-01-15_120000.tar.gz';
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
await page.route(`**/api/v1/backups/${filename}/restore`, async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({
status: 500,
json: { error: 'Internal server error: backup file corrupted' },
});
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Click restore on first backup
const restoreButton = page.locator(SELECTORS.restoreBtn).first();
await restoreButton.click();
// Wait for dialog and confirm
const dialog = page.locator(SELECTORS.confirmDialog);
await expect(dialog).toBeVisible();
const confirmButton = dialog.locator(SELECTORS.confirmRestoreButton);
await confirmButton.click();
// Wait for error toast
await waitForToast(page, /error|failed/i, { type: 'error' });
});
});
// =========================================================================
// Edge Cases Tests (2 tests)
// =========================================================================
test.describe('Edge Cases', () => {
test('should disable restore button while restore is in progress', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
const filename = 'backup_2024-01-15_120000.tar.gz';
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
await page.route(`**/api/v1/backups/${filename}/restore`, async (route) => {
if (route.request().method() === 'POST') {
// Delay response to observe loading state
await new Promise((resolve) => setTimeout(resolve, 1000));
await route.fulfill({
status: 200,
json: { message: 'Restore completed successfully' },
});
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Click restore on first backup
const restoreButton = page.locator(SELECTORS.restoreBtn).first();
await restoreButton.click();
// Wait for dialog
const dialog = page.locator(SELECTORS.confirmDialog);
await expect(dialog).toBeVisible();
// Click confirm restore button
const confirmButton = dialog.locator(SELECTORS.confirmRestoreButton);
await confirmButton.click();
// The confirm button should be in loading state (disabled or showing spinner)
// Check if the button shows loading state or is disabled during the request
await expect(confirmButton).toBeDisabled({ timeout: 500 }).catch(() => {
// Button might use isLoading prop instead of disabled attribute
// This is acceptable behavior
});
// Wait for API response
await waitForAPIResponse(page, `/api/v1/backups/${filename}/restore`, { status: 200 });
});
test('should handle restore of corrupted backup with appropriate error message', async ({
page,
adminUser,
}) => {
await loginUser(page, adminUser);
const filename = 'backup_2024-01-15_120000.tar.gz';
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
await page.route(`**/api/v1/backups/${filename}/restore`, async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({
status: 422,
json: { error: 'Backup file is corrupted or invalid' },
});
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Click restore on first backup
const restoreButton = page.locator(SELECTORS.restoreBtn).first();
await restoreButton.click();
// Wait for dialog and confirm
const dialog = page.locator(SELECTORS.confirmDialog);
await expect(dialog).toBeVisible();
const confirmButton = dialog.locator(SELECTORS.confirmRestoreButton);
await confirmButton.click();
// Wait for error toast indicating the backup issue
await waitForToast(page, /error|failed|corrupted|invalid/i, { type: 'error' });
});
});
});

View File

@@ -1,753 +0,0 @@
/**
* Import Caddyfile - E2E Tests
*
* Tests for the Caddyfile import wizard functionality.
* Covers 18 test scenarios as defined in phase5-implementation.md.
*
* Test Categories:
* - Page Layout (2 tests): heading, wizard steps
* - File Upload (4 tests): dropzone, file selection, invalid file, file validation
* - Preview Step (4 tests): preview content, syntax validation, edit preview, warnings
* - Review Step (4 tests): server list, configuration details, select/deselect, validation
* - Import Execution (4 tests): import success, progress, error handling, partial import
*/
import { test, expect, loginUser } from '../fixtures/auth-fixtures';
import {
mockImportAPI,
mockImportPreview,
ImportPreview,
ImportSession,
IMPORT_SELECTORS,
} from '../utils/phase5-helpers';
import { waitForToast, waitForLoadingComplete, waitForAPIResponse } from '../utils/wait-helpers';
/**
* Selectors for the Import Caddyfile page
*/
const SELECTORS = {
// Page elements
pageTitle: 'h1',
dropzone: '[data-testid="import-dropzone"]',
banner: '[data-testid="import-banner"]',
reviewTable: '[data-testid="import-review-table"]',
uploadInput: 'input[type="file"]',
// Buttons
parseButton: 'button:has-text("Parse")',
nextButton: 'button:has-text("Next")',
importButton: 'button:has-text("Import")',
commitButton: 'button:has-text("Commit")',
cancelButton: 'button:has-text("Cancel")',
backButton: 'button:has-text("Back")',
// Text input
pasteTextarea: 'textarea',
// Review table elements
hostRow: 'tbody tr',
hostCheckbox: 'input[type="checkbox"]',
conflictIndicator: '.text-yellow-400',
newIndicator: '.text-green-400',
// Modals
successModal: '[data-testid="import-success-modal"]',
// Error display
errorMessage: '.bg-red-900, .bg-red-900\\/20',
warningMessage: '.bg-yellow-900, .bg-yellow-900\\/20',
// Source view
sourceToggle: 'text=Source Caddyfile Content',
sourceContent: 'pre.font-mono',
};
/**
* Mock Caddyfile content for testing
*/
const mockCaddyfile = `
example.com {
reverse_proxy localhost:3000
}
api.example.com {
reverse_proxy localhost:8080
}
`;
/**
* Mock preview response with valid hosts
*/
const mockPreviewSuccess: ImportPreview = {
session: {
id: 'test-session-123',
state: 'reviewing',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
preview: {
hosts: [
{ domain_names: 'example.com', forward_host: 'localhost', forward_port: 3000, forward_scheme: 'http' },
{ domain_names: 'api.example.com', forward_host: 'localhost', forward_port: 8080, forward_scheme: 'http' },
],
conflicts: [],
errors: [],
},
caddyfile_content: mockCaddyfile,
};
/**
* Mock preview response with conflicts
*/
const mockPreviewWithConflicts: ImportPreview = {
session: {
id: 'test-session-456',
state: 'reviewing',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
preview: {
hosts: [
{ domain_names: 'existing.example.com', forward_host: 'new-server', forward_port: 8080, forward_scheme: 'https' },
],
conflicts: ['existing.example.com'],
errors: [],
},
conflict_details: {
'existing.example.com': {
existing: {
forward_scheme: 'http',
forward_host: 'old-server',
forward_port: 80,
ssl_forced: false,
websocket: false,
enabled: true,
},
imported: {
forward_scheme: 'https',
forward_host: 'new-server',
forward_port: 8080,
ssl_forced: true,
websocket: true,
},
},
},
};
/**
* Mock preview response with warnings/errors
*/
const mockPreviewWithWarnings: ImportPreview = {
session: {
id: 'test-session-789',
state: 'reviewing',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
preview: {
hosts: [
{ domain_names: 'valid.example.com', forward_host: 'server', forward_port: 8080, forward_scheme: 'http' },
],
conflicts: [],
errors: ['Line 10: Invalid directive "invalid_directive"', 'Line 15: Unsupported matcher syntax'],
},
};
/**
* Helper to set up all import API mocks with the specified preview response
* Handles the full flow: initial status (no session) → upload → status (with session) → preview
*/
async function setupImportMocks(
page: import('@playwright/test').Page,
preview: ImportPreview,
options?: { uploadError?: boolean; commitError?: boolean }
) {
let hasSession = false;
// Mock status endpoint - initially no session, then has session after upload
await page.route('**/api/v1/import/status', async (route) => {
if (hasSession) {
await route.fulfill({
status: 200,
json: { has_pending: true, session: preview.session },
});
} else {
await route.fulfill({
status: 200,
json: { has_pending: false },
});
}
});
// Mock upload endpoint - sets hasSession to true on success
await page.route('**/api/v1/import/upload', async (route) => {
if (options?.uploadError) {
await route.fulfill({ status: 400, json: { error: 'Invalid Caddyfile syntax' } });
} else {
hasSession = true;
await route.fulfill({ status: 200, json: preview });
}
});
// Mock preview endpoint
await page.route('**/api/v1/import/preview', async (route) => {
await route.fulfill({ status: 200, json: preview });
});
// Mock commit endpoint
await page.route('**/api/v1/import/commit', async (route) => {
if (options?.commitError) {
await route.fulfill({ status: 500, json: { error: 'Commit failed' } });
} else {
await route.fulfill({ status: 200, json: { created: preview.preview.hosts.length, updated: 0, skipped: 0, errors: [] } });
}
});
// Mock cancel endpoint
await page.route('**/api/v1/import/cancel', async (route) => {
hasSession = false;
await route.fulfill({ status: 200, json: {} });
});
// Mock backups endpoint for pre-import backup
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({
status: 201,
json: { filename: 'pre-import-backup.tar.gz', size: 1000, time: new Date().toISOString() },
});
} else {
await route.continue();
}
});
}
test.describe('Import Caddyfile - Wizard', () => {
// =========================================================================
// Page Layout Tests (2 tests)
// =========================================================================
test.describe('Page Layout', () => {
test('should display import page with correct heading', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
await page.goto('/tasks/import/caddyfile');
await waitForLoadingComplete(page);
await expect(page.locator(SELECTORS.pageTitle)).toContainText(/import/i);
});
test('should show upload section with wizard steps', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
await page.goto('/tasks/import/caddyfile');
await waitForLoadingComplete(page);
// Verify upload section is visible
const dropzone = page.locator(SELECTORS.dropzone);
await expect(dropzone).toBeVisible();
// Verify paste textarea is visible
const textarea = page.locator(SELECTORS.pasteTextarea);
await expect(textarea).toBeVisible();
// Verify parse button exists
const parseButton = page.getByRole('button', { name: /parse|review/i });
await expect(parseButton).toBeVisible();
});
});
// =========================================================================
// File Upload Tests (4 tests)
// =========================================================================
test.describe('File Upload', () => {
test('should display file upload dropzone', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
await page.goto('/tasks/import/caddyfile');
await waitForLoadingComplete(page);
// Verify dropzone/file input is present
const dropzone = page.locator(SELECTORS.dropzone);
await expect(dropzone).toBeVisible();
// Verify it accepts proper file types
const fileInput = page.locator(SELECTORS.uploadInput);
await expect(fileInput).toHaveAttribute('accept', /.caddyfile|.txt|text\/plain/);
});
test('should accept valid Caddyfile via file upload', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Mock import API
await page.route('**/api/v1/import/upload', async (route) => {
await route.fulfill({ status: 200, json: mockPreviewSuccess });
});
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({
status: 201,
json: { filename: 'pre-import-backup.tar.gz', size: 1000, time: new Date().toISOString() },
});
} else {
await route.continue();
}
});
await page.goto('/tasks/import/caddyfile');
await waitForLoadingComplete(page);
// Upload file
const fileInput = page.locator(SELECTORS.uploadInput);
await fileInput.setInputFiles({
name: 'Caddyfile',
mimeType: 'text/plain',
buffer: Buffer.from(mockCaddyfile),
});
// The textarea should now contain the file content
const textarea = page.locator(SELECTORS.pasteTextarea);
await expect(textarea).toHaveValue(/example\.com/);
});
test('should accept valid Caddyfile via paste', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Mock all import API endpoints using the helper
await setupImportMocks(page, mockPreviewSuccess);
await page.goto('/tasks/import/caddyfile');
await waitForLoadingComplete(page);
// Paste content into textarea
const textarea = page.locator(SELECTORS.pasteTextarea);
await textarea.fill(mockCaddyfile);
// Verify content is in textarea
await expect(textarea).toHaveValue(/example\.com/);
// Click parse/review button
const parseButton = page.getByRole('button', { name: /parse|review/i });
await parseButton.click();
// Should show review table after API completes
await expect(page.locator(SELECTORS.reviewTable)).toBeVisible({ timeout: 10000 });
});
test('should show error for empty content submission', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
await page.goto('/tasks/import/caddyfile');
await waitForLoadingComplete(page);
// Ensure textarea is empty
const textarea = page.locator(SELECTORS.pasteTextarea);
await textarea.fill('');
// The parse button should be disabled when content is empty
const parseButton = page.getByRole('button', { name: /parse|review/i });
await expect(parseButton).toBeDisabled();
});
});
// =========================================================================
// Preview Step Tests (4 tests)
// =========================================================================
test.describe('Preview Step', () => {
test('should show parsed hosts from Caddyfile', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Use the complete mock helper to avoid missing endpoints
await setupImportMocks(page, mockPreviewSuccess);
await page.goto('/tasks/import/caddyfile');
await waitForLoadingComplete(page);
// Paste content and submit
await page.locator(SELECTORS.pasteTextarea).fill(mockCaddyfile);
await page.getByRole('button', { name: /parse|review/i }).click();
// Wait for the review table to appear
await expect(page.locator(SELECTORS.reviewTable)).toBeVisible({ timeout: 10000 });
// Verify both hosts are shown
await expect(page.getByText('example.com', { exact: true })).toBeVisible();
await expect(page.getByText('api.example.com', { exact: true })).toBeVisible();
});
test('should show validation errors for invalid Caddyfile syntax', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Mock import API with error
await page.route('**/api/v1/import/upload', async (route) => {
await route.fulfill({
status: 400,
json: { error: 'Invalid Caddyfile syntax at line 5' },
});
});
await page.goto('/tasks/import/caddyfile');
await waitForLoadingComplete(page);
// Paste invalid content
await page.locator(SELECTORS.pasteTextarea).fill('invalid { broken syntax');
await page.getByRole('button', { name: /parse|review/i }).click();
// Should show error message
await expect(page.locator(SELECTORS.errorMessage)).toBeVisible({ timeout: 5000 });
});
test('should display source Caddyfile content in preview', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Set up all import mocks
await setupImportMocks(page, mockPreviewSuccess);
await page.goto('/tasks/import/caddyfile');
await waitForLoadingComplete(page);
// Upload and parse
await page.locator(SELECTORS.pasteTextarea).fill(mockCaddyfile);
await page.getByRole('button', { name: /parse|review/i }).click();
// Wait for review table to appear after API completes
await expect(page.locator(SELECTORS.reviewTable)).toBeVisible({ timeout: 10000 });
// Click to show source content
const sourceToggle = page.locator(SELECTORS.sourceToggle);
if (await sourceToggle.isVisible()) {
await sourceToggle.click();
// Should show the source content
const sourceContent = page.locator('pre');
await expect(sourceContent).toContainText('reverse_proxy');
}
});
test('should show warnings for parsing issues', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Set up import mocks with warnings response
await setupImportMocks(page, mockPreviewWithWarnings);
await page.goto('/tasks/import/caddyfile');
await waitForLoadingComplete(page);
// Paste content with issues
await page.locator(SELECTORS.pasteTextarea).fill('test { invalid }');
await page.getByRole('button', { name: /parse|review/i }).click();
// Should show warning messages - check for error display in review table
await expect(page.locator(SELECTORS.reviewTable)).toBeVisible({ timeout: 5000 });
await expect(page.getByText('Line 10: Invalid directive').first()).toBeVisible();
});
});
// =========================================================================
// Review Step Tests (4 tests)
// =========================================================================
test.describe('Review Step', () => {
test('should display server list with configuration details', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Set up all import mocks
await setupImportMocks(page, mockPreviewSuccess);
await page.goto('/tasks/import/caddyfile');
await waitForLoadingComplete(page);
// Parse content
await page.locator(SELECTORS.pasteTextarea).fill(mockCaddyfile);
await page.getByRole('button', { name: /parse|review/i }).click();
// Verify review table is displayed
const reviewTable = page.locator(SELECTORS.reviewTable);
await expect(reviewTable).toBeVisible({ timeout: 10000 });
// Verify domain names are shown
await expect(page.getByText('example.com', { exact: true })).toBeVisible();
await expect(page.getByText('api.example.com', { exact: true })).toBeVisible();
// Verify "New" status indicators for non-conflicting hosts
await expect(page.locator(SELECTORS.newIndicator)).toHaveCount(2);
});
test('should highlight conflicts with existing hosts', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Set up import mocks with conflicts response
await setupImportMocks(page, mockPreviewWithConflicts);
await page.goto('/tasks/import/caddyfile');
await waitForLoadingComplete(page);
// Parse content
await page.locator(SELECTORS.pasteTextarea).fill('existing.example.com { reverse_proxy new-server:8080 }');
await page.getByRole('button', { name: /parse|review/i }).click();
// Verify conflict indicator is shown
await expect(page.locator(SELECTORS.reviewTable)).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Conflict', { exact: true })).toBeVisible();
});
test('should allow conflict resolution selection', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Set up import mocks with conflicts response
await setupImportMocks(page, mockPreviewWithConflicts);
await page.goto('/tasks/import/caddyfile');
await waitForLoadingComplete(page);
// Parse content
await page.locator(SELECTORS.pasteTextarea).fill('existing.example.com { reverse_proxy new-server:8080 }');
await page.getByRole('button', { name: /parse|review/i }).click();
// Wait for review table to appear after API completes
await expect(page.locator(SELECTORS.reviewTable)).toBeVisible({ timeout: 10000 });
// Verify resolution dropdown exists
const resolutionSelect = page.locator('select').first();
await expect(resolutionSelect).toBeVisible();
// Select "overwrite" option
await resolutionSelect.selectOption('overwrite');
await expect(resolutionSelect).toHaveValue('overwrite');
});
test('should require name for each host before commit', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Set up all import mocks
await setupImportMocks(page, mockPreviewSuccess);
await page.goto('/tasks/import/caddyfile');
await waitForLoadingComplete(page);
// Parse content
await page.locator(SELECTORS.pasteTextarea).fill(mockCaddyfile);
await page.getByRole('button', { name: /parse|review/i }).click();
// Wait for review table
await expect(page.locator(SELECTORS.reviewTable)).toBeVisible({ timeout: 10000 });
// Verify name inputs are present
const nameInputs = page.locator('input[type="text"]');
await expect(nameInputs).toHaveCount(2);
// Clear the first name input
await nameInputs.first().clear();
// Try to commit
await page.locator(SELECTORS.commitButton).click();
// Should show validation error
await expect(page.locator(SELECTORS.errorMessage)).toBeVisible({ timeout: 5000 });
});
});
// =========================================================================
// Import Execution Tests (4 tests)
// =========================================================================
test.describe('Import Execution', () => {
test('should commit import successfully', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
let commitCalled = false;
// Set up all import mocks
await setupImportMocks(page, mockPreviewSuccess);
// Override commit to track if called
await page.route('**/api/v1/import/commit', async (route) => {
commitCalled = true;
await route.fulfill({
status: 200,
json: { created: 2, updated: 0, skipped: 0, errors: [] },
});
});
await page.goto('/tasks/import/caddyfile');
await waitForLoadingComplete(page);
// Parse content
await page.locator(SELECTORS.pasteTextarea).fill(mockCaddyfile);
await page.getByRole('button', { name: /parse|review/i }).click();
// Wait for review table to appear
await expect(page.locator(SELECTORS.reviewTable)).toBeVisible({ timeout: 10000 });
// Click commit button
await page.locator(SELECTORS.commitButton).click();
// Success modal should appear
await expect(page.locator(SELECTORS.successModal)).toBeVisible({ timeout: 10000 });
// Verify commit was called
expect(commitCalled).toBe(true);
});
test('should show progress during import', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Set up all import mocks
await setupImportMocks(page, mockPreviewSuccess);
// Override commit with delay to simulate slow operation
await page.route('**/api/v1/import/commit', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 500));
await route.fulfill({
status: 200,
json: { created: 2, updated: 0, skipped: 0, errors: [] },
});
});
await page.goto('/tasks/import/caddyfile');
await waitForLoadingComplete(page);
// Parse and go to review
await page.locator(SELECTORS.pasteTextarea).fill(mockCaddyfile);
await page.getByRole('button', { name: /parse|review/i }).click();
// Wait for review table
await expect(page.locator(SELECTORS.reviewTable)).toBeVisible({ timeout: 10000 });
// Click commit and verify button shows loading state
const commitButton = page.locator(SELECTORS.commitButton);
await commitButton.click();
// Button should be disabled or show loading text during commit
await expect(commitButton).toBeDisabled();
// Wait for commit to complete
await waitForAPIResponse(page, '/api/v1/import/commit', { status: 200 });
});
test('should handle import errors gracefully', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Set up import mocks with commit error
await setupImportMocks(page, mockPreviewSuccess, { commitError: true });
await page.goto('/tasks/import/caddyfile');
await waitForLoadingComplete(page);
// Parse and go to review
await page.locator(SELECTORS.pasteTextarea).fill(mockCaddyfile);
await page.getByRole('button', { name: /parse|review/i }).click();
// Wait for review table
await expect(page.locator(SELECTORS.reviewTable)).toBeVisible({ timeout: 10000 });
// Click commit
await page.locator(SELECTORS.commitButton).click();
// Should show error message
await expect(page.locator(SELECTORS.errorMessage)).toBeVisible({ timeout: 5000 });
});
test('should handle partial import with some failures', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Set up all import mocks
await setupImportMocks(page, mockPreviewSuccess);
// Override commit to return partial success with errors
await page.route('**/api/v1/import/commit', async (route) => {
await route.fulfill({
status: 200,
json: {
created: 1,
updated: 0,
skipped: 0,
errors: ['Failed to import api.example.com: upstream unreachable'],
},
});
});
await page.goto('/tasks/import/caddyfile');
await waitForLoadingComplete(page);
// Parse and go to review
await page.locator(SELECTORS.pasteTextarea).fill(mockCaddyfile);
await page.getByRole('button', { name: /parse|review/i }).click();
// Wait for review table to appear
await expect(page.locator(SELECTORS.reviewTable)).toBeVisible({ timeout: 10000 });
// Click commit
await page.locator(SELECTORS.commitButton).click();
// Success modal should appear (partial success is still success)
await expect(page.locator(SELECTORS.successModal)).toBeVisible({ timeout: 10000 });
// The modal should show the partial results
// Check for error indicator in success modal
await expect(page.getByText('1 error encountered')).toBeVisible();
});
});
// =========================================================================
// Session Management Tests (2 additional tests)
// =========================================================================
test.describe('Session Management', () => {
test('should show import banner when session exists', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Mock status API to return existing session
await page.route('**/api/v1/import/status', async (route) => {
await route.fulfill({
status: 200,
json: {
has_pending: true,
session: {
id: 'existing-session',
state: 'reviewing',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
},
});
});
await page.route('**/api/v1/import/preview', async (route) => {
await route.fulfill({ status: 200, json: mockPreviewSuccess });
});
await page.goto('/tasks/import/caddyfile');
await waitForLoadingComplete(page);
// Import banner should be visible
await expect(page.locator(SELECTORS.banner)).toBeVisible();
});
test('should allow canceling import session', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
let cancelCalled = false;
// Set up all import mocks
await setupImportMocks(page, mockPreviewSuccess);
// Override cancel to track if called
await page.route('**/api/v1/import/cancel', async (route) => {
cancelCalled = true;
await route.fulfill({ status: 204 });
});
await page.goto('/tasks/import/caddyfile');
await waitForLoadingComplete(page);
// Parse and go to review
await page.locator(SELECTORS.pasteTextarea).fill(mockCaddyfile);
await page.getByRole('button', { name: /parse|review/i }).click();
// Wait for review table
await expect(page.locator(SELECTORS.reviewTable)).toBeVisible({ timeout: 10000 });
// Handle browser confirm dialog (the component uses confirm()) - must register BEFORE clicking
page.on('dialog', async (dialog) => {
await dialog.accept();
});
// Click back/cancel button and confirm in dialog
await page.locator(SELECTORS.backButton).click();
// Verify cancel was called or review table is hidden
await expect(page.locator(SELECTORS.reviewTable)).not.toBeVisible({ timeout: 5000 });
});
});
});

View File

@@ -1,334 +0,0 @@
/**
* Import CrowdSec Configuration - E2E Tests
*
* Tests for the CrowdSec configuration import functionality.
* Covers 8 test scenarios as defined in phase5-implementation.md.
*
* Test Categories:
* - Page Layout (2 tests): heading, form display
* - File Validation (3 tests): valid file, invalid format, missing fields
* - Import Execution (3 tests): import success, error handling, already exists
*/
import { test, expect, loginUser } from '../fixtures/auth-fixtures';
import { waitForToast, waitForLoadingComplete, waitForAPIResponse } from '../utils/wait-helpers';
/**
* Selectors for the Import CrowdSec page
*/
const SELECTORS = {
// Page elements
pageTitle: 'h1',
fileInput: '[data-testid="crowdsec-import-file"]',
progress: '[data-testid="import-progress"]',
// Buttons
importButton: 'button:has-text("Import")',
// Error/success messages
errorMessage: '.bg-red-900',
successToast: '[data-testid="toast-success"]',
};
/**
* Mock CrowdSec configuration for testing
*/
const mockCrowdSecConfig = {
lapi_url: 'http://crowdsec:8080',
bouncer_api_key: 'test-api-key',
mode: 'live',
};
/**
* Helper to create a mock tar.gz file buffer
*/
function createMockTarGzBuffer(): Buffer {
return Buffer.from('mock tar.gz content for crowdsec config');
}
/**
* Helper to create a mock zip file buffer
*/
function createMockZipBuffer(): Buffer {
return Buffer.from('mock zip content for crowdsec config');
}
test.describe('Import CrowdSec Configuration', () => {
// =========================================================================
// Page Layout Tests (2 tests)
// =========================================================================
test.describe('Page Layout', () => {
test('should display import page with correct heading', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
await page.goto('/tasks/import/crowdsec');
await waitForLoadingComplete(page);
await expect(page.locator(SELECTORS.pageTitle)).toContainText(/crowdsec|import/i);
});
test('should show file upload form with accepted formats', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
await page.goto('/tasks/import/crowdsec');
await waitForLoadingComplete(page);
// Verify file input is visible
const fileInput = page.locator(SELECTORS.fileInput);
await expect(fileInput).toBeVisible();
// Verify it accepts proper file types (.tar.gz, .zip)
await expect(fileInput).toHaveAttribute('accept', /\.tar\.gz|\.zip/);
// Verify import button exists
const importButton = page.locator(SELECTORS.importButton);
await expect(importButton).toBeVisible();
// Verify progress section exists
await expect(page.locator(SELECTORS.progress)).toBeVisible();
});
});
// =========================================================================
// File Validation Tests (3 tests)
// =========================================================================
test.describe('File Validation', () => {
test('should accept valid .tar.gz configuration files', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Mock backup and import APIs
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({
status: 201,
json: { filename: 'pre-import-backup.tar.gz', size: 1000, time: new Date().toISOString() },
});
} else {
await route.continue();
}
});
await page.route('**/api/v1/admin/crowdsec/import', async (route) => {
await route.fulfill({
status: 200,
json: { message: 'Import successful' },
});
});
await page.goto('/tasks/import/crowdsec');
await waitForLoadingComplete(page);
// Upload .tar.gz file
const fileInput = page.locator(SELECTORS.fileInput);
await fileInput.setInputFiles({
name: 'crowdsec-config.tar.gz',
mimeType: 'application/gzip',
buffer: createMockTarGzBuffer(),
});
// Verify file was accepted (import button should be enabled)
await expect(page.locator(SELECTORS.importButton)).toBeEnabled();
});
test('should accept valid .zip configuration files', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Mock backup and import APIs
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({
status: 201,
json: { filename: 'pre-import-backup.tar.gz', size: 1000, time: new Date().toISOString() },
});
} else {
await route.continue();
}
});
await page.route('**/api/v1/admin/crowdsec/import', async (route) => {
await route.fulfill({
status: 200,
json: { message: 'Import successful' },
});
});
await page.goto('/tasks/import/crowdsec');
await waitForLoadingComplete(page);
// Upload .zip file
const fileInput = page.locator(SELECTORS.fileInput);
await fileInput.setInputFiles({
name: 'crowdsec-config.zip',
mimeType: 'application/zip',
buffer: createMockZipBuffer(),
});
// Verify file was accepted (import button should be enabled)
await expect(page.locator(SELECTORS.importButton)).toBeEnabled();
});
test('should disable import button when no file selected', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
await page.goto('/tasks/import/crowdsec');
await waitForLoadingComplete(page);
// Import button should be disabled when no file is selected
await expect(page.locator(SELECTORS.importButton)).toBeDisabled();
});
});
// =========================================================================
// Import Execution Tests (3 tests)
// =========================================================================
test.describe('Import Execution', () => {
test('should create backup before import and complete successfully', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
let backupCalled = false;
let importCalled = false;
const callOrder: string[] = [];
// Mock backup API
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'POST') {
backupCalled = true;
callOrder.push('backup');
await route.fulfill({
status: 201,
json: { filename: 'pre-import-backup.tar.gz', size: 1000, time: new Date().toISOString() },
});
} else {
await route.continue();
}
});
// Mock import API
await page.route('**/api/v1/admin/crowdsec/import', async (route) => {
importCalled = true;
callOrder.push('import');
await route.fulfill({
status: 200,
json: { message: 'CrowdSec configuration imported successfully' },
});
});
await page.goto('/tasks/import/crowdsec');
await waitForLoadingComplete(page);
// Upload file
const fileInput = page.locator(SELECTORS.fileInput);
await fileInput.setInputFiles({
name: 'crowdsec-config.tar.gz',
mimeType: 'application/gzip',
buffer: createMockTarGzBuffer(),
});
// Click import button and wait for import API response concurrently
await Promise.all([
page.waitForResponse(r => r.url().includes('/api/v1/admin/crowdsec/import') && r.status() === 200),
page.locator(SELECTORS.importButton).click(),
]);
// Verify backup was called FIRST, then import
expect(backupCalled).toBe(true);
expect(importCalled).toBe(true);
expect(callOrder).toEqual(['backup', 'import']);
// Verify success toast - use specific text match
await expect(page.getByText('CrowdSec config imported')).toBeVisible({ timeout: 10000 });
});
test('should handle import errors gracefully', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Mock backup API (success)
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({
status: 201,
json: { filename: 'pre-import-backup.tar.gz', size: 1000, time: new Date().toISOString() },
});
} else {
await route.continue();
}
});
// Mock import API (failure)
await page.route('**/api/v1/admin/crowdsec/import', async (route) => {
await route.fulfill({
status: 400,
json: { error: 'Invalid configuration format: missing required field "lapi_url"' },
});
});
await page.goto('/tasks/import/crowdsec');
await waitForLoadingComplete(page);
// Upload file
const fileInput = page.locator(SELECTORS.fileInput);
await fileInput.setInputFiles({
name: 'crowdsec-config.tar.gz',
mimeType: 'application/gzip',
buffer: createMockTarGzBuffer(),
});
// Click import button and wait for import API response concurrently
await Promise.all([
page.waitForResponse(r => r.url().includes('/api/v1/admin/crowdsec/import') && r.status() === 400),
page.locator(SELECTORS.importButton).click(),
]);
// Verify error toast - use specific text match
await expect(page.getByText(/Import failed/i)).toBeVisible({ timeout: 10000 });
});
test('should show loading state during import', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
// Mock backup API with delay
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'POST') {
await new Promise((resolve) => setTimeout(resolve, 300));
await route.fulfill({
status: 201,
json: { filename: 'pre-import-backup.tar.gz', size: 1000, time: new Date().toISOString() },
});
} else {
await route.continue();
}
});
// Mock import API with delay
await page.route('**/api/v1/admin/crowdsec/import', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 500));
await route.fulfill({
status: 200,
json: { message: 'Import successful' },
});
});
await page.goto('/tasks/import/crowdsec');
await waitForLoadingComplete(page);
// Upload file
const fileInput = page.locator(SELECTORS.fileInput);
await fileInput.setInputFiles({
name: 'crowdsec-config.tar.gz',
mimeType: 'application/gzip',
buffer: createMockTarGzBuffer(),
});
// Set up response promise before clicking to capture loading state
const importResponsePromise = page.waitForResponse(r => r.url().includes('/api/v1/admin/crowdsec/import') && r.status() === 200);
// Click import button
const importButton = page.locator(SELECTORS.importButton);
await importButton.click();
// Button should be disabled during import (loading state)
await expect(importButton).toBeDisabled();
// Wait for import to complete
await importResponsePromise;
// Button should be enabled again after completion
await expect(importButton).toBeEnabled();
});
});
});

View File

@@ -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);
});
});
});