Files
Charon/tests/tasks/caddy-import-gaps.spec.ts
GitHub Actions c3b20bff65 test: implement Caddy import E2E gap tests
Add 11 Playwright E2E tests covering Caddy import functionality gaps:

Success modal navigation and button actions (Gap 1)
Conflict details expansion with side-by-side comparison (Gap 2)
Overwrite resolution flow for existing hosts (Gap 3)
Session resume via banner (Gap 4 - skipped, documented limitation)
Custom name editing in review table (Gap 5)
Fixes:

backend/internal/caddy/importer.go: Handle errcheck lint errors
Result: 9 tests passing, 2 skipped with documented reason
2026-01-31 02:15:13 +00:00

527 lines
23 KiB
TypeScript

/**
* Caddy Import Gap Coverage - E2E Tests
*
* This file addresses 5 identified gaps in Caddy Import E2E test coverage:
* 1. Success Modal Navigation (tests 1.1-1.4)
* 2. Conflict Details Expansion (tests 2.1-2.3)
* 3. Overwrite Resolution Flow (test 3.1)
* 4. Session Resume via Banner (tests 4.1-4.2)
* 5. Name Editing in Review (test 5.1)
*
* Key Patterns:
* - Uses stored auth state (no login calls needed)
* - Response waiters registered BEFORE click actions
* - Real API calls (no mocking) for reliable integration testing
* - TestDataManager fixture for automatic resource cleanup
* - Row-scoped selectors (filter by domain, then find within row)
*/
import { test, expect } from '../fixtures/auth-fixtures';
import type { TestDataManager } from '../utils/TestDataManager';
import type { Page } from '@playwright/test';
/**
* Helper: Generate unique domain with namespace isolation
* This prevents conflicts when tests run in parallel
*/
function generateDomain(testData: TestDataManager, suffix: string): string {
return `${testData.getNamespace()}-${suffix}.example.com`;
}
/**
* Helper: Complete the full import flow from paste to success modal
* Reusable across multiple tests to reduce duplication
*/
async function completeImportFlow(
page: Page,
caddyfile: string
): Promise<void> {
await test.step('Navigate to import page', async () => {
await page.goto('/tasks/import/caddyfile');
});
await test.step('Paste Caddyfile content', async () => {
await page.locator('textarea').fill(caddyfile);
});
await test.step('Parse and wait for review table', async () => {
const uploadPromise = page.waitForResponse(r =>
r.url().includes('/api/v1/import/upload') && r.status() === 200
);
await page.getByRole('button', { name: /parse|review/i }).click();
await uploadPromise;
await expect(page.getByTestId('import-review-table')).toBeVisible();
});
await test.step('Commit import and wait for success modal', async () => {
const commitPromise = page.waitForResponse(r =>
r.url().includes('/api/v1/import/commit') && r.status() === 200
);
await page.getByRole('button', { name: /commit/i }).click();
await commitPromise;
await expect(page.getByTestId('import-success-modal')).toBeVisible();
});
}
test.describe('Caddy Import Gap Coverage @caddy-import-gaps', () => {
// =========================================================================
// Gap 1: Success Modal Navigation
// =========================================================================
test.describe('Success Modal Navigation', () => {
test('1.1: should display success modal after successful import commit', async ({ page, testData }) => {
const domain = generateDomain(testData, 'success-modal-test');
const caddyfile = `${domain} { reverse_proxy localhost:3000 }`;
await completeImportFlow(page, caddyfile);
// Verify success modal is visible
await expect(page.getByTestId('import-success-modal')).toBeVisible();
// Verify modal contains expected text
const modal = page.getByTestId('import-success-modal');
await expect(modal).toContainText(/import.*completed/i);
// Verify count is shown (should be 1 host created)
await expect(modal).toContainText(/1.*created/i);
});
test('1.2: should navigate to /proxy-hosts when clicking View Proxy Hosts button', async ({ page, testData }) => {
const domain = generateDomain(testData, 'view-hosts-nav');
const caddyfile = `${domain} { reverse_proxy localhost:3000 }`;
await completeImportFlow(page, caddyfile);
await test.step('Click View Proxy Hosts button', async () => {
const modal = page.getByTestId('import-success-modal');
await modal.getByRole('button', { name: /view.*proxy.*hosts/i }).click();
});
await test.step('Verify navigation to proxy hosts page', async () => {
await expect(page).toHaveURL(/\/proxy-hosts/);
await expect(page.getByTestId('import-success-modal')).not.toBeVisible();
});
});
test('1.3: should navigate to /dashboard when clicking Go to Dashboard button', async ({ page, testData }) => {
const domain = generateDomain(testData, 'dashboard-nav');
const caddyfile = `${domain} { reverse_proxy localhost:3000 }`;
await completeImportFlow(page, caddyfile);
await test.step('Click Go to Dashboard button', async () => {
const modal = page.getByTestId('import-success-modal');
await modal.getByRole('button', { name: /dashboard/i }).click();
});
await test.step('Verify navigation to dashboard', async () => {
// Dashboard can be at / or /dashboard - check the pathname portion
await expect(page).toHaveURL(/\/(dashboard)?$/);
await expect(page.getByTestId('import-success-modal')).not.toBeVisible();
});
});
test('1.4: should close modal and stay on import page when clicking Close', async ({ page, testData }) => {
const domain = generateDomain(testData, 'close-modal');
const caddyfile = `${domain} { reverse_proxy localhost:3000 }`;
await completeImportFlow(page, caddyfile);
await test.step('Click Close button', async () => {
const modal = page.getByTestId('import-success-modal');
await modal.getByRole('button', { name: /close/i }).click();
});
await test.step('Verify modal is closed and still on import page', async () => {
await expect(page.getByTestId('import-success-modal')).not.toBeVisible();
await expect(page).toHaveURL(/\/tasks\/import\/caddyfile/);
// Verify textarea is still visible (back to import form)
await expect(page.locator('textarea')).toBeVisible();
});
});
});
// =========================================================================
// Gap 2: Conflict Details Expansion
// =========================================================================
test.describe('Conflict Details Expansion', () => {
test('2.1: should show conflict indicator and expand button for conflicting domain', async ({ page, testData }) => {
// Create existing host via API to generate conflict
const result = await testData.createProxyHost({
domain: 'conflict-test.example.com',
forwardHost: 'localhost',
forwardPort: 8080,
name: 'Existing Conflict Host',
});
const namespacedDomain = result.domain;
await test.step('Navigate to import page and paste conflicting Caddyfile', async () => {
await page.goto('/tasks/import/caddyfile');
const caddyfile = `${namespacedDomain} { reverse_proxy localhost:9000 }`;
await page.locator('textarea').fill(caddyfile);
});
await test.step('Parse and wait for review table', async () => {
const uploadPromise = page.waitForResponse(r =>
r.url().includes('/api/v1/import/upload') && r.status() === 200
);
await page.getByRole('button', { name: /parse|review/i }).click();
await uploadPromise;
await expect(page.getByTestId('import-review-table')).toBeVisible();
});
await test.step('Verify conflict indicator and expand button in row', async () => {
// Use row-scoped selector: filter by domain, then find elements within
const domainRow = page.locator('tr').filter({ hasText: namespacedDomain });
// Verify conflict indicator (yellow badge with "Conflict" text)
const conflictBadge = domainRow.locator('.text-yellow-400, .text-yellow-600, .bg-yellow-500').filter({ hasText: /conflict/i });
await expect(conflictBadge.first()).toBeVisible();
// Verify expand button is present (▶ or similar)
const expandButton = domainRow.getByRole('button', { name: /expand|details/i }).or(
domainRow.locator('button[aria-label*="expand"], button:has-text("▶")')
);
await expect(expandButton.first()).toBeVisible();
});
});
test('2.2: should display side-by-side configuration comparison when expanding conflict row', async ({ page, testData }) => {
// Create existing host with specific config
const result = await testData.createProxyHost({
domain: 'expand-test.example.com',
forwardHost: 'old-server',
forwardPort: 8080,
name: 'Expand Test Host',
});
const namespacedDomain = result.domain;
await test.step('Navigate to import page and parse conflicting Caddyfile', async () => {
await page.goto('/tasks/import/caddyfile');
const caddyfile = `${namespacedDomain} { reverse_proxy new-server:9000 }`;
await page.locator('textarea').fill(caddyfile);
const uploadPromise = page.waitForResponse(r =>
r.url().includes('/api/v1/import/upload') && r.status() === 200
);
await page.getByRole('button', { name: /parse|review/i }).click();
await uploadPromise;
await expect(page.getByTestId('import-review-table')).toBeVisible();
});
await test.step('Click expand button to show conflict details', async () => {
const domainRow = page.locator('tr').filter({ hasText: namespacedDomain });
const expandButton = domainRow.getByRole('button', { name: /expand|details/i }).or(
domainRow.locator('button[aria-label*="expand"], button:has-text("▶")')
);
await expandButton.first().click();
});
await test.step('Verify side-by-side comparison is displayed', async () => {
// Look for "Current Configuration" and "Imported Configuration" section headings
// Use getByRole('heading') to be more specific and avoid matching recommendation text
await expect(page.getByRole('heading', { name: /current.*configuration/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /imported.*configuration/i })).toBeVisible();
// Port is displayed within Target string like "http://old-server:8080"
// Find Target label and verify ports in the description definition (dd) elements
const targetLabels = page.locator('dt').filter({ hasText: /target/i });
await expect(targetLabels).toHaveCount(2);
// Verify old config shows port 8080 and new shows port 9000
await expect(page.getByText(/old-server:8080/)).toBeVisible();
await expect(page.getByText(/new-server:9000/)).toBeVisible();
});
});
test('2.3: should show recommendation text in expanded conflict details', async ({ page, testData }) => {
// Create existing host
const result = await testData.createProxyHost({
domain: 'recommendation-test.example.com',
forwardHost: 'server1',
forwardPort: 3000,
name: 'Recommendation Test Host',
});
const namespacedDomain = result.domain;
await test.step('Navigate to import page and parse conflicting Caddyfile', async () => {
await page.goto('/tasks/import/caddyfile');
const caddyfile = `${namespacedDomain} { reverse_proxy server2:4000 }`;
await page.locator('textarea').fill(caddyfile);
const uploadPromise = page.waitForResponse(r =>
r.url().includes('/api/v1/import/upload') && r.status() === 200
);
await page.getByRole('button', { name: /parse|review/i }).click();
await uploadPromise;
await expect(page.getByTestId('import-review-table')).toBeVisible();
});
await test.step('Expand conflict details', async () => {
const domainRow = page.locator('tr').filter({ hasText: namespacedDomain });
const expandButton = domainRow.getByRole('button', { name: /expand|details/i }).or(
domainRow.locator('button[aria-label*="expand"], button:has-text("▶")')
);
await expandButton.first().click();
});
await test.step('Verify recommendation text is displayed', async () => {
// Look for recommendation box (typically has blue left border or contains "Recommendation:")
const recommendationText = page.locator('.border-l-4.border-blue-500, .border-blue-500').or(
page.getByText(/💡.*recommendation/i)
);
await expect(recommendationText.first()).toBeVisible();
});
});
});
// =========================================================================
// Gap 3: Overwrite Resolution Flow
// =========================================================================
test.describe('Overwrite Resolution Flow', () => {
test('3.1: should update existing host when selecting Replace with Imported resolution', async ({ page, request, testData }) => {
// Create existing host with initial config
const result = await testData.createProxyHost({
domain: 'overwrite-test.example.com',
forwardHost: 'old-server',
forwardPort: 3000,
name: 'Overwrite Test Host',
});
const namespacedDomain = result.domain;
const hostId = result.id;
await test.step('Navigate to import page and parse conflicting Caddyfile', async () => {
await page.goto('/tasks/import/caddyfile');
// Import with different config (new-server:9000)
const caddyfile = `${namespacedDomain} { reverse_proxy new-server:9000 }`;
await page.locator('textarea').fill(caddyfile);
const uploadPromise = page.waitForResponse(r =>
r.url().includes('/api/v1/import/upload') && r.status() === 200
);
await page.getByRole('button', { name: /parse|review/i }).click();
await uploadPromise;
await expect(page.getByTestId('import-review-table')).toBeVisible();
});
await test.step('Select "Replace with Imported" resolution', async () => {
// Find the row for this domain
const domainRow = page.locator('tr').filter({ hasText: namespacedDomain });
// Find the resolution dropdown within this row
const resolutionDropdown = domainRow.locator('select');
await expect(resolutionDropdown).toBeVisible();
// Select the overwrite/replace option
await resolutionDropdown.selectOption({ label: 'Replace with Imported' });
});
await test.step('Commit import', async () => {
const commitPromise = page.waitForResponse(r =>
r.url().includes('/api/v1/import/commit') && r.status() === 200
);
await page.getByRole('button', { name: /commit/i }).click();
await commitPromise;
await expect(page.getByTestId('import-success-modal')).toBeVisible();
});
await test.step('Verify existing host was updated (not duplicated)', async () => {
// Fetch the host via API
const response = await request.get(`/api/v1/proxy-hosts/${hostId}`);
expect(response.ok()).toBeTruthy();
const host = await response.json();
// Verify forward_host was updated to new-server
expect(host.forward_host).toBe('new-server');
// Verify forward_port was updated to 9000
expect(host.forward_port).toBe(9000);
// Verify no duplicate was created - fetch all hosts and check count
const allHostsResponse = await request.get('/api/v1/proxy-hosts');
expect(allHostsResponse.ok()).toBeTruthy();
const allHosts = await allHostsResponse.json();
// Count hosts with this domain
const matchingHosts = allHosts.filter((h: { domain_names: string }) =>
h.domain_names === namespacedDomain
);
expect(matchingHosts.length).toBe(1);
});
});
});
// =========================================================================
// Gap 4: Session Resume via Banner
// =========================================================================
test.describe('Session Resume via Banner', () => {
test.skip('4.1: should show pending session banner when returning to import page', async ({ page, testData }) => {
// SKIP: Browser-uploaded import sessions are transient (file-based only) and not persisted
// to the database. The import-banner only appears for database-backed sessions or
// Docker-mounted Caddyfiles. This tests an unimplemented feature for browser uploads.
const domain = generateDomain(testData, 'session-resume-test');
const caddyfile = `${domain} { reverse_proxy localhost:4000 }`;
await test.step('Create import session by parsing content', async () => {
await page.goto('/tasks/import/caddyfile');
await page.locator('textarea').fill(caddyfile);
const uploadPromise = page.waitForResponse(r =>
r.url().includes('/api/v1/import/upload') && r.status() === 200
);
await page.getByRole('button', { name: /parse|review/i }).click();
await uploadPromise;
// Session now exists
await expect(page.getByTestId('import-review-table')).toBeVisible();
});
await test.step('Navigate away from import page', async () => {
await page.goto('/proxy-hosts');
await expect(page).toHaveURL(/\/proxy-hosts/);
});
await test.step('Navigate back to import page', async () => {
// Wait for status API to be called after navigation
const statusPromise = page.waitForResponse(r =>
r.url().includes('/api/v1/import/status') && r.status() === 200
);
await page.goto('/tasks/import/caddyfile');
await statusPromise;
});
await test.step('Verify pending session banner is displayed', async () => {
// Banner should appear after status query confirms pending session
const banner = page.getByTestId('import-banner');
await expect(banner).toBeVisible({ timeout: 10000 });
await expect(banner).toContainText(/pending.*import.*session/i);
// Verify "Review Changes" button is visible
await expect(banner.getByRole('button', { name: /review.*changes/i })).toBeVisible();
// Review table should NOT be visible initially (until clicking Review Changes)
await expect(page.getByTestId('import-review-table')).not.toBeVisible();
});
});
test.skip('4.2: should restore review table with previous content when clicking Review Changes', async ({ page, testData }) => {
// SKIP: Browser-uploaded import sessions are transient (file-based only) and not persisted
// to the database. Session resume only works for Docker-mounted Caddyfiles.
// See test 4.1 skip reason for details.
const domain = generateDomain(testData, 'review-changes-test');
const caddyfile = `${domain} { reverse_proxy localhost:5000 }`;
await test.step('Create import session', async () => {
await page.goto('/tasks/import/caddyfile');
await page.locator('textarea').fill(caddyfile);
const uploadPromise = page.waitForResponse(r =>
r.url().includes('/api/v1/import/upload') && r.status() === 200
);
await page.getByRole('button', { name: /parse|review/i }).click();
await uploadPromise;
await expect(page.getByTestId('import-review-table')).toBeVisible();
});
await test.step('Navigate away and back', async () => {
await page.goto('/proxy-hosts');
// Wait for status API to be called after navigation
const statusPromise = page.waitForResponse(r =>
r.url().includes('/api/v1/import/status') && r.status() === 200
);
await page.goto('/tasks/import/caddyfile');
await statusPromise;
await expect(page.getByTestId('import-banner')).toBeVisible({ timeout: 10000 });
});
await test.step('Click Review Changes button', async () => {
const banner = page.getByTestId('import-banner');
await banner.getByRole('button', { name: /review.*changes/i }).click();
});
await test.step('Verify review table is restored with original content', async () => {
const reviewTable = page.getByTestId('import-review-table');
await expect(reviewTable).toBeVisible();
// Verify the table contains the domain from original upload
await expect(reviewTable.getByText(domain)).toBeVisible();
// Banner should no longer be visible (or integrated into review UI)
// Note: Some implementations keep banner visible but change its content
// If banner remains, it should show different text
});
});
});
// =========================================================================
// Gap 5: Name Editing in Review
// =========================================================================
test.describe('Name Editing in Review', () => {
test('5.1: should create proxy host with custom name from review table input', async ({ page, request, testData }) => {
const domain = generateDomain(testData, 'custom-name-test');
const customName = 'My Custom Proxy Name';
const caddyfile = `${domain} { reverse_proxy localhost:5000 }`;
await test.step('Navigate to import page and parse Caddyfile', async () => {
await page.goto('/tasks/import/caddyfile');
await page.locator('textarea').fill(caddyfile);
const uploadPromise = page.waitForResponse(r =>
r.url().includes('/api/v1/import/upload') && r.status() === 200
);
await page.getByRole('button', { name: /parse|review/i }).click();
await uploadPromise;
await expect(page.getByTestId('import-review-table')).toBeVisible();
});
await test.step('Edit the name field in review table', async () => {
// Find the row for this domain
const domainRow = page.locator('tr').filter({ hasText: domain });
// Find name input within this row
const nameInput = domainRow.locator('input[type="text"]');
await expect(nameInput).toBeVisible();
// Clear and fill with custom name
await nameInput.clear();
await nameInput.fill(customName);
// Verify input value was set
await expect(nameInput).toHaveValue(customName);
});
await test.step('Commit import', async () => {
const commitPromise = page.waitForResponse(r =>
r.url().includes('/api/v1/import/commit') && r.status() === 200
);
await page.getByRole('button', { name: /commit/i }).click();
await commitPromise;
await expect(page.getByTestId('import-success-modal')).toBeVisible();
});
await test.step('Verify created host has custom name', async () => {
// Fetch all proxy hosts
const response = await request.get('/api/v1/proxy-hosts');
expect(response.ok()).toBeTruthy();
const hosts = await response.json();
// Find the host with our domain
const createdHost = hosts.find((h: { domain_names: string }) =>
h.domain_names === domain
);
expect(createdHost).toBeDefined();
expect(createdHost.name).toBe(customName);
});
});
});
});