chore: clean .gitignore cache
This commit is contained in:
@@ -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.
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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