chore: clean .gitignore cache
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,451 +0,0 @@
|
||||
/**
|
||||
* Authentication E2E Tests
|
||||
*
|
||||
* Tests the authentication flows for the Charon application including:
|
||||
* - Login with valid credentials
|
||||
* - Login with invalid credentials
|
||||
* - Login with non-existent user
|
||||
* - Logout functionality
|
||||
* - Session persistence across page refreshes
|
||||
* - Session expiration handling
|
||||
*
|
||||
* These tests use per-test user fixtures to ensure isolation and avoid
|
||||
* race conditions in parallel execution.
|
||||
*
|
||||
* @see /projects/Charon/docs/plans/current_spec.md - Section 4.1.2
|
||||
*/
|
||||
|
||||
import { test, expect, loginUser, logoutUser, TEST_PASSWORD } from '../fixtures/auth-fixtures';
|
||||
import { waitForToast, waitForLoadingComplete, waitForAPIResponse } from '../utils/wait-helpers';
|
||||
|
||||
test.describe('Authentication Flows', () => {
|
||||
test.describe('Login with Valid Credentials', () => {
|
||||
/**
|
||||
* Test: Successful login redirects to dashboard
|
||||
* Verifies that a user with valid credentials can log in and is redirected
|
||||
* to the main dashboard.
|
||||
*/
|
||||
test('should login with valid credentials and redirect to dashboard', async ({
|
||||
page,
|
||||
adminUser,
|
||||
}) => {
|
||||
await test.step('Navigate to login page', async () => {
|
||||
await page.goto('/login');
|
||||
// Verify login page is loaded by checking for the email input field
|
||||
await expect(page.locator('input[type="email"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Enter valid credentials', async () => {
|
||||
await page.locator('input[type="email"]').fill(adminUser.email);
|
||||
await page.locator('input[type="password"]').fill(TEST_PASSWORD);
|
||||
});
|
||||
|
||||
await test.step('Submit login form', async () => {
|
||||
const responsePromise = waitForAPIResponse(page, '/api/v1/auth/login', { status: 200 });
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
await responsePromise;
|
||||
});
|
||||
|
||||
await test.step('Verify redirect to dashboard', async () => {
|
||||
await page.waitForURL('/');
|
||||
await waitForLoadingComplete(page);
|
||||
// Dashboard should show main content area
|
||||
await expect(page.getByRole('main')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Login form shows loading state during authentication
|
||||
*/
|
||||
test('should show loading state during authentication', async ({ page, adminUser }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
await page.locator('input[type="email"]').fill(adminUser.email);
|
||||
await page.locator('input[type="password"]').fill(TEST_PASSWORD);
|
||||
|
||||
await test.step('Verify loading state on submit', async () => {
|
||||
const loginButton = page.getByRole('button', { name: /sign in/i });
|
||||
await loginButton.click();
|
||||
|
||||
// Button should be disabled or show loading indicator during request
|
||||
await expect(loginButton).toBeDisabled().catch(() => {
|
||||
// Some implementations use loading spinner instead
|
||||
});
|
||||
});
|
||||
|
||||
await page.waitForURL('/');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Login with Invalid Credentials', () => {
|
||||
/**
|
||||
* Test: Wrong password shows error message
|
||||
* Verifies that entering an incorrect password displays an appropriate error.
|
||||
*/
|
||||
test('should show error message for wrong password', async ({ page, adminUser }) => {
|
||||
await test.step('Navigate to login page', async () => {
|
||||
await page.goto('/login');
|
||||
});
|
||||
|
||||
await test.step('Enter valid email with wrong password', async () => {
|
||||
await page.locator('input[type="email"]').fill(adminUser.email);
|
||||
await page.locator('input[type="password"]').fill('WrongPassword123!');
|
||||
});
|
||||
|
||||
await test.step('Submit and verify error', async () => {
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
|
||||
// Wait for error message to appear
|
||||
const errorMessage = page
|
||||
.getByRole('alert')
|
||||
.or(page.getByText(/invalid|incorrect|wrong|failed/i));
|
||||
await expect(errorMessage).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
await test.step('Verify user stays on login page', async () => {
|
||||
await expect(page).toHaveURL(/login/);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Empty password shows validation error
|
||||
*/
|
||||
test('should show validation error for empty password', async ({ page, adminUser }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
await page.locator('input[type="email"]').fill(adminUser.email);
|
||||
// Leave password empty
|
||||
|
||||
await test.step('Submit and verify validation error', async () => {
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
|
||||
// Check for HTML5 validation state, aria-invalid, or visible error text
|
||||
const passwordInput = page.locator('input[type="password"]');
|
||||
const isInvalid =
|
||||
(await passwordInput.getAttribute('aria-invalid')) === 'true' ||
|
||||
(await passwordInput.evaluate((el: HTMLInputElement) => !el.validity.valid)) ||
|
||||
(await page.getByText(/password.*required|required.*password/i).isVisible().catch(() => false));
|
||||
|
||||
expect(isInvalid).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Login with Non-existent User', () => {
|
||||
/**
|
||||
* Test: Unknown email shows error message
|
||||
* Verifies that a non-existent user email displays an appropriate error.
|
||||
*/
|
||||
test('should show error message for non-existent user', async ({ page }) => {
|
||||
await test.step('Navigate to login page', async () => {
|
||||
await page.goto('/login');
|
||||
});
|
||||
|
||||
await test.step('Enter non-existent email', async () => {
|
||||
const nonExistentEmail = `nonexistent-${Date.now()}@test.local`;
|
||||
await page.locator('input[type="email"]').fill(nonExistentEmail);
|
||||
await page.locator('input[type="password"]').fill('SomePassword123!');
|
||||
});
|
||||
|
||||
await test.step('Submit and verify error', async () => {
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
|
||||
// Wait for error message - should not reveal if user exists
|
||||
const errorMessage = page
|
||||
.getByRole('alert')
|
||||
.or(page.getByText(/invalid|incorrect|not found|failed/i));
|
||||
await expect(errorMessage).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
await test.step('Verify user stays on login page', async () => {
|
||||
await expect(page).toHaveURL(/login/);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Invalid email format shows validation error
|
||||
*/
|
||||
test('should show validation error for invalid email format', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
await page.locator('input[type="email"]').fill('not-an-email');
|
||||
await page.locator('input[type="password"]').fill('SomePassword123!');
|
||||
|
||||
await test.step('Verify email validation error', async () => {
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
|
||||
const emailInput = page.locator('input[type="email"]');
|
||||
|
||||
// Check for HTML5 validation state or aria-invalid or visible error text
|
||||
const isInvalid =
|
||||
(await emailInput.getAttribute('aria-invalid')) === 'true' ||
|
||||
(await emailInput.evaluate((el: HTMLInputElement) => !el.validity.valid)) ||
|
||||
(await page.getByText(/valid email|invalid email/i).isVisible().catch(() => false));
|
||||
|
||||
expect(isInvalid).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Logout Functionality', () => {
|
||||
/**
|
||||
* Test: Logout redirects to login page and clears session
|
||||
* Verifies that clicking logout properly ends the session.
|
||||
*/
|
||||
test('should logout and redirect to login page', async ({ page, adminUser }) => {
|
||||
await test.step('Login first', async () => {
|
||||
await loginUser(page, adminUser);
|
||||
await expect(page).toHaveURL('/');
|
||||
});
|
||||
|
||||
await test.step('Perform logout', async () => {
|
||||
await logoutUser(page);
|
||||
});
|
||||
|
||||
await test.step('Verify redirect to login page', async () => {
|
||||
await expect(page).toHaveURL(/login/);
|
||||
});
|
||||
|
||||
await test.step('Verify session is cleared - cannot access protected route', async () => {
|
||||
await page.goto('/');
|
||||
// Should redirect to login since session is cleared
|
||||
await expect(page).toHaveURL(/login/);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Logout clears authentication cookies
|
||||
*/
|
||||
test('should clear authentication cookies on logout', async ({ page, adminUser }) => {
|
||||
await loginUser(page, adminUser);
|
||||
|
||||
await test.step('Verify auth cookie exists before logout', async () => {
|
||||
const cookies = await page.context().cookies();
|
||||
const hasAuthCookie = cookies.some(
|
||||
(c) => c.name.includes('token') || c.name.includes('auth') || c.name.includes('session')
|
||||
);
|
||||
// Some implementations use localStorage instead of cookies
|
||||
if (!hasAuthCookie) {
|
||||
const localStorageToken = await page.evaluate(() =>
|
||||
localStorage.getItem('token') || localStorage.getItem('authToken')
|
||||
);
|
||||
expect(localStorageToken || hasAuthCookie).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
await logoutUser(page);
|
||||
|
||||
await test.step('Verify auth data cleared after logout', async () => {
|
||||
const cookies = await page.context().cookies();
|
||||
const hasAuthCookie = cookies.some(
|
||||
(c) =>
|
||||
(c.name.includes('token') || c.name.includes('auth') || c.name.includes('session')) &&
|
||||
c.value !== ''
|
||||
);
|
||||
|
||||
const localStorageToken = await page.evaluate(() =>
|
||||
localStorage.getItem('token') || localStorage.getItem('authToken')
|
||||
);
|
||||
|
||||
expect(hasAuthCookie && localStorageToken).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Session Persistence', () => {
|
||||
/**
|
||||
* Test: Session persists across page refresh
|
||||
* Verifies that refreshing the page maintains the logged-in state.
|
||||
*/
|
||||
test('should maintain session after page refresh', async ({ page, adminUser }) => {
|
||||
await test.step('Login', async () => {
|
||||
await loginUser(page, adminUser);
|
||||
await expect(page).toHaveURL('/');
|
||||
});
|
||||
|
||||
await test.step('Refresh page', async () => {
|
||||
await page.reload();
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
await test.step('Verify still logged in', async () => {
|
||||
// Should still be on dashboard, not redirected to login
|
||||
await expect(page).toHaveURL('/');
|
||||
await expect(page.getByRole('main')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Session persists when navigating between pages
|
||||
*/
|
||||
test('should maintain session when navigating between pages', async ({ page, adminUser }) => {
|
||||
await loginUser(page, adminUser);
|
||||
|
||||
await test.step('Navigate to different pages', async () => {
|
||||
// Navigate to proxy hosts
|
||||
const proxyHostsLink = page.getByRole('link', { name: /proxy.*hosts?/i });
|
||||
if (await proxyHostsLink.isVisible()) {
|
||||
await proxyHostsLink.click();
|
||||
await waitForLoadingComplete(page);
|
||||
await expect(page.getByRole('main')).toBeVisible();
|
||||
}
|
||||
|
||||
// Navigate back to dashboard
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
await test.step('Verify still logged in', async () => {
|
||||
await expect(page).toHaveURL('/');
|
||||
await expect(page.getByRole('main')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Session Expiration Handling', () => {
|
||||
/**
|
||||
* Test: Expired token redirects to login
|
||||
* Simulates an expired session and verifies proper redirect.
|
||||
*/
|
||||
test('should redirect to login when session expires', async ({ page, adminUser }) => {
|
||||
await loginUser(page, adminUser);
|
||||
|
||||
await test.step('Simulate session expiration by clearing auth data', async () => {
|
||||
// Clear cookies and storage to simulate expiration
|
||||
await page.context().clearCookies();
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('authToken');
|
||||
localStorage.removeItem('charon_auth_token');
|
||||
sessionStorage.clear();
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Attempt to access protected resource', async () => {
|
||||
await page.reload();
|
||||
});
|
||||
|
||||
await test.step('Verify redirect to login', async () => {
|
||||
await expect(page).toHaveURL(/login/, { timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: API returns 401 on expired token, UI handles gracefully
|
||||
*/
|
||||
test('should handle 401 response gracefully', async ({ page, adminUser }) => {
|
||||
await loginUser(page, adminUser);
|
||||
|
||||
await test.step('Intercept API calls to return 401', async () => {
|
||||
await page.route('**/api/v1/**', async (route) => {
|
||||
// Let health check through, block others with 401
|
||||
if (route.request().url().includes('/health')) {
|
||||
await route.continue();
|
||||
} else {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ message: 'Token expired' }),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Trigger an API call by navigating', async () => {
|
||||
await page.goto('/proxy-hosts');
|
||||
// Wait for the 401 response to be processed and UI to react
|
||||
await page.waitForTimeout(2000);
|
||||
});
|
||||
|
||||
await test.step('Verify redirect to login or error message', async () => {
|
||||
// Wait for potential redirect to login page
|
||||
try {
|
||||
await page.waitForURL(/\/login/, { timeout: 5000 });
|
||||
} catch {
|
||||
// If no redirect, check for session expired message
|
||||
}
|
||||
|
||||
// Should either redirect to login or show session expired message
|
||||
const isLoginPage = page.url().includes('/login');
|
||||
const hasSessionExpiredMessage = await page
|
||||
.getByText(/session.*expired|please.*login|unauthorized/i)
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
expect(isLoginPage || hasSessionExpiredMessage).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Authentication Accessibility', () => {
|
||||
/**
|
||||
* Test: Login form is keyboard accessible
|
||||
*/
|
||||
test('should be fully keyboard navigable', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
await test.step('Tab through form elements and verify focus order', async () => {
|
||||
const emailInput = page.locator('input[type="email"]');
|
||||
const passwordInput = page.locator('input[type="password"]');
|
||||
const submitButton = page.getByRole('button', { name: /sign in/i });
|
||||
|
||||
// Focus the email input first
|
||||
await emailInput.focus();
|
||||
await expect(emailInput).toBeFocused();
|
||||
|
||||
// Tab to password field
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(passwordInput).toBeFocused();
|
||||
|
||||
// Tab to submit button (may go through "Forgot Password" link first)
|
||||
await page.keyboard.press('Tab');
|
||||
// If there's a "Forgot Password" link, tab again
|
||||
if (!(await submitButton.evaluate((el) => el === document.activeElement))) {
|
||||
await page.keyboard.press('Tab');
|
||||
}
|
||||
await expect(submitButton).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Login form has proper ARIA labels
|
||||
*/
|
||||
test('should have accessible form labels', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
await test.step('Verify email input is visible', async () => {
|
||||
const emailInput = page.locator('input[type="email"]');
|
||||
await expect(emailInput).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify password input is visible', async () => {
|
||||
const passwordInput = page.locator('input[type="password"]');
|
||||
await expect(passwordInput).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify submit button has accessible name', async () => {
|
||||
const submitButton = page.getByRole('button', { name: /sign in/i });
|
||||
await expect(submitButton).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Error messages are announced to screen readers
|
||||
*/
|
||||
test('should announce errors to screen readers', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
await page.locator('input[type="email"]').fill('test@example.com');
|
||||
await page.locator('input[type="password"]').fill('wrongpassword');
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
|
||||
await test.step('Verify error has proper ARIA role', async () => {
|
||||
const errorAlert = page.locator('[role="alert"], [aria-live="polite"], [aria-live="assertive"]');
|
||||
// Wait for error to appear
|
||||
await expect(errorAlert).toBeVisible({ timeout: 10000 }).catch(() => {
|
||||
// Some implementations may not use live regions
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,549 +0,0 @@
|
||||
/**
|
||||
* Dashboard E2E Tests
|
||||
*
|
||||
* Tests the main dashboard functionality including:
|
||||
* - Dashboard loads successfully with main elements visible
|
||||
* - Summary cards display data correctly
|
||||
* - Quick action buttons navigate to correct pages
|
||||
* - Recent activity displays changes
|
||||
* - System status indicators are visible
|
||||
* - Empty state handling for new installations
|
||||
*
|
||||
* @see /projects/Charon/docs/plans/current_spec.md - Section 4.1.2
|
||||
*/
|
||||
|
||||
import { test, expect, loginUser } from '../fixtures/auth-fixtures';
|
||||
import { waitForLoadingComplete, waitForTableLoad } from '../utils/wait-helpers';
|
||||
|
||||
test.describe('Dashboard', () => {
|
||||
test.beforeEach(async ({ page, adminUser }) => {
|
||||
await loginUser(page, adminUser);
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
test.describe('Dashboard Loads Successfully', () => {
|
||||
/**
|
||||
* Test: Dashboard main content area is visible
|
||||
* Verifies that the dashboard loads without errors and displays main content.
|
||||
*/
|
||||
test('should display main dashboard content area', async ({ page }) => {
|
||||
await test.step('Navigate to dashboard', async () => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
await test.step('Verify main content area exists', async () => {
|
||||
await expect(page.getByRole('main')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify no error messages displayed', async () => {
|
||||
const errorAlert = page.getByRole('alert').filter({ hasText: /error|failed/i });
|
||||
await expect(errorAlert).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Dashboard has proper page title
|
||||
*/
|
||||
test('should have proper page title', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await test.step('Verify page title', async () => {
|
||||
const title = await page.title();
|
||||
expect(title).toBeTruthy();
|
||||
expect(title.toLowerCase()).toMatch(/charon|dashboard|home/i);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Dashboard header is visible
|
||||
*/
|
||||
test('should display dashboard header with navigation', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
// Wait for network idle to ensure all assets are loaded in parallel runs
|
||||
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
|
||||
|
||||
await test.step('Verify header/navigation exists', async () => {
|
||||
// Check for visible page structure elements
|
||||
const header = page.locator('header').first();
|
||||
const nav = page.getByRole('navigation').first();
|
||||
const sidebar = page.locator('[class*="sidebar"]').first();
|
||||
const main = page.getByRole('main');
|
||||
const links = page.locator('a[href]');
|
||||
|
||||
const hasHeader = await header.isVisible().catch(() => false);
|
||||
const hasNav = await nav.isVisible().catch(() => false);
|
||||
const hasSidebar = await sidebar.isVisible().catch(() => false);
|
||||
const hasMain = await main.isVisible().catch(() => false);
|
||||
const hasLinks = (await links.count()) > 0;
|
||||
|
||||
// App should have some form of structure
|
||||
expect(hasHeader || hasNav || hasSidebar || hasMain || hasLinks).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Summary Cards Display Data', () => {
|
||||
/**
|
||||
* Test: Proxy hosts count card is displayed
|
||||
* Verifies that the summary card showing proxy hosts count is visible.
|
||||
*/
|
||||
test('should display proxy hosts summary card', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Verify proxy hosts card exists', async () => {
|
||||
const proxyCard = page
|
||||
.getByRole('region', { name: /proxy.*hosts?/i })
|
||||
.or(page.locator('[data-testid*="proxy"]'))
|
||||
.or(page.getByText(/proxy.*hosts?/i).first());
|
||||
|
||||
if (await proxyCard.isVisible().catch(() => false)) {
|
||||
await expect(proxyCard).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Certificates count card is displayed
|
||||
*/
|
||||
test('should display certificates summary card', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Verify certificates card exists', async () => {
|
||||
const certCard = page
|
||||
.getByRole('region', { name: /certificates?/i })
|
||||
.or(page.locator('[data-testid*="certificate"]'))
|
||||
.or(page.getByText(/certificates?/i).first());
|
||||
|
||||
if (await certCard.isVisible().catch(() => false)) {
|
||||
await expect(certCard).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Summary cards show numeric values
|
||||
*/
|
||||
test('should display numeric counts in summary cards', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Verify cards contain numbers', async () => {
|
||||
// Look for elements that typically display counts
|
||||
const countElements = page.locator(
|
||||
'[class*="count"], [class*="stat"], [class*="number"], [class*="value"]'
|
||||
);
|
||||
|
||||
if ((await countElements.count()) > 0) {
|
||||
const firstCount = countElements.first();
|
||||
const text = await firstCount.textContent();
|
||||
// Should contain a number (0 or more)
|
||||
expect(text).toMatch(/\d+/);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Quick Action Buttons', () => {
|
||||
/**
|
||||
* Test: "Add Proxy Host" button navigates correctly
|
||||
*/
|
||||
test('should navigate to add proxy host when clicking quick action', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Find and click Add Proxy Host button', async () => {
|
||||
const addProxyButton = page
|
||||
.getByRole('button', { name: /add.*proxy/i })
|
||||
.or(page.getByRole('link', { name: /add.*proxy/i }))
|
||||
.first();
|
||||
|
||||
if (await addProxyButton.isVisible().catch(() => false)) {
|
||||
await addProxyButton.click();
|
||||
|
||||
await test.step('Verify navigation to proxy hosts or dialog opens', async () => {
|
||||
// Either navigates to proxy-hosts page or opens a dialog
|
||||
const isOnProxyPage = page.url().includes('proxy');
|
||||
const hasDialog = await page.getByRole('dialog').isVisible().catch(() => false);
|
||||
|
||||
expect(isOnProxyPage || hasDialog).toBeTruthy();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: "Add Certificate" button navigates correctly
|
||||
*/
|
||||
test('should navigate to add certificate when clicking quick action', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Find and click Add Certificate button', async () => {
|
||||
const addCertButton = page
|
||||
.getByRole('button', { name: /add.*certificate/i })
|
||||
.or(page.getByRole('link', { name: /add.*certificate/i }))
|
||||
.first();
|
||||
|
||||
if (await addCertButton.isVisible().catch(() => false)) {
|
||||
await addCertButton.click();
|
||||
|
||||
await test.step('Verify navigation to certificates or dialog opens', async () => {
|
||||
const isOnCertPage = page.url().includes('certificate');
|
||||
const hasDialog = await page.getByRole('dialog').isVisible().catch(() => false);
|
||||
|
||||
expect(isOnCertPage || hasDialog).toBeTruthy();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Quick action buttons are keyboard accessible
|
||||
*/
|
||||
test('should make quick action buttons keyboard accessible', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Tab to quick action buttons', async () => {
|
||||
// Tab through page to find quick action buttons with limited iterations
|
||||
let foundButton = false;
|
||||
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await page.keyboard.press('Tab');
|
||||
const focused = page.locator(':focus');
|
||||
|
||||
// Check if element exists and is visible before getting text
|
||||
if (await focused.count() > 0 && await focused.isVisible().catch(() => false)) {
|
||||
const focusedText = await focused.textContent().catch(() => '');
|
||||
|
||||
if (focusedText?.match(/add.*proxy|add.*certificate|new/i)) {
|
||||
foundButton = true;
|
||||
await expect(focused).toBeFocused();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Quick action buttons may not exist or be reachable - this is acceptable
|
||||
expect(foundButton || true).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Recent Activity', () => {
|
||||
/**
|
||||
* Test: Recent activity section is displayed
|
||||
*/
|
||||
test('should display recent activity section', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Verify activity section exists', async () => {
|
||||
const activitySection = page
|
||||
.getByRole('region', { name: /activity|recent|log/i })
|
||||
.or(page.getByText(/recent.*activity|activity.*log/i))
|
||||
.or(page.locator('[data-testid*="activity"]'));
|
||||
|
||||
// Activity section may not exist on all dashboard implementations
|
||||
if (await activitySection.isVisible().catch(() => false)) {
|
||||
await expect(activitySection).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Activity items show timestamp and description
|
||||
*/
|
||||
test('should display activity items with details', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Verify activity items have content', async () => {
|
||||
const activityItems = page.locator(
|
||||
'[class*="activity-item"], [class*="log-entry"], [data-testid*="activity-item"]'
|
||||
);
|
||||
|
||||
if ((await activityItems.count()) > 0) {
|
||||
const firstItem = activityItems.first();
|
||||
const text = await firstItem.textContent();
|
||||
|
||||
// Activity items typically contain text
|
||||
expect(text?.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('System Status Indicators', () => {
|
||||
/**
|
||||
* Test: System health status is visible
|
||||
*/
|
||||
test('should display system health status indicator', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Verify health status indicator exists', async () => {
|
||||
const healthIndicator = page
|
||||
.getByRole('status')
|
||||
.or(page.getByText(/healthy|online|running|status/i))
|
||||
.or(page.locator('[class*="health"], [class*="status"]'))
|
||||
.first();
|
||||
|
||||
if (await healthIndicator.isVisible().catch(() => false)) {
|
||||
await expect(healthIndicator).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Database connection status is visible
|
||||
*/
|
||||
test('should display database status', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Verify database status is shown', async () => {
|
||||
const dbStatus = page
|
||||
.getByText(/database|db.*connected|storage/i)
|
||||
.or(page.locator('[data-testid*="database"]'))
|
||||
.first();
|
||||
|
||||
// Database status may be part of a health section
|
||||
if (await dbStatus.isVisible().catch(() => false)) {
|
||||
await expect(dbStatus).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Status indicators use appropriate colors
|
||||
*/
|
||||
test('should use appropriate status colors', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Verify status uses visual indicators', async () => {
|
||||
// Look for success/healthy indicators (usually green)
|
||||
const successIndicator = page.locator(
|
||||
'[class*="success"], [class*="healthy"], [class*="online"], [class*="green"]'
|
||||
);
|
||||
|
||||
// Or warning/error indicators
|
||||
const warningIndicator = page.locator(
|
||||
'[class*="warning"], [class*="error"], [class*="offline"], [class*="red"], [class*="yellow"]'
|
||||
);
|
||||
|
||||
const hasVisualIndicator =
|
||||
(await successIndicator.count()) > 0 || (await warningIndicator.count()) > 0;
|
||||
|
||||
// Visual indicators may not be present in all implementations
|
||||
expect(hasVisualIndicator).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Empty State Handling', () => {
|
||||
/**
|
||||
* Test: Dashboard handles empty state gracefully
|
||||
* For new installations without any proxy hosts or certificates.
|
||||
*/
|
||||
test('should display helpful empty state message', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Check for empty state or content', async () => {
|
||||
// Either shows empty state message or actual content
|
||||
const emptyState = page
|
||||
.getByText(/no.*proxy|get.*started|add.*first|empty/i)
|
||||
.or(page.locator('[class*="empty-state"]'));
|
||||
|
||||
const hasContent = page.locator('[class*="card"], [class*="host"], [class*="item"]');
|
||||
|
||||
const hasEmptyState = await emptyState.isVisible().catch(() => false);
|
||||
const hasActualContent = (await hasContent.count()) > 0;
|
||||
|
||||
// Dashboard should show either empty state or content, not crash
|
||||
expect(hasEmptyState || hasActualContent || true).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Empty state provides action to add first item
|
||||
*/
|
||||
test('should provide action button in empty state', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Verify empty state has call-to-action', async () => {
|
||||
const emptyState = page.locator('[class*="empty-state"], [class*="empty"]').first();
|
||||
|
||||
if (await emptyState.isVisible().catch(() => false)) {
|
||||
const ctaButton = emptyState.getByRole('button').or(emptyState.getByRole('link'));
|
||||
await expect(ctaButton).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Dashboard Accessibility', () => {
|
||||
/**
|
||||
* Test: Dashboard has proper heading structure
|
||||
*/
|
||||
test('should have proper heading hierarchy', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
await page.waitForTimeout(300); // Allow content to fully render
|
||||
|
||||
await test.step('Verify heading structure', async () => {
|
||||
// Check for any semantic structure on the page
|
||||
const h1Count = await page.locator('h1').count();
|
||||
const h2Count = await page.locator('h2').count();
|
||||
const h3Count = await page.locator('h3').count();
|
||||
const anyHeading = await page.getByRole('heading').count();
|
||||
|
||||
// Check for visually styled headings or title elements
|
||||
const hasTitleElements = await page.locator('[class*="title"]').count() > 0;
|
||||
const hasCards = await page.locator('[class*="card"]').count() > 0;
|
||||
const hasMain = await page.getByRole('main').isVisible().catch(() => false);
|
||||
const hasLinks = await page.locator('a[href]').count() > 0;
|
||||
|
||||
// Dashboard may use cards/titles instead of traditional headings
|
||||
// Pass if any meaningful structure exists
|
||||
const hasHeadingStructure = h1Count > 0 || h2Count > 0 || h3Count > 0 || anyHeading > 0;
|
||||
const hasOtherStructure = hasTitleElements || hasCards || hasMain || hasLinks;
|
||||
|
||||
expect(hasHeadingStructure || hasOtherStructure).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Dashboard sections have proper landmarks
|
||||
*/
|
||||
test('should use semantic landmarks', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Verify landmark regions exist', async () => {
|
||||
// Check for main landmark
|
||||
const main = page.getByRole('main');
|
||||
await expect(main).toBeVisible();
|
||||
|
||||
// Check for navigation landmark
|
||||
const nav = page.getByRole('navigation');
|
||||
if (await nav.isVisible().catch(() => false)) {
|
||||
await expect(nav).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Dashboard cards are accessible
|
||||
*/
|
||||
test('should make summary cards keyboard accessible', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Verify cards are reachable via keyboard', async () => {
|
||||
// Tab through dashboard with limited iterations to avoid timeout
|
||||
let reachedCard = false;
|
||||
let focusableElementsFound = 0;
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await page.keyboard.press('Tab');
|
||||
const focused = page.locator(':focus');
|
||||
|
||||
// Check if any element is focused
|
||||
if (await focused.count() > 0 && await focused.isVisible().catch(() => false)) {
|
||||
focusableElementsFound++;
|
||||
|
||||
// Check if focused element is within a card
|
||||
const isInCard = await focused
|
||||
.locator('xpath=ancestor::*[contains(@class, "card")]')
|
||||
.count()
|
||||
.catch(() => 0);
|
||||
|
||||
if (isInCard > 0) {
|
||||
reachedCard = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cards may not have focusable elements - verify we at least found some focusable elements
|
||||
expect(reachedCard || focusableElementsFound > 0 || true).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Status indicators have accessible text
|
||||
*/
|
||||
test('should provide accessible text for status indicators', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Verify status has accessible text', async () => {
|
||||
const statusElement = page
|
||||
.getByRole('status')
|
||||
.or(page.locator('[aria-label*="status"]'))
|
||||
.first();
|
||||
|
||||
if (await statusElement.isVisible().catch(() => false)) {
|
||||
// Should have text content or aria-label
|
||||
const text = await statusElement.textContent();
|
||||
const ariaLabel = await statusElement.getAttribute('aria-label');
|
||||
|
||||
expect(text?.length || ariaLabel?.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Dashboard Performance', () => {
|
||||
/**
|
||||
* Test: Dashboard loads within acceptable time
|
||||
*/
|
||||
test('should load dashboard within 5 seconds', async ({ page }) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
const loadTime = Date.now() - startTime;
|
||||
|
||||
await test.step('Verify load time is acceptable', async () => {
|
||||
// Dashboard should load within 5 seconds
|
||||
expect(loadTime).toBeLessThan(5000);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: No console errors on dashboard load
|
||||
*/
|
||||
test('should not have console errors on load', async ({ page }) => {
|
||||
const consoleErrors: string[] = [];
|
||||
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
consoleErrors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Verify no JavaScript errors', async () => {
|
||||
// Filter out known acceptable errors
|
||||
const significantErrors = consoleErrors.filter(
|
||||
(error) =>
|
||||
!error.includes('favicon') && !error.includes('ResizeObserver') && !error.includes('net::')
|
||||
);
|
||||
|
||||
expect(significantErrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,791 +0,0 @@
|
||||
/**
|
||||
* Navigation E2E Tests
|
||||
*
|
||||
* Tests the navigation functionality of the Charon application including:
|
||||
* - Main menu items are clickable and navigate correctly
|
||||
* - Sidebar navigation expand/collapse behavior
|
||||
* - Breadcrumbs display correct path
|
||||
* - Deep links resolve properly
|
||||
* - Browser back button navigation
|
||||
* - Keyboard navigation through menus
|
||||
*
|
||||
* @see /projects/Charon/docs/plans/current_spec.md - Section 4.1.2
|
||||
*/
|
||||
|
||||
import { test, expect, loginUser } from '../fixtures/auth-fixtures';
|
||||
import { waitForLoadingComplete } from '../utils/wait-helpers';
|
||||
|
||||
test.describe('Navigation', () => {
|
||||
test.beforeEach(async ({ page, adminUser }) => {
|
||||
await loginUser(page, adminUser);
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
test.describe('Main Menu Items', () => {
|
||||
/**
|
||||
* Test: All main navigation items are visible and clickable
|
||||
*/
|
||||
test('should display all main navigation items', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Verify navigation menu exists', async () => {
|
||||
const nav = page.getByRole('navigation');
|
||||
await expect(nav.first()).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify common navigation items exist', async () => {
|
||||
const expectedNavItems = [
|
||||
/dashboard|home/i,
|
||||
/proxy.*hosts?/i,
|
||||
/certificates?|ssl/i,
|
||||
/access.*lists?|acl/i,
|
||||
/settings?/i,
|
||||
];
|
||||
|
||||
for (const pattern of expectedNavItems) {
|
||||
const navItem = page
|
||||
.getByRole('link', { name: pattern })
|
||||
.or(page.getByRole('button', { name: pattern }))
|
||||
.first();
|
||||
|
||||
if (await navItem.isVisible().catch(() => false)) {
|
||||
await expect(navItem).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Proxy Hosts navigation works
|
||||
*/
|
||||
test('should navigate to Proxy Hosts page', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await test.step('Click Proxy Hosts navigation', async () => {
|
||||
const proxyNav = page
|
||||
.getByRole('link', { name: /proxy.*hosts?/i })
|
||||
.or(page.getByRole('button', { name: /proxy.*hosts?/i }))
|
||||
.first();
|
||||
|
||||
await proxyNav.click();
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
await test.step('Verify navigation to Proxy Hosts page', async () => {
|
||||
await expect(page).toHaveURL(/proxy/i);
|
||||
const heading = page.getByRole('heading', { name: /proxy.*hosts?/i });
|
||||
if (await heading.isVisible().catch(() => false)) {
|
||||
await expect(heading).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Certificates navigation works
|
||||
*/
|
||||
test('should navigate to Certificates page', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await test.step('Click Certificates navigation', async () => {
|
||||
const certNav = page
|
||||
.getByRole('link', { name: /certificates?|ssl/i })
|
||||
.or(page.getByRole('button', { name: /certificates?|ssl/i }))
|
||||
.first();
|
||||
|
||||
if (await certNav.isVisible().catch(() => false)) {
|
||||
await certNav.click();
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Verify navigation to Certificates page', async () => {
|
||||
await expect(page).toHaveURL(/certificate/i);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Access Lists navigation works
|
||||
*/
|
||||
test('should navigate to Access Lists page', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await test.step('Click Access Lists navigation', async () => {
|
||||
const aclNav = page
|
||||
.getByRole('link', { name: /access.*lists?|acl/i })
|
||||
.or(page.getByRole('button', { name: /access.*lists?|acl/i }))
|
||||
.first();
|
||||
|
||||
if (await aclNav.isVisible().catch(() => false)) {
|
||||
await aclNav.click();
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Verify navigation to Access Lists page', async () => {
|
||||
await expect(page).toHaveURL(/access|acl/i);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Settings navigation works
|
||||
*/
|
||||
test('should navigate to Settings page', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await test.step('Click Settings navigation', async () => {
|
||||
const settingsNav = page
|
||||
.getByRole('link', { name: /settings?/i })
|
||||
.or(page.getByRole('button', { name: /settings?/i }))
|
||||
.first();
|
||||
|
||||
if (await settingsNav.isVisible().catch(() => false)) {
|
||||
await settingsNav.click();
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Verify navigation to Settings page', async () => {
|
||||
await expect(page).toHaveURL(/settings?/i);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Sidebar Navigation', () => {
|
||||
/**
|
||||
* Test: Sidebar expand/collapse works
|
||||
*/
|
||||
test('should expand and collapse sidebar sections', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Find expandable sidebar sections', async () => {
|
||||
const expandButtons = page.locator('[aria-expanded]');
|
||||
|
||||
if ((await expandButtons.count()) > 0) {
|
||||
const firstExpandable = expandButtons.first();
|
||||
const initialState = await firstExpandable.getAttribute('aria-expanded');
|
||||
|
||||
await test.step('Toggle expand state', async () => {
|
||||
await firstExpandable.click();
|
||||
await page.waitForTimeout(300); // Wait for animation
|
||||
|
||||
const newState = await firstExpandable.getAttribute('aria-expanded');
|
||||
expect(newState).not.toBe(initialState);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Sidebar shows active state for current page
|
||||
*/
|
||||
test('should highlight active navigation item', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Navigate to a specific page', async () => {
|
||||
const proxyNav = page
|
||||
.getByRole('link', { name: /proxy.*hosts?/i })
|
||||
.first();
|
||||
|
||||
if (await proxyNav.isVisible().catch(() => false)) {
|
||||
await proxyNav.click();
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Verify active state indication', async () => {
|
||||
// Check for aria-current or active class
|
||||
const hasActiveCurrent =
|
||||
(await proxyNav.getAttribute('aria-current')) === 'page' ||
|
||||
(await proxyNav.getAttribute('aria-current')) === 'true';
|
||||
|
||||
const hasActiveClass =
|
||||
(await proxyNav.getAttribute('class'))?.includes('active') ||
|
||||
(await proxyNav.getAttribute('class'))?.includes('current');
|
||||
|
||||
expect(hasActiveCurrent || hasActiveClass || true).toBeTruthy();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Sidebar persists state across navigation
|
||||
*/
|
||||
test('should maintain sidebar state across page navigation', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Check sidebar visibility', async () => {
|
||||
const sidebar = page
|
||||
.getByRole('navigation')
|
||||
.or(page.locator('[class*="sidebar"]'))
|
||||
.first();
|
||||
|
||||
const wasVisible = await sidebar.isVisible().catch(() => false);
|
||||
|
||||
await test.step('Navigate and verify sidebar persists', async () => {
|
||||
await page.goto('/proxy-hosts');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
const isStillVisible = await sidebar.isVisible().catch(() => false);
|
||||
expect(isStillVisible).toBe(wasVisible);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Breadcrumbs', () => {
|
||||
/**
|
||||
* Test: Breadcrumbs show correct path
|
||||
*/
|
||||
test('should display breadcrumbs with correct path', async ({ page }) => {
|
||||
await test.step('Navigate to a nested page', async () => {
|
||||
await page.goto('/proxy-hosts');
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
await test.step('Verify breadcrumbs exist', async () => {
|
||||
const breadcrumbs = page
|
||||
.getByRole('navigation', { name: /breadcrumb/i })
|
||||
.or(page.locator('[aria-label*="breadcrumb"]'))
|
||||
.or(page.locator('[class*="breadcrumb"]'));
|
||||
|
||||
if (await breadcrumbs.isVisible().catch(() => false)) {
|
||||
await expect(breadcrumbs).toBeVisible();
|
||||
|
||||
await test.step('Verify breadcrumb items', async () => {
|
||||
const items = breadcrumbs.getByRole('link').or(breadcrumbs.locator('a, span'));
|
||||
expect(await items.count()).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Breadcrumb links navigate correctly
|
||||
*/
|
||||
test('should navigate when clicking breadcrumb links', async ({ page }) => {
|
||||
await page.goto('/proxy-hosts');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Find clickable breadcrumb', async () => {
|
||||
const breadcrumbs = page
|
||||
.getByRole('navigation', { name: /breadcrumb/i })
|
||||
.or(page.locator('[class*="breadcrumb"]'));
|
||||
|
||||
if (await breadcrumbs.isVisible().catch(() => false)) {
|
||||
const homeLink = breadcrumbs
|
||||
.getByRole('link', { name: /home|dashboard/i })
|
||||
.first();
|
||||
|
||||
if (await homeLink.isVisible().catch(() => false)) {
|
||||
await homeLink.click();
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await expect(page).toHaveURL('/');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Deep Links', () => {
|
||||
/**
|
||||
* Test: Direct URL to page works
|
||||
*/
|
||||
test('should resolve direct URL to proxy hosts page', async ({ page }) => {
|
||||
await test.step('Navigate directly to proxy hosts', async () => {
|
||||
await page.goto('/proxy-hosts');
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
await test.step('Verify page loaded correctly', async () => {
|
||||
await expect(page).toHaveURL(/proxy/);
|
||||
await expect(page.getByRole('main')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Direct URL to specific resource works
|
||||
*/
|
||||
test('should handle deep link to specific resource', async ({ page }) => {
|
||||
await test.step('Navigate to a specific resource URL', async () => {
|
||||
// Try to access a specific proxy host (may not exist)
|
||||
await page.goto('/proxy-hosts/123');
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
await test.step('Verify appropriate response', async () => {
|
||||
// App should handle this gracefully - we just verify it doesn't crash
|
||||
// The app may: show resource, show error, redirect, or show list
|
||||
|
||||
// Check page is responsive (not blank or crashed)
|
||||
const bodyContent = await page.locator('body').textContent().catch(() => '');
|
||||
const hasContent = bodyContent && bodyContent.length > 0;
|
||||
|
||||
// Check for any visible UI element
|
||||
const hasVisibleUI = await page.locator('body > *').first().isVisible().catch(() => false);
|
||||
|
||||
// Test passes if page rendered anything
|
||||
expect(hasContent || hasVisibleUI).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Invalid deep link shows error page
|
||||
*/
|
||||
test('should handle invalid deep links gracefully', async ({ page }) => {
|
||||
await test.step('Navigate to non-existent page', async () => {
|
||||
await page.goto('/non-existent-page-12345');
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
await test.step('Verify error handling', async () => {
|
||||
// App should handle gracefully - show 404, redirect, or show some content
|
||||
const currentUrl = page.url();
|
||||
|
||||
const hasNotFound = await page
|
||||
.getByText(/not found|404|page.*exist|error/i)
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
// Check if redirected to dashboard or known route
|
||||
const redirectedToDashboard = currentUrl.endsWith('/') || currentUrl.includes('/login');
|
||||
const redirectedToKnownRoute = currentUrl.includes('/proxy') || currentUrl.includes('/certificate');
|
||||
|
||||
// Check for any visible content (app didn't crash)
|
||||
const hasVisibleContent = await page.locator('body > *').first().isVisible().catch(() => false);
|
||||
|
||||
// Any graceful handling is acceptable
|
||||
expect(hasNotFound || redirectedToDashboard || redirectedToKnownRoute || hasVisibleContent).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Back Button Navigation', () => {
|
||||
/**
|
||||
* Test: Browser back button navigates correctly
|
||||
*/
|
||||
test('should navigate back with browser back button', async ({ page }) => {
|
||||
await test.step('Build navigation history', async () => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await page.goto('/proxy-hosts');
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
await test.step('Click browser back button', async () => {
|
||||
await page.goBack();
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
await test.step('Verify returned to previous page', async () => {
|
||||
await expect(page).toHaveURL('/');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Forward button works after back
|
||||
*/
|
||||
test('should navigate forward after going back', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await page.goto('/proxy-hosts');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Go back then forward', async () => {
|
||||
await page.goBack();
|
||||
await waitForLoadingComplete(page);
|
||||
await expect(page).toHaveURL('/');
|
||||
|
||||
await page.goForward();
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
await test.step('Verify returned to forward page', async () => {
|
||||
await expect(page).toHaveURL(/proxy/);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Back button from form doesn't lose data warning
|
||||
*/
|
||||
test('should warn about unsaved changes when navigating back', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Navigate to a form page', async () => {
|
||||
// Try to find an "Add" button to open a form
|
||||
const addButton = page
|
||||
.getByRole('button', { name: /add|new|create/i })
|
||||
.first();
|
||||
|
||||
if (await addButton.isVisible().catch(() => false)) {
|
||||
await addButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Fill in some data to trigger unsaved changes
|
||||
const nameInput = page
|
||||
.getByLabel(/name|domain|title/i)
|
||||
.first();
|
||||
|
||||
if (await nameInput.isVisible().catch(() => false)) {
|
||||
await nameInput.fill('Test unsaved data');
|
||||
|
||||
// Setup dialog handler for beforeunload
|
||||
page.on('dialog', async (dialog) => {
|
||||
expect(dialog.type()).toBe('beforeunload');
|
||||
await dialog.dismiss();
|
||||
});
|
||||
|
||||
// Try to navigate back
|
||||
await page.goBack();
|
||||
|
||||
// May show confirmation or proceed based on implementation
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Keyboard Navigation', () => {
|
||||
/**
|
||||
* Test: Tab through navigation items
|
||||
*/
|
||||
test('should tab through menu items', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Tab through navigation', async () => {
|
||||
const focusedElements: string[] = [];
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await page.keyboard.press('Tab');
|
||||
const focused = page.locator(':focus');
|
||||
|
||||
// Only process if element exists and is visible
|
||||
if (await focused.count() > 0 && await focused.isVisible().catch(() => false)) {
|
||||
const tagName = await focused.evaluate((el) => el.tagName).catch(() => '');
|
||||
const role = await focused.getAttribute('role').catch(() => '');
|
||||
const text = await focused.textContent().catch(() => '');
|
||||
|
||||
if (tagName || role) {
|
||||
focusedElements.push(`${tagName || role}: ${text?.trim().substring(0, 20)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Should have found some focusable elements (or page has no focusable nav items)
|
||||
expect(focusedElements.length >= 0).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Enter key activates menu items
|
||||
*/
|
||||
test('should activate menu item with Enter key', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Focus a navigation link and press Enter', async () => {
|
||||
// Tab to find a navigation link with limited iterations
|
||||
let foundNavLink = false;
|
||||
|
||||
for (let i = 0; i < 12; i++) {
|
||||
await page.keyboard.press('Tab');
|
||||
const focused = page.locator(':focus');
|
||||
|
||||
// Check element exists before querying attributes
|
||||
if (await focused.count() === 0 || !await focused.isVisible().catch(() => false)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const href = await focused.getAttribute('href').catch(() => null);
|
||||
const text = await focused.textContent().catch(() => '');
|
||||
|
||||
if (href !== null && text?.match(/proxy|certificate|settings|dashboard|home/i)) {
|
||||
foundNavLink = true;
|
||||
const initialUrl = page.url();
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
// URL should change after activation (or stay same if already on that page)
|
||||
const newUrl = page.url();
|
||||
// Navigation worked if URL changed or we're on a valid page
|
||||
expect(newUrl).toBeTruthy();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// May not find nav link depending on focus order - this is acceptable
|
||||
expect(foundNavLink || true).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Escape key closes dropdowns
|
||||
*/
|
||||
test('should close dropdown menus with Escape key', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Open a dropdown and close with Escape', async () => {
|
||||
const dropdown = page.locator('[aria-haspopup="true"]').first();
|
||||
|
||||
if (await dropdown.isVisible().catch(() => false)) {
|
||||
await dropdown.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Verify dropdown is open
|
||||
const expanded = await dropdown.getAttribute('aria-expanded');
|
||||
|
||||
if (expanded === 'true') {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const closedState = await dropdown.getAttribute('aria-expanded');
|
||||
expect(closedState).toBe('false');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Arrow keys navigate within menus
|
||||
*/
|
||||
test('should navigate menu with arrow keys', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Use arrow keys in menu', async () => {
|
||||
const menu = page.getByRole('menu').or(page.getByRole('menubar')).first();
|
||||
|
||||
if (await menu.isVisible().catch(() => false)) {
|
||||
// Focus the menu
|
||||
await menu.focus();
|
||||
|
||||
// Use arrow keys and check focus changes
|
||||
await page.keyboard.press('ArrowDown');
|
||||
const focused1Element = page.locator(':focus');
|
||||
const focused1 = await focused1Element.count() > 0
|
||||
? await focused1Element.textContent().catch(() => '')
|
||||
: '';
|
||||
|
||||
await page.keyboard.press('ArrowDown');
|
||||
const focused2Element = page.locator(':focus');
|
||||
const focused2 = await focused2Element.count() > 0
|
||||
? await focused2Element.textContent().catch(() => '')
|
||||
: '';
|
||||
|
||||
// Arrow key navigation tested - focus may or may not change depending on menu implementation
|
||||
expect(true).toBeTruthy();
|
||||
} else {
|
||||
// No menu/menubar role present - this is acceptable for many navigation patterns
|
||||
expect(true).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Skip link for keyboard users
|
||||
* TODO: Implement skip-to-content link in the application for better accessibility
|
||||
*/
|
||||
test.skip('should have skip to main content link', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await test.step('Tab to first element and check for skip link', async () => {
|
||||
await page.keyboard.press('Tab');
|
||||
|
||||
const focused = page.locator(':focus');
|
||||
const text = await focused.textContent().catch(() => '');
|
||||
const href = await focused.getAttribute('href').catch(() => '');
|
||||
|
||||
// First focusable element should be skip link
|
||||
const isSkipLink =
|
||||
text?.match(/skip.*main|skip.*content/i) || href?.includes('#main');
|
||||
|
||||
expect(isSkipLink).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Navigation Accessibility', () => {
|
||||
/**
|
||||
* Test: Navigation has proper ARIA landmarks
|
||||
*/
|
||||
test('should have navigation landmark role', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Verify navigation landmark exists', async () => {
|
||||
const nav = page.getByRole('navigation');
|
||||
await expect(nav.first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Navigation items have accessible names
|
||||
*/
|
||||
test('should have accessible names for all navigation items', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Verify navigation items have names', async () => {
|
||||
const navLinks = page.getByRole('navigation').getByRole('link');
|
||||
const count = await navLinks.count();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const link = navLinks.nth(i);
|
||||
const text = await link.textContent();
|
||||
const ariaLabel = await link.getAttribute('aria-label');
|
||||
|
||||
// Each link should have text or aria-label
|
||||
expect(text?.trim() || ariaLabel).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Current page indicated with aria-current
|
||||
*/
|
||||
test('should indicate current page with aria-current', async ({ page }) => {
|
||||
await page.goto('/proxy-hosts');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Verify aria-current on active link', async () => {
|
||||
const navLinks = page.getByRole('navigation').getByRole('link');
|
||||
const count = await navLinks.count();
|
||||
|
||||
let hasAriaCurrent = false;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const link = navLinks.nth(i);
|
||||
const ariaCurrent = await link.getAttribute('aria-current');
|
||||
|
||||
if (ariaCurrent === 'page' || ariaCurrent === 'true') {
|
||||
hasAriaCurrent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// aria-current is recommended but not always implemented
|
||||
expect(hasAriaCurrent || true).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Focus visible on navigation items
|
||||
*/
|
||||
test('should show visible focus indicator', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
await test.step('Verify focus is visible', async () => {
|
||||
// Tab to first navigation item
|
||||
await page.keyboard.press('Tab');
|
||||
|
||||
const focused = page.locator(':focus');
|
||||
|
||||
if (await focused.isVisible().catch(() => false)) {
|
||||
// Check if focus is visually distinct (has outline or similar)
|
||||
const outline = await focused.evaluate((el) => {
|
||||
const style = window.getComputedStyle(el);
|
||||
return style.outline || style.boxShadow;
|
||||
});
|
||||
|
||||
// Focus indicator should be present
|
||||
expect(outline || true).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Responsive Navigation', () => {
|
||||
/**
|
||||
* Test: Mobile menu toggle works
|
||||
*/
|
||||
test('should toggle mobile menu', async ({ page }) => {
|
||||
// Navigate first, then resize to mobile viewport
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.waitForTimeout(300); // Allow layout reflow
|
||||
|
||||
await test.step('Find and click mobile menu button', async () => {
|
||||
const menuButton = page
|
||||
.getByRole('button', { name: /menu|toggle/i })
|
||||
.or(page.locator('[class*="hamburger"], [class*="menu-toggle"]'))
|
||||
.first();
|
||||
|
||||
if (await menuButton.isVisible().catch(() => false)) {
|
||||
await menuButton.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
await test.step('Verify menu opens', async () => {
|
||||
const nav = page.getByRole('navigation');
|
||||
await expect(nav.first()).toBeVisible();
|
||||
});
|
||||
} else {
|
||||
// On this app, navigation may remain visible on mobile
|
||||
const nav = page.getByRole('navigation').first();
|
||||
const sidebar = page.locator('[class*="sidebar"]').first();
|
||||
const hasNav = await nav.isVisible().catch(() => false);
|
||||
const hasSidebar = await sidebar.isVisible().catch(() => false);
|
||||
expect(hasNav || hasSidebar).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Navigation adapts to different screen sizes
|
||||
*/
|
||||
test('should adapt navigation to screen size', async ({ page }) => {
|
||||
await test.step('Check desktop navigation', async () => {
|
||||
// Navigate first, then verify at desktop size
|
||||
await page.goto('/');
|
||||
await waitForLoadingComplete(page);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.waitForTimeout(300); // Allow layout reflow
|
||||
|
||||
// On desktop, check for any navigation structure
|
||||
const desktopNav = page.getByRole('navigation');
|
||||
const sidebar = page.locator('[class*="sidebar"]').first();
|
||||
const links = page.locator('a[href]');
|
||||
|
||||
const hasNav = await desktopNav.first().isVisible().catch(() => false);
|
||||
const hasSidebar = await sidebar.isVisible().catch(() => false);
|
||||
const hasLinks = await links.first().isVisible().catch(() => false);
|
||||
|
||||
// Desktop should have some navigation mechanism
|
||||
expect(hasNav || hasSidebar || hasLinks).toBeTruthy();
|
||||
});
|
||||
|
||||
await test.step('Check mobile navigation', async () => {
|
||||
// Resize to mobile
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.waitForTimeout(300); // Allow layout reflow
|
||||
|
||||
// On mobile, nav may be hidden behind hamburger menu or still visible
|
||||
const hamburger = page.locator(
|
||||
'[class*="hamburger"], [class*="menu-toggle"], [aria-label*="menu"], [class*="burger"]'
|
||||
);
|
||||
const nav = page.getByRole('navigation').first();
|
||||
const sidebar = page.locator('[class*="sidebar"]').first();
|
||||
const links = page.locator('a[href]');
|
||||
|
||||
// Either nav is visible, or there's a hamburger menu, or sidebar, or links
|
||||
const hasHamburger = await hamburger.isVisible().catch(() => false);
|
||||
const hasVisibleNav = await nav.isVisible().catch(() => false);
|
||||
const hasSidebar = await sidebar.isVisible().catch(() => false);
|
||||
const hasLinks = await links.first().isVisible().catch(() => false);
|
||||
|
||||
// Mobile should have some navigation mechanism
|
||||
expect(hasHamburger || hasVisibleNav || hasSidebar || hasLinks).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,990 +0,0 @@
|
||||
/**
|
||||
* Proxy Hosts CRUD E2E Tests
|
||||
*
|
||||
* Tests the Proxy Hosts management functionality including:
|
||||
* - List view with table, columns, and empty states
|
||||
* - Create proxy host with form validation
|
||||
* - Read/view proxy host details
|
||||
* - Update existing proxy hosts
|
||||
* - Delete proxy hosts with confirmation
|
||||
*
|
||||
* @see /projects/Charon/docs/plans/current_spec.md - Phase 2
|
||||
*/
|
||||
|
||||
import { test, expect, loginUser, TEST_PASSWORD } from '../fixtures/auth-fixtures';
|
||||
import { waitForLoadingComplete, waitForToast, waitForModal } from '../utils/wait-helpers';
|
||||
import {
|
||||
basicProxyHost,
|
||||
proxyHostWithSSL,
|
||||
proxyHostWithWebSocket,
|
||||
invalidProxyHosts,
|
||||
generateProxyHost,
|
||||
type ProxyHostConfig,
|
||||
} from '../fixtures/proxy-hosts';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Helper to dismiss the "New Base Domain Detected" dialog if it appears.
|
||||
* This dialog asks if the user wants to save the domain to their domain list.
|
||||
*/
|
||||
async function dismissDomainDialog(page: Page): Promise<void> {
|
||||
const noThanksBtn = page.getByRole('button', { name: /No, thanks/i });
|
||||
if (await noThanksBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await noThanksBtn.click();
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Proxy Hosts - CRUD Operations', () => {
|
||||
test.beforeEach(async ({ page, adminUser }) => {
|
||||
await loginUser(page, adminUser);
|
||||
await waitForLoadingComplete(page);
|
||||
await page.goto('/proxy-hosts');
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
// Helper to get the primary Add Host button (in header, not empty state)
|
||||
const getAddHostButton = (page: import('@playwright/test').Page) =>
|
||||
page.getByRole('button', { name: 'Add Proxy Host' }).first();
|
||||
|
||||
// Helper to get the Save button (primary form submit, not confirmation)
|
||||
const getSaveButton = (page: import('@playwright/test').Page) =>
|
||||
page.getByRole('button', { name: 'Save', exact: true });
|
||||
|
||||
test.describe('List View', () => {
|
||||
test('should display proxy hosts page with title', async ({ page }) => {
|
||||
await test.step('Verify page title is visible', async () => {
|
||||
const heading = page.getByRole('heading', { name: 'Proxy Hosts', exact: true });
|
||||
await expect(heading).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify Add Host button is present', async () => {
|
||||
const addButton = getAddHostButton(page);
|
||||
await expect(addButton).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test('should show correct table columns', async ({ page }) => {
|
||||
await test.step('Verify table headers exist', async () => {
|
||||
// The table should have columns: Name, Domain, Forward To, SSL, Features, Status, Actions
|
||||
const expectedColumns = [
|
||||
/name/i,
|
||||
/domain/i,
|
||||
/forward/i,
|
||||
/ssl/i,
|
||||
/features/i,
|
||||
/status/i,
|
||||
/actions/i,
|
||||
];
|
||||
|
||||
for (const pattern of expectedColumns) {
|
||||
const header = page.getByRole('columnheader', { name: pattern });
|
||||
// Column headers may be hidden on mobile, so check if at least one matches
|
||||
const headerExists = await header.count() > 0;
|
||||
if (headerExists) {
|
||||
await expect(header.first()).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('should display empty state when no hosts exist', async ({ page, testData }) => {
|
||||
await test.step('Check for empty state or existing hosts', async () => {
|
||||
// Wait for page to settle
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// The page may show empty state or hosts depending on test data
|
||||
const emptyStateHeading = page.getByRole('heading', { name: 'No proxy hosts' });
|
||||
const table = page.getByRole('table');
|
||||
|
||||
// Either empty state is visible OR a table with data
|
||||
const hasEmptyState = await emptyStateHeading.isVisible().catch(() => false);
|
||||
const hasTable = await table.isVisible().catch(() => false);
|
||||
|
||||
expect(hasEmptyState || hasTable).toBeTruthy();
|
||||
|
||||
if (hasEmptyState) {
|
||||
// Empty state should have an Add Host action
|
||||
const addAction = getAddHostButton(page);
|
||||
await expect(addAction).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('should show loading skeleton while fetching data', async ({ page }) => {
|
||||
await test.step('Navigate and observe loading state', async () => {
|
||||
// Reload to observe loading skeleton
|
||||
await page.reload();
|
||||
|
||||
// Wait for page to load - check for either table or empty state
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const table = page.getByRole('table');
|
||||
const emptyState = page.getByRole('heading', { name: 'No proxy hosts' });
|
||||
|
||||
const hasTable = await table.isVisible().catch(() => false);
|
||||
const hasEmpty = await emptyState.isVisible().catch(() => false);
|
||||
|
||||
expect(hasTable || hasEmpty).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test('should support row selection for bulk operations', async ({ page }) => {
|
||||
await test.step('Check for selectable rows', async () => {
|
||||
// Look for checkbox in table header (select all) or rows
|
||||
const selectAllCheckbox = page.locator('thead').getByRole('checkbox');
|
||||
const rowCheckboxes = page.locator('tbody').getByRole('checkbox');
|
||||
|
||||
const hasSelectAll = await selectAllCheckbox.count() > 0;
|
||||
const hasRowCheckboxes = await rowCheckboxes.count() > 0;
|
||||
|
||||
// Selection is available if we have checkboxes (only when hosts exist)
|
||||
if (hasSelectAll || hasRowCheckboxes) {
|
||||
// Try selecting all
|
||||
if (hasSelectAll) {
|
||||
await selectAllCheckbox.first().click();
|
||||
// Should show bulk action bar
|
||||
const bulkBar = page.getByText(/selected/i);
|
||||
const hasBulkBar = await bulkBar.isVisible().catch(() => false);
|
||||
expect(hasBulkBar || true).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Create Proxy Host', () => {
|
||||
test('should open create modal when Add button clicked', async ({ page }) => {
|
||||
await test.step('Click Add Host button', async () => {
|
||||
const addButton = getAddHostButton(page);
|
||||
await addButton.click();
|
||||
});
|
||||
|
||||
await test.step('Verify form modal opens', async () => {
|
||||
// The form should be visible as a modal/dialog
|
||||
const formTitle = page.getByRole('heading', { name: /add.*proxy.*host/i });
|
||||
await expect(formTitle).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify essential form fields are present
|
||||
const nameInput = page.locator('#proxy-name').or(page.getByLabel(/name/i));
|
||||
await expect(nameInput.first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test('should validate required fields', async ({ page }) => {
|
||||
await test.step('Open create form', async () => {
|
||||
await getAddHostButton(page).click();
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
|
||||
await test.step('Try to submit empty form', async () => {
|
||||
const saveButton = getSaveButton(page);
|
||||
await saveButton.click();
|
||||
|
||||
// Form should show validation error or prevent submission
|
||||
// Required fields: name, domain_names, forward_host, forward_port
|
||||
const nameInput = page.locator('#proxy-name');
|
||||
const isInvalid = await nameInput.evaluate((el: HTMLInputElement) =>
|
||||
el.validity.valid === false || el.getAttribute('aria-invalid') === 'true'
|
||||
).catch(() => false);
|
||||
|
||||
// Browser validation or custom validation should prevent submission
|
||||
expect(isInvalid || true).toBeTruthy();
|
||||
});
|
||||
|
||||
await test.step('Close form', async () => {
|
||||
await page.getByRole('button', { name: /cancel/i }).click();
|
||||
});
|
||||
});
|
||||
|
||||
test('should validate domain format', async ({ page }) => {
|
||||
await test.step('Open create form', async () => {
|
||||
await getAddHostButton(page).click();
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
|
||||
await test.step('Enter invalid domain', async () => {
|
||||
const domainInput = page.locator('#domain-names').or(page.getByLabel(/domain/i));
|
||||
await domainInput.first().fill('not a valid domain!');
|
||||
|
||||
// Tab away to trigger validation
|
||||
await page.keyboard.press('Tab');
|
||||
});
|
||||
|
||||
await test.step('Close form', async () => {
|
||||
await page.getByRole('button', { name: /cancel/i }).click();
|
||||
});
|
||||
});
|
||||
|
||||
test('should validate port number range (1-65535)', async ({ page }) => {
|
||||
await test.step('Open create form', async () => {
|
||||
await getAddHostButton(page).click();
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
|
||||
await test.step('Enter invalid port (too high)', async () => {
|
||||
const portInput = page.locator('#forward-port').or(page.getByLabel(/port/i));
|
||||
await portInput.first().fill('70000');
|
||||
|
||||
// HTML5 validation should mark this as invalid (max=65535)
|
||||
const isValid = await portInput.first().evaluate((el: HTMLInputElement) =>
|
||||
el.validity.valid
|
||||
).catch(() => true);
|
||||
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
await test.step('Enter valid port', async () => {
|
||||
const portInput = page.locator('#forward-port').or(page.getByLabel(/port/i));
|
||||
await portInput.first().fill('8080');
|
||||
|
||||
const isValid = await portInput.first().evaluate((el: HTMLInputElement) =>
|
||||
el.validity.valid
|
||||
).catch(() => false);
|
||||
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
await test.step('Close form', async () => {
|
||||
await page.getByRole('button', { name: /cancel/i }).click();
|
||||
});
|
||||
});
|
||||
|
||||
test('should create proxy host with minimal config', async ({ page, testData }) => {
|
||||
const hostConfig = generateProxyHost();
|
||||
|
||||
await test.step('Open create form', async () => {
|
||||
await getAddHostButton(page).click();
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
|
||||
await test.step('Fill in minimal required fields', async () => {
|
||||
// Name
|
||||
const nameInput = page.locator('#proxy-name');
|
||||
await nameInput.fill(`Test Host ${Date.now()}`);
|
||||
|
||||
// Domain
|
||||
const domainInput = page.locator('#domain-names');
|
||||
await domainInput.fill(hostConfig.domain);
|
||||
|
||||
// Dismiss the "New Base Domain Detected" dialog if it appears after domain input
|
||||
await dismissDomainDialog(page);
|
||||
|
||||
// Forward Host
|
||||
const forwardHostInput = page.locator('#forward-host');
|
||||
await forwardHostInput.fill(hostConfig.forwardHost);
|
||||
|
||||
// Forward Port
|
||||
const forwardPortInput = page.locator('#forward-port');
|
||||
await forwardPortInput.clear();
|
||||
await forwardPortInput.fill(String(hostConfig.forwardPort));
|
||||
});
|
||||
|
||||
await test.step('Submit form', async () => {
|
||||
// Dismiss the "New Base Domain Detected" dialog if it appears
|
||||
await dismissDomainDialog(page);
|
||||
|
||||
const saveButton = getSaveButton(page);
|
||||
await saveButton.click();
|
||||
|
||||
// Dismiss domain dialog again in case it appeared after Save click
|
||||
await dismissDomainDialog(page);
|
||||
|
||||
// Handle "Unsaved changes" confirmation dialog if it appears
|
||||
const confirmDialog = page.getByRole('button', { name: /yes.*save/i });
|
||||
if (await confirmDialog.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await confirmDialog.click();
|
||||
}
|
||||
|
||||
// Wait for loading overlay or success state
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
await test.step('Verify host was created', async () => {
|
||||
// Wait for either success toast OR host appearing in list
|
||||
// Use Promise.race with proper Playwright auto-waiting for reliability
|
||||
const successToast = page.getByText(/success|created|saved/i);
|
||||
const hostInList = page.getByText(hostConfig.domain);
|
||||
|
||||
// First, wait for any modal to close (form submission complete)
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Try waiting for success indicators with proper retry logic
|
||||
let verified = false;
|
||||
|
||||
// Check 1: Wait for toast (may have already disappeared)
|
||||
const hasSuccess = await successToast.isVisible({ timeout: 2000 }).catch(() => false);
|
||||
if (hasSuccess) {
|
||||
verified = true;
|
||||
}
|
||||
|
||||
// Check 2: If no toast, check if we're back on list page with the host visible
|
||||
if (!verified) {
|
||||
// Wait for navigation back to list
|
||||
await page.waitForURL(/\/proxy-hosts(?!\/)/, { timeout: 5000 }).catch(() => {});
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
// Now check for the host in the list
|
||||
const hasHostInList = await hostInList.isVisible({ timeout: 5000 }).catch(() => false);
|
||||
if (hasHostInList) {
|
||||
verified = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check 3: If still not verified, the form might still be open - check for no error
|
||||
if (!verified) {
|
||||
const errorMessage = page.getByText(/error|failed|invalid/i);
|
||||
const hasError = await errorMessage.isVisible({ timeout: 1000 }).catch(() => false);
|
||||
// If no error is shown and we're past the form, consider it a pass
|
||||
if (!hasError) {
|
||||
// Refresh and check list
|
||||
await page.goto('/proxy-hosts');
|
||||
await waitForLoadingComplete(page);
|
||||
verified = await hostInList.isVisible({ timeout: 5000 }).catch(() => false);
|
||||
}
|
||||
}
|
||||
|
||||
expect(verified).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test('should create proxy host with SSL enabled', async ({ page }) => {
|
||||
const hostConfig = generateProxyHost({ scheme: 'https' });
|
||||
|
||||
await test.step('Open create form', async () => {
|
||||
await getAddHostButton(page).click();
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
|
||||
await test.step('Fill in fields with SSL options', async () => {
|
||||
await page.locator('#proxy-name').fill(`SSL Test ${Date.now()}`);
|
||||
await page.locator('#domain-names').fill(hostConfig.domain);
|
||||
await page.locator('#forward-host').fill(hostConfig.forwardHost);
|
||||
await page.locator('#forward-port').clear();
|
||||
await page.locator('#forward-port').fill(String(hostConfig.forwardPort));
|
||||
|
||||
// Enable SSL options (Force SSL should be on by default)
|
||||
const forceSSLCheckbox = page.getByLabel(/force.*ssl/i);
|
||||
if (!await forceSSLCheckbox.isChecked()) {
|
||||
await forceSSLCheckbox.check();
|
||||
}
|
||||
|
||||
// Enable HSTS
|
||||
const hstsCheckbox = page.getByLabel(/hsts.*enabled/i);
|
||||
if (!await hstsCheckbox.isChecked()) {
|
||||
await hstsCheckbox.check();
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('Submit and verify', async () => {
|
||||
// Dismiss the "New Base Domain Detected" dialog if it appears
|
||||
await dismissDomainDialog(page);
|
||||
|
||||
await getSaveButton(page).click();
|
||||
|
||||
// Handle "Unsaved changes" confirmation dialog if it appears
|
||||
const confirmDialog = page.getByRole('button', { name: /yes.*save/i });
|
||||
if (await confirmDialog.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await confirmDialog.click();
|
||||
}
|
||||
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
// Verify creation
|
||||
const hostCreated = await page.getByText(hostConfig.domain).isVisible({ timeout: 5000 }).catch(() => false);
|
||||
expect(hostCreated || true).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test('should create proxy host with WebSocket support', async ({ page }) => {
|
||||
const hostConfig = generateProxyHost();
|
||||
|
||||
await test.step('Open create form', async () => {
|
||||
await getAddHostButton(page).click();
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
|
||||
await test.step('Fill form with WebSocket enabled', async () => {
|
||||
await page.locator('#proxy-name').fill(`WS Test ${Date.now()}`);
|
||||
await page.locator('#domain-names').fill(hostConfig.domain);
|
||||
await page.locator('#forward-host').fill(hostConfig.forwardHost);
|
||||
await page.locator('#forward-port').clear();
|
||||
await page.locator('#forward-port').fill(String(hostConfig.forwardPort));
|
||||
|
||||
// Enable WebSocket support
|
||||
const wsCheckbox = page.getByLabel(/websocket/i);
|
||||
if (!await wsCheckbox.isChecked()) {
|
||||
await wsCheckbox.check();
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('Submit and verify', async () => {
|
||||
// Dismiss the "New Base Domain Detected" dialog if it appears
|
||||
await dismissDomainDialog(page);
|
||||
|
||||
await getSaveButton(page).click();
|
||||
|
||||
// Handle "Unsaved changes" confirmation dialog if it appears
|
||||
const confirmDialog = page.getByRole('button', { name: /yes.*save/i });
|
||||
if (await confirmDialog.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await confirmDialog.click();
|
||||
}
|
||||
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
});
|
||||
|
||||
test('should show form with all security options', async ({ page }) => {
|
||||
await test.step('Open create form', async () => {
|
||||
await getAddHostButton(page).click();
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
|
||||
await test.step('Verify security options are present', async () => {
|
||||
const securityOptions = [
|
||||
/force.*ssl/i,
|
||||
/http.*2/i,
|
||||
/hsts/i,
|
||||
/block.*exploits/i,
|
||||
/websocket/i,
|
||||
];
|
||||
|
||||
for (const option of securityOptions) {
|
||||
const checkbox = page.getByLabel(option);
|
||||
const exists = await checkbox.count() > 0;
|
||||
expect(exists || true).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('Close form', async () => {
|
||||
await page.getByRole('button', { name: /cancel/i }).click();
|
||||
});
|
||||
});
|
||||
|
||||
test('should show application preset selector', async ({ page }) => {
|
||||
await test.step('Open create form', async () => {
|
||||
await getAddHostButton(page).click();
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
|
||||
await test.step('Verify application preset dropdown', async () => {
|
||||
const presetSelect = page.locator('#application-preset').or(page.getByLabel(/application.*preset/i));
|
||||
await expect(presetSelect.first()).toBeVisible();
|
||||
|
||||
// Check for common presets
|
||||
const presets = ['plex', 'jellyfin', 'homeassistant', 'nextcloud'];
|
||||
for (const preset of presets) {
|
||||
const option = page.locator(`option:text-matches("${preset}", "i")`);
|
||||
const exists = await option.count() > 0;
|
||||
expect(exists || true).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('Close form', async () => {
|
||||
await page.getByRole('button', { name: /cancel/i }).click();
|
||||
});
|
||||
});
|
||||
|
||||
test('should show test connection button', async ({ page }) => {
|
||||
await test.step('Open create form', async () => {
|
||||
await getAddHostButton(page).click();
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
|
||||
await test.step('Verify test connection button exists', async () => {
|
||||
const testButton = page.getByRole('button', { name: /test.*connection/i });
|
||||
await expect(testButton).toBeVisible();
|
||||
|
||||
// Button should be disabled initially (no host/port entered)
|
||||
const isDisabled = await testButton.isDisabled();
|
||||
expect(isDisabled).toBe(true);
|
||||
});
|
||||
|
||||
await test.step('Enter host details and check button becomes enabled', async () => {
|
||||
await page.locator('#forward-host').fill('192.168.1.100');
|
||||
await page.locator('#forward-port').fill('80');
|
||||
|
||||
const testButton = page.getByRole('button', { name: /test.*connection/i });
|
||||
const isDisabled = await testButton.isDisabled();
|
||||
expect(isDisabled).toBe(false);
|
||||
});
|
||||
|
||||
await test.step('Close form', async () => {
|
||||
await page.getByRole('button', { name: /cancel/i }).click();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Read/View Proxy Host', () => {
|
||||
test('should display host details in table row', async ({ page }) => {
|
||||
await test.step('Check table displays host information', async () => {
|
||||
const table = page.getByRole('table');
|
||||
const hasTable = await table.isVisible().catch(() => false);
|
||||
|
||||
if (hasTable) {
|
||||
// Verify table has data rows
|
||||
const rows = page.locator('tbody tr');
|
||||
const rowCount = await rows.count();
|
||||
|
||||
if (rowCount > 0) {
|
||||
// First row should have domain and forward info
|
||||
const firstRow = rows.first();
|
||||
await expect(firstRow).toBeVisible();
|
||||
|
||||
// Check for expected content patterns
|
||||
const rowText = await firstRow.textContent();
|
||||
expect(rowText).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('should show SSL badge for HTTPS hosts', async ({ page }) => {
|
||||
await test.step('Check for SSL badges in table', async () => {
|
||||
// SSL badges appear for hosts with ssl_forced
|
||||
const sslBadges = page.locator('text=SSL').or(page.getByText(/staging/i));
|
||||
const badgeCount = await sslBadges.count();
|
||||
|
||||
// May or may not have SSL hosts depending on test data
|
||||
expect(badgeCount >= 0).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test('should show status toggle for enabling/disabling hosts', async ({ page }) => {
|
||||
await test.step('Check for status toggles', async () => {
|
||||
// Status is shown as a Switch/toggle
|
||||
const statusToggles = page.locator('tbody').getByRole('switch');
|
||||
const toggleCount = await statusToggles.count();
|
||||
|
||||
// If we have hosts, we should have toggles
|
||||
if (toggleCount > 0) {
|
||||
const firstToggle = statusToggles.first();
|
||||
await expect(firstToggle).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('should show feature badges (WebSocket, ACL)', async ({ page }) => {
|
||||
await test.step('Check for feature badges', async () => {
|
||||
// Look for WebSocket or ACL badges
|
||||
const wsBadge = page.getByText(/ws|websocket/i);
|
||||
const aclBadge = page.getByText(/acl|access/i);
|
||||
|
||||
const hasWs = await wsBadge.count() > 0;
|
||||
const hasAcl = await aclBadge.count() > 0;
|
||||
|
||||
// May or may not exist depending on host configuration
|
||||
expect(hasWs || hasAcl || true).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test('should have clickable domain links', async ({ page }) => {
|
||||
await test.step('Check domain links', async () => {
|
||||
const domainLinks = page.locator('tbody a[href]');
|
||||
const linkCount = await domainLinks.count();
|
||||
|
||||
if (linkCount > 0) {
|
||||
const firstLink = domainLinks.first();
|
||||
const href = await firstLink.getAttribute('href');
|
||||
|
||||
// Links should point to the domain URL
|
||||
expect(href).toMatch(/^https?:\/\//);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Update Proxy Host', () => {
|
||||
test('should open edit modal with existing values', async ({ page }) => {
|
||||
await test.step('Find and click Edit button', async () => {
|
||||
const editButtons = page.getByRole('button', { name: /edit/i });
|
||||
const editCount = await editButtons.count();
|
||||
|
||||
if (editCount > 0) {
|
||||
await editButtons.first().click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify form opens with "Edit" title
|
||||
const formTitle = page.getByRole('heading', { name: /edit.*proxy.*host/i });
|
||||
await expect(formTitle).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify fields are populated
|
||||
const nameInput = page.locator('#proxy-name');
|
||||
const nameValue = await nameInput.inputValue();
|
||||
expect(nameValue.length >= 0).toBeTruthy();
|
||||
|
||||
// Close form
|
||||
await page.getByRole('button', { name: /cancel/i }).click();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('should update domain name', async ({ page }) => {
|
||||
await test.step('Edit a host if available', async () => {
|
||||
const editButtons = page.getByRole('button', { name: /edit/i });
|
||||
const editCount = await editButtons.count();
|
||||
|
||||
if (editCount > 0) {
|
||||
await editButtons.first().click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const domainInput = page.locator('#domain-names');
|
||||
const originalDomain = await domainInput.inputValue();
|
||||
|
||||
// Append a test suffix
|
||||
const newDomain = `test-${Date.now()}.example.com`;
|
||||
await domainInput.clear();
|
||||
await domainInput.fill(newDomain);
|
||||
|
||||
// Save
|
||||
await page.getByRole('button', { name: /save/i }).click();
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
// Verify update (check for new domain or revert)
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('should toggle SSL settings', async ({ page }) => {
|
||||
await test.step('Edit and toggle SSL', async () => {
|
||||
const editButtons = page.getByRole('button', { name: /edit/i });
|
||||
const editCount = await editButtons.count();
|
||||
|
||||
if (editCount > 0) {
|
||||
await editButtons.first().click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const forceSSLCheckbox = page.getByLabel(/force.*ssl/i);
|
||||
const wasChecked = await forceSSLCheckbox.isChecked();
|
||||
|
||||
// Toggle the checkbox
|
||||
if (wasChecked) {
|
||||
await forceSSLCheckbox.uncheck();
|
||||
} else {
|
||||
await forceSSLCheckbox.check();
|
||||
}
|
||||
|
||||
const isNowChecked = await forceSSLCheckbox.isChecked();
|
||||
expect(isNowChecked).toBe(!wasChecked);
|
||||
|
||||
// Cancel without saving
|
||||
await page.getByRole('button', { name: /cancel/i }).click();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('should update forward host and port', async ({ page }) => {
|
||||
await test.step('Edit forward settings', async () => {
|
||||
const editButtons = page.getByRole('button', { name: /edit/i });
|
||||
const editCount = await editButtons.count();
|
||||
|
||||
if (editCount > 0) {
|
||||
await editButtons.first().click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Update forward host
|
||||
const forwardHostInput = page.locator('#forward-host');
|
||||
await forwardHostInput.clear();
|
||||
await forwardHostInput.fill('192.168.1.200');
|
||||
|
||||
// Update forward port
|
||||
const forwardPortInput = page.locator('#forward-port');
|
||||
await forwardPortInput.clear();
|
||||
await forwardPortInput.fill('9000');
|
||||
|
||||
// Verify values
|
||||
expect(await forwardHostInput.inputValue()).toBe('192.168.1.200');
|
||||
expect(await forwardPortInput.inputValue()).toBe('9000');
|
||||
|
||||
// Cancel without saving
|
||||
await page.getByRole('button', { name: /cancel/i }).click();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('should toggle host enabled/disabled from list', async ({ page }) => {
|
||||
await test.step('Toggle status switch', async () => {
|
||||
const statusToggles = page.locator('tbody').getByRole('switch');
|
||||
const toggleCount = await statusToggles.count();
|
||||
|
||||
if (toggleCount > 0) {
|
||||
const firstToggle = statusToggles.first();
|
||||
const wasChecked = await firstToggle.isChecked();
|
||||
|
||||
// Toggle the switch
|
||||
await firstToggle.click();
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
// The toggle state should change (or loading overlay appears)
|
||||
// Note: actual toggle may take time to reflect
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Delete Proxy Host', () => {
|
||||
test('should show confirmation dialog before delete', async ({ page }) => {
|
||||
await test.step('Click Delete button', async () => {
|
||||
const deleteButtons = page.getByRole('button', { name: /delete/i });
|
||||
const deleteCount = await deleteButtons.count();
|
||||
|
||||
if (deleteCount > 0) {
|
||||
// Find delete button in table row (not bulk delete)
|
||||
const rowDeleteButton = page.locator('tbody').getByRole('button', { name: /delete/i }).first();
|
||||
|
||||
if (await rowDeleteButton.isVisible().catch(() => false)) {
|
||||
await rowDeleteButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Confirmation dialog should appear
|
||||
const dialog = page.getByRole('dialog').or(page.getByRole('alertdialog'));
|
||||
const hasDialog = await dialog.isVisible({ timeout: 3000 }).catch(() => false);
|
||||
|
||||
if (hasDialog) {
|
||||
// Dialog should have confirm and cancel buttons
|
||||
const confirmButton = dialog.getByRole('button', { name: /delete|confirm/i });
|
||||
const cancelButton = dialog.getByRole('button', { name: /cancel/i });
|
||||
|
||||
await expect(confirmButton).toBeVisible();
|
||||
await expect(cancelButton).toBeVisible();
|
||||
|
||||
// Cancel the delete
|
||||
await cancelButton.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('should cancel delete when confirmation dismissed', async ({ page }) => {
|
||||
await test.step('Trigger delete and cancel', async () => {
|
||||
const rowDeleteButton = page.locator('tbody').getByRole('button', { name: /delete/i }).first();
|
||||
|
||||
if (await rowDeleteButton.isVisible().catch(() => false)) {
|
||||
await rowDeleteButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const dialog = page.getByRole('dialog').or(page.getByRole('alertdialog'));
|
||||
|
||||
if (await dialog.isVisible().catch(() => false)) {
|
||||
// Click cancel
|
||||
await dialog.getByRole('button', { name: /cancel/i }).click();
|
||||
|
||||
// Dialog should close
|
||||
await expect(dialog).not.toBeVisible({ timeout: 3000 });
|
||||
|
||||
// Host should still be in the list
|
||||
const tableRows = page.locator('tbody tr');
|
||||
const rowCount = await tableRows.count();
|
||||
expect(rowCount >= 0).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('should show delete confirmation with host name', async ({ page }) => {
|
||||
await test.step('Verify confirmation message includes host info', async () => {
|
||||
const rowDeleteButton = page.locator('tbody').getByRole('button', { name: /delete/i }).first();
|
||||
|
||||
if (await rowDeleteButton.isVisible().catch(() => false)) {
|
||||
await rowDeleteButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const dialog = page.getByRole('dialog').or(page.getByRole('alertdialog'));
|
||||
|
||||
if (await dialog.isVisible().catch(() => false)) {
|
||||
// Dialog should mention the host being deleted
|
||||
const dialogText = await dialog.textContent();
|
||||
expect(dialogText).toBeTruthy();
|
||||
|
||||
// Cancel
|
||||
await dialog.getByRole('button', { name: /cancel/i }).click();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Bulk Operations', () => {
|
||||
test('should show bulk action bar when hosts are selected', async ({ page }) => {
|
||||
await test.step('Select hosts and verify bulk bar', async () => {
|
||||
const selectAllCheckbox = page.locator('thead').getByRole('checkbox');
|
||||
|
||||
if (await selectAllCheckbox.isVisible().catch(() => false)) {
|
||||
await selectAllCheckbox.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Bulk action bar should appear
|
||||
const bulkBar = page.getByText(/selected/i);
|
||||
const hasBulkBar = await bulkBar.isVisible().catch(() => false);
|
||||
|
||||
if (hasBulkBar) {
|
||||
// Verify bulk action buttons
|
||||
const bulkApply = page.getByRole('button', { name: /bulk.*apply|apply/i });
|
||||
const bulkDelete = page.getByRole('button', { name: /delete/i });
|
||||
|
||||
const hasApply = await bulkApply.isVisible().catch(() => false);
|
||||
const hasDelete = await bulkDelete.isVisible().catch(() => false);
|
||||
|
||||
expect(hasApply || hasDelete).toBeTruthy();
|
||||
}
|
||||
|
||||
// Deselect
|
||||
await selectAllCheckbox.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('should open bulk apply settings modal', async ({ page }) => {
|
||||
await test.step('Select hosts and open bulk apply', async () => {
|
||||
const selectAllCheckbox = page.locator('thead').getByRole('checkbox');
|
||||
|
||||
if (await selectAllCheckbox.isVisible().catch(() => false)) {
|
||||
await selectAllCheckbox.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const bulkApplyButton = page.getByRole('button', { name: /bulk.*apply/i });
|
||||
|
||||
if (await bulkApplyButton.isVisible().catch(() => false)) {
|
||||
await bulkApplyButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Bulk apply modal should open
|
||||
const modal = page.getByRole('dialog');
|
||||
const hasModal = await modal.isVisible().catch(() => false);
|
||||
|
||||
if (hasModal) {
|
||||
// Close modal
|
||||
await page.getByRole('button', { name: /cancel/i }).click();
|
||||
}
|
||||
}
|
||||
|
||||
// Deselect
|
||||
await selectAllCheckbox.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('should open bulk ACL modal', async ({ page }) => {
|
||||
await test.step('Select hosts and open ACL modal', async () => {
|
||||
const selectAllCheckbox = page.locator('thead').getByRole('checkbox');
|
||||
|
||||
if (await selectAllCheckbox.isVisible().catch(() => false)) {
|
||||
await selectAllCheckbox.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const manageACLButton = page.getByRole('button', { name: /manage.*acl|acl/i });
|
||||
|
||||
if (await manageACLButton.isVisible().catch(() => false)) {
|
||||
await manageACLButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// ACL modal should open
|
||||
const modal = page.getByRole('dialog');
|
||||
const hasModal = await modal.isVisible().catch(() => false);
|
||||
|
||||
if (hasModal) {
|
||||
// Should have apply/remove tabs or buttons
|
||||
const applyTab = page.getByRole('button', { name: /apply.*acl/i });
|
||||
const removeTab = page.getByRole('button', { name: /remove.*acl/i });
|
||||
|
||||
const hasApply = await applyTab.isVisible().catch(() => false);
|
||||
const hasRemove = await removeTab.isVisible().catch(() => false);
|
||||
|
||||
expect(hasApply || hasRemove || true).toBeTruthy();
|
||||
|
||||
// Close modal
|
||||
await page.getByRole('button', { name: /cancel/i }).click();
|
||||
}
|
||||
}
|
||||
|
||||
// Deselect
|
||||
await selectAllCheckbox.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Form Accessibility', () => {
|
||||
test('should have accessible form labels', async ({ page }) => {
|
||||
await test.step('Open form and verify labels', async () => {
|
||||
await getAddHostButton(page).click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Check that inputs have associated labels
|
||||
const nameInput = page.locator('#proxy-name');
|
||||
const label = page.locator('label[for="proxy-name"]');
|
||||
|
||||
const hasLabel = await label.isVisible().catch(() => false);
|
||||
expect(hasLabel).toBeTruthy();
|
||||
|
||||
// Close form
|
||||
await page.getByRole('button', { name: /cancel/i }).click();
|
||||
});
|
||||
});
|
||||
|
||||
test('should be keyboard navigable', async ({ page }) => {
|
||||
await test.step('Navigate form with keyboard', async () => {
|
||||
await getAddHostButton(page).click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Tab through form fields
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Tab');
|
||||
|
||||
// Some element should be focused
|
||||
const focusedElement = page.locator(':focus');
|
||||
const hasFocus = await focusedElement.isVisible().catch(() => false);
|
||||
expect(hasFocus || true).toBeTruthy();
|
||||
|
||||
// Escape should close the form
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Form may still be open, close with button if needed
|
||||
const cancelButton = page.getByRole('button', { name: /cancel/i });
|
||||
if (await cancelButton.isVisible().catch(() => false)) {
|
||||
await cancelButton.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Docker Integration', () => {
|
||||
test('should show Docker container selector when source is selected', async ({ page }) => {
|
||||
await test.step('Open form and check Docker options', async () => {
|
||||
await getAddHostButton(page).click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Source dropdown should be visible
|
||||
const sourceSelect = page.locator('#connection-source');
|
||||
await expect(sourceSelect).toBeVisible();
|
||||
|
||||
// Should have Local Docker Socket option
|
||||
const localOption = page.locator('option:text-matches("local", "i")');
|
||||
const hasLocalOption = await localOption.count() > 0;
|
||||
expect(hasLocalOption).toBeTruthy();
|
||||
|
||||
// Close form
|
||||
await page.getByRole('button', { name: /cancel/i }).click();
|
||||
});
|
||||
});
|
||||
|
||||
test('should show containers dropdown when Docker source selected', async ({ page }) => {
|
||||
await test.step('Select Docker source', async () => {
|
||||
await getAddHostButton(page).click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const sourceSelect = page.locator('#connection-source');
|
||||
await sourceSelect.selectOption('local');
|
||||
|
||||
// Containers dropdown should be visible
|
||||
const containersSelect = page.locator('#quick-select-docker');
|
||||
await expect(containersSelect).toBeVisible();
|
||||
|
||||
// Close form
|
||||
await page.getByRole('button', { name: /cancel/i }).click();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user