- Added `waitForDialog`, `waitForFormFields`, `waitForDebounce`, `waitForConfigReload`, and `waitForNavigation` functions to improve synchronization in tests. - Updated existing tests in `access-lists-crud.spec.ts` and `proxy-hosts.spec.ts` to utilize new wait helpers, enhancing reliability and readability. - Created unit tests for new wait helpers in `wait-helpers.spec.ts` to ensure correct functionality and edge case handling.
993 lines
38 KiB
TypeScript
993 lines
38 KiB
TypeScript
/**
|
|
* 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, waitForDialog, waitForDebounce } from '../utils/wait-helpers';
|
|
import { clickSwitch } from '../utils/ui-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 waitForDebounce(page, { delay: 300 }); // Allow dialog to close and DOM to update
|
|
}
|
|
}
|
|
|
|
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 waitForDebounce(page, { delay: 1000 }); // Allow initial data fetch and render
|
|
|
|
// 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 waitForDebounce(page, { delay: 2000 }); // Allow network requests and render
|
|
|
|
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 waitForModal(page); // Wait for modal to open
|
|
});
|
|
|
|
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 waitForModal(page); // Wait for form modal to open
|
|
});
|
|
|
|
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 waitForModal(page); // Wait for form modal to open
|
|
});
|
|
|
|
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 waitForModal(page); // Wait for form modal to open
|
|
});
|
|
|
|
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 waitForModal(page); // Wait for form modal to open
|
|
});
|
|
|
|
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 waitForDebounce(page, { delay: 1000 }); // Allow form submission and modal close
|
|
|
|
// 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 waitForModal(page); // Wait for form modal to open
|
|
});
|
|
|
|
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 waitForModal(page); // Wait for form modal to open
|
|
});
|
|
|
|
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 waitForModal(page); // Wait for form modal to open
|
|
});
|
|
|
|
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 waitForModal(page); // Wait for form modal to open
|
|
});
|
|
|
|
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 waitForModal(page); // Wait for form modal to open
|
|
});
|
|
|
|
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 waitForModal(page); // Wait for edit modal to open
|
|
|
|
// 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 waitForModal(page); // Wait for edit modal to open
|
|
|
|
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 waitForModal(page); // Wait for edit modal to open
|
|
|
|
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 waitForModal(page); // Wait for edit modal to open
|
|
|
|
// 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 clickSwitch(firstToggle);
|
|
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 waitForDialog(page); // Wait for confirmation dialog
|
|
|
|
// 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 waitForDialog(page); // Wait for confirmation dialog
|
|
|
|
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 waitForDialog(page); // Wait for confirmation dialog
|
|
|
|
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 waitForDebounce(page, { delay: 300 }); // Allow selection state to update
|
|
|
|
// 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 waitForDebounce(page, { delay: 300 }); // Allow selection state to update
|
|
|
|
const bulkApplyButton = page.getByRole('button', { name: /bulk.*apply/i });
|
|
|
|
if (await bulkApplyButton.isVisible().catch(() => false)) {
|
|
await bulkApplyButton.click();
|
|
await waitForModal(page); // Wait for bulk apply modal
|
|
|
|
// 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 waitForDebounce(page, { delay: 300 }); // Allow selection state to update
|
|
|
|
const manageACLButton = page.getByRole('button', { name: /manage.*acl|acl/i });
|
|
|
|
if (await manageACLButton.isVisible().catch(() => false)) {
|
|
await manageACLButton.click();
|
|
await waitForModal(page); // Wait for ACL modal
|
|
|
|
// 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 waitForModal(page); // Wait for form modal to open
|
|
|
|
// 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 waitForModal(page); // Wait for form modal to open
|
|
|
|
// 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 waitForModal(page); // Wait for form modal to open
|
|
|
|
// 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 waitForModal(page); // Wait for form modal to open
|
|
|
|
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();
|
|
});
|
|
});
|
|
});
|
|
});
|