feat: improve Caddy import with directive detection and warnings

Add backend detection for import directives with actionable error message
Display warning banner for unsupported features (file_server, redirects)
Ensure multi-file import button always visible in upload form
Add accessibility attributes (role, aria-labelledby) to multi-site modal
Fix 12 frontend unit tests with outdated hook mock interfaces
Add data-testid attributes for E2E test reliability
Fix JSON syntax in 4 translation files (missing commas)
Create 6 diagnostic E2E tests covering import edge cases
Addresses Reddit feedback on Caddy import UX confusion
This commit is contained in:
GitHub Actions
2026-01-30 15:29:49 +00:00
parent 76440c8364
commit fc2df97fe1
17 changed files with 7396 additions and 0 deletions

View File

@@ -0,0 +1,249 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import ImportReviewTable from '../ImportReviewTable'
describe('ImportReviewTable - Status Display', () => {
const mockOnCommit = vi.fn()
const mockOnCancel = vi.fn()
it('displays New badge for hosts without conflicts', () => {
const hosts = [
{
domain_names: 'app.example.com',
forward_host: 'localhost',
forward_port: 8080,
},
]
render(
<ImportReviewTable
hosts={hosts}
conflicts={[]}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
expect(screen.getByText('New')).toBeInTheDocument()
})
it('displays Conflict badge for hosts in conflicts array', () => {
const hosts = [
{
domain_names: 'conflict.example.com',
forward_host: 'localhost',
forward_port: 80,
},
]
render(
<ImportReviewTable
hosts={hosts}
conflicts={['conflict.example.com']}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
expect(screen.getByText('Conflict')).toBeInTheDocument()
expect(screen.queryByText('New')).not.toBeInTheDocument()
})
it('shows expand button only for hosts with conflicts', () => {
const hosts = [
{
domain_names: 'conflict.example.com',
forward_host: 'localhost',
forward_port: 80,
},
{
domain_names: 'new.example.com',
forward_host: 'localhost',
forward_port: 8080,
},
]
const conflictDetails = {
'conflict.example.com': {
existing: {
forward_scheme: 'http',
forward_host: 'localhost',
forward_port: 80,
ssl_forced: false,
websocket: false,
enabled: true,
},
imported: {
forward_scheme: 'http',
forward_host: 'localhost',
forward_port: 8080,
ssl_forced: false,
websocket: false,
},
},
}
render(
<ImportReviewTable
hosts={hosts}
conflicts={['conflict.example.com']}
conflictDetails={conflictDetails}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
// Expand button shows as triangle character
const expandButtons = screen.getAllByRole('button', { name: /▶/ })
expect(expandButtons).toHaveLength(1)
})
it('expands to show conflict details when clicked', async () => {
const hosts = [
{
domain_names: 'conflict.example.com',
forward_host: 'localhost',
forward_port: 80,
},
]
const conflictDetails = {
'conflict.example.com': {
existing: {
forward_scheme: 'http',
forward_host: 'localhost',
forward_port: 80,
ssl_forced: false,
websocket: false,
enabled: true,
},
imported: {
forward_scheme: 'http',
forward_host: 'localhost',
forward_port: 8080,
ssl_forced: false,
websocket: false,
},
},
}
render(
<ImportReviewTable
hosts={hosts}
conflicts={['conflict.example.com']}
conflictDetails={conflictDetails}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
const expandButton = screen.getByRole('button', { name: /▶/ })
fireEvent.click(expandButton)
expect(screen.getByText('Current Configuration')).toBeInTheDocument()
expect(screen.getByText('Imported Configuration')).toBeInTheDocument()
})
it('collapses conflict details when clicked again', () => {
const hosts = [
{
domain_names: 'conflict.example.com',
forward_host: 'localhost',
forward_port: 80,
},
]
const conflictDetails = {
'conflict.example.com': {
existing: {
forward_scheme: 'http',
forward_host: 'localhost',
forward_port: 80,
ssl_forced: false,
websocket: false,
enabled: true,
},
imported: {
forward_scheme: 'http',
forward_host: 'localhost',
forward_port: 8080,
ssl_forced: false,
websocket: false,
},
},
}
render(
<ImportReviewTable
hosts={hosts}
conflicts={['conflict.example.com']}
conflictDetails={conflictDetails}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
const expandButton = screen.getByRole('button', { name: /▶/ })
// Expand
fireEvent.click(expandButton)
expect(screen.getByText('Current Configuration')).toBeInTheDocument()
// Collapse (now button shows ▼)
const collapseButton = screen.getByRole('button', { name: /▼/ })
fireEvent.click(collapseButton)
expect(screen.queryByText('Current Configuration')).not.toBeInTheDocument()
})
it('shows conflict resolution dropdown for conflicting hosts', () => {
const hosts = [
{
domain_names: 'conflict.example.com',
forward_host: 'localhost',
forward_port: 80,
},
]
render(
<ImportReviewTable
hosts={hosts}
conflicts={['conflict.example.com']}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
const select = screen.getByRole('combobox')
expect(select).toBeInTheDocument()
expect(screen.getByText('Keep Existing (Skip Import)')).toBeInTheDocument()
expect(screen.getByText('Replace with Imported')).toBeInTheDocument()
})
it('shows "Will be imported" text for non-conflicting hosts', () => {
const hosts = [
{
domain_names: 'new.example.com',
forward_host: 'localhost',
forward_port: 8080,
},
]
render(
<ImportReviewTable
hosts={hosts}
conflicts={[]}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
expect(screen.getByText('Will be imported')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,85 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import ImportCaddy from '../ImportCaddy'
// Create a simple mock for useImport that returns the error state
const mockUseImport = vi.fn()
// Mock the hooks
vi.mock('../../hooks/useImport', () => ({
useImport: () => mockUseImport(),
}))
// Mock translation
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
return ({ children }: { children: React.ReactNode }) => (
<BrowserRouter>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</BrowserRouter>
)
}
describe('ImportCaddy - Import Detection Error Display', () => {
it('displays error message when import directives detected', () => {
// Mock the hook to return error state with imports
mockUseImport.mockReturnValue({
session: null,
preview: null,
loading: false,
error: 'This Caddyfile contains import directives. Please use the multi-file import flow to upload all referenced files together.',
commitSuccess: false,
commitResult: null,
clearCommitResult: vi.fn(),
upload: vi.fn(),
commit: vi.fn(),
cancel: vi.fn(),
})
render(<ImportCaddy />, { wrapper: createWrapper() })
// Check main error message is displayed
expect(screen.getByText(/this caddyfile contains import directives/i)).toBeInTheDocument()
// Check multi-site import button is available as alternative
const multiSiteButton = screen.getByTestId('multi-file-import-button')
expect(multiSiteButton).toBeInTheDocument()
})
it('displays plain error when no imports detected', () => {
// Mock the hook to return error without imports
mockUseImport.mockReturnValue({
session: null,
preview: null,
loading: false,
error: 'no sites found in uploaded Caddyfile',
commitSuccess: false,
commitResult: null,
clearCommitResult: vi.fn(),
upload: vi.fn(),
commit: vi.fn(),
cancel: vi.fn(),
})
render(<ImportCaddy />, { wrapper: createWrapper() })
// Should show error message
expect(screen.getByText('no sites found in uploaded Caddyfile')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,204 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import userEvent from '@testing-library/user-event'
import ImportCaddy from '../ImportCaddy'
import { useImport } from '../../hooks/useImport'
// Mock the hooks and API calls
vi.mock('../../hooks/useImport')
vi.mock('../../api/backups', () => ({
createBackup: vi.fn().mockResolvedValue({}),
}))
const mockUseImport = vi.mocked(useImport)
describe('ImportCaddy - Multi-File Modal', () => {
const defaultMockReturn = {
session: null,
preview: null,
loading: false,
error: null,
commitSuccess: false,
commitResult: null,
clearCommitResult: vi.fn(),
upload: vi.fn(),
commit: vi.fn(),
cancel: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
mockUseImport.mockReturnValue(defaultMockReturn)
})
it('renders multi-file button when no session exists', () => {
render(
<BrowserRouter>
<ImportCaddy />
</BrowserRouter>
)
const button = screen.getByTestId('multi-file-import-button')
expect(button).toBeInTheDocument()
expect(button).toHaveTextContent(/multi.*site.*import/i)
})
it('shows import banner when session exists (multi-file hidden during active session)', () => {
mockUseImport.mockReturnValueOnce({
...defaultMockReturn,
session: { id: 'test-session-id', state: 'reviewing', created_at: '', updated_at: '' },
})
render(
<BrowserRouter>
<ImportCaddy />
</BrowserRouter>
)
// When a session exists, the import banner is shown instead of the upload form
expect(screen.getByTestId('import-banner')).toBeInTheDocument()
// Multi-file button is part of upload form, which is hidden during active session
expect(screen.queryByTestId('multi-file-import-button')).not.toBeInTheDocument()
})
it('opens modal when multi-file button is clicked', async () => {
const user = userEvent.setup()
render(
<BrowserRouter>
<ImportCaddy />
</BrowserRouter>
)
const button = screen.getByTestId('multi-file-import-button')
await user.click(button)
await waitFor(() => {
const modal = screen.getByTestId('multi-site-modal')
expect(modal).toBeInTheDocument()
})
})
it('modal has correct accessibility attributes', async () => {
const user = userEvent.setup()
render(
<BrowserRouter>
<ImportCaddy />
</BrowserRouter>
)
const button = screen.getByTestId('multi-file-import-button')
await user.click(button)
await waitFor(() => {
const modal = screen.getByRole('dialog')
expect(modal).toBeInTheDocument()
expect(modal).toHaveAttribute('aria-modal', 'true')
expect(modal).toHaveAttribute('aria-labelledby', 'multi-site-modal-title')
expect(modal).toHaveAttribute('data-testid', 'multi-site-modal')
})
})
it('modal contains correct title for screen readers', async () => {
const user = userEvent.setup()
render(
<BrowserRouter>
<ImportCaddy />
</BrowserRouter>
)
const button = screen.getByTestId('multi-file-import-button')
await user.click(button)
await waitFor(() => {
// Use heading role to specifically target the modal title, not the button
const title = screen.getByRole('heading', { name: 'Multi-site Import' })
expect(title).toBeInTheDocument()
expect(title).toHaveAttribute('id', 'multi-site-modal-title')
})
})
it('closes modal when clicking outside overlay', async () => {
const user = userEvent.setup()
render(
<BrowserRouter>
<ImportCaddy />
</BrowserRouter>
)
// Open modal
const button = screen.getByTestId('multi-file-import-button')
await user.click(button)
// Wait for modal to appear
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Click overlay (the semi-transparent background)
const overlay = screen.getByRole('dialog').querySelector('.bg-black\\/60')
expect(overlay).toBeInTheDocument()
if (overlay) {
await user.click(overlay)
// Modal should close
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
}
})
it('opens modal and shows it correctly', async () => {
const user = userEvent.setup()
render(
<BrowserRouter>
<ImportCaddy />
</BrowserRouter>
)
const button = screen.getByTestId('multi-file-import-button')
await user.click(button)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Verify modal is displayed
const modal = screen.getByRole('dialog')
expect(modal).toBeInTheDocument()
})
it('modal button text matches E2E test selector', () => {
render(
<BrowserRouter>
<ImportCaddy />
</BrowserRouter>
)
// E2E test uses: page.getByRole('button', { name: /multi.*file|multi.*site/i })
const button = screen.getByRole('button', { name: /multi.*file|multi.*site/i })
expect(button).toBeInTheDocument()
})
it('handles error state from import', async () => {
mockUseImport.mockReturnValueOnce({
...defaultMockReturn,
error: 'Import directives detected',
})
render(
<BrowserRouter>
<ImportCaddy />
</BrowserRouter>
)
// Error message should display
expect(screen.getByText(/Import directives detected/i)).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,188 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import ImportCaddy from '../ImportCaddy'
// Create a simple mock for useImport that returns the preview state
const mockUseImport = vi.fn()
// Mock the hooks
vi.mock('../../hooks/useImport', () => ({
useImport: () => mockUseImport(),
}))
// Mock translation
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
return ({ children }: { children: React.ReactNode }) => (
<BrowserRouter>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</BrowserRouter>
)
}
describe('ImportCaddy - Warning Display', () => {
it('displays empty file warning when session exists but no hosts found', () => {
// Mock the hook to return session with empty hosts
mockUseImport.mockReturnValue({
session: { id: 'test-session', state: 'reviewing', created_at: '', updated_at: '' },
preview: {
session: { id: 'test-session', state: 'reviewing' },
preview: {
hosts: [],
conflicts: [],
errors: [],
},
},
loading: false,
error: null,
commitSuccess: false,
commitResult: null,
clearCommitResult: vi.fn(),
upload: vi.fn(),
commit: vi.fn(),
cancel: vi.fn(),
})
render(<ImportCaddy />, { wrapper: createWrapper() })
// Check empty file warning is displayed
expect(screen.getByText('importCaddy.noDomainsFound')).toBeInTheDocument()
expect(screen.getByText('importCaddy.emptyFileWarning')).toBeInTheDocument()
})
it('displays import banner when session exists', () => {
// Mock the hook to return session with hosts
mockUseImport.mockReturnValue({
session: { id: 'test-session', state: 'reviewing', created_at: '', updated_at: '' },
preview: {
session: { id: 'test-session', state: 'reviewing' },
preview: {
hosts: [{ domain_names: 'example.com' }],
conflicts: [],
errors: [],
},
},
loading: false,
error: null,
commitSuccess: false,
commitResult: null,
clearCommitResult: vi.fn(),
upload: vi.fn(),
commit: vi.fn(),
cancel: vi.fn(),
})
render(<ImportCaddy />, { wrapper: createWrapper() })
// Check import banner is visible
expect(screen.getByTestId('import-banner')).toBeInTheDocument()
})
it('does not display empty file warning when hosts exist', () => {
// Mock the hook to return session with hosts
mockUseImport.mockReturnValue({
session: { id: 'test-session', state: 'reviewing', created_at: '', updated_at: '' },
preview: {
session: { id: 'test-session', state: 'reviewing' },
preview: {
hosts: [{ domain_names: 'example.com' }],
conflicts: [],
errors: [],
},
},
loading: false,
error: null,
commitSuccess: false,
commitResult: null,
clearCommitResult: vi.fn(),
upload: vi.fn(),
commit: vi.fn(),
cancel: vi.fn(),
})
render(<ImportCaddy />, { wrapper: createWrapper() })
// Check empty file warning is NOT visible
expect(screen.queryByText('importCaddy.noDomainsFound')).not.toBeInTheDocument()
})
it('does not display import banner when no session exists', () => {
// Mock the hook to return null session
mockUseImport.mockReturnValue({
session: null,
preview: null,
loading: false,
error: null,
commitSuccess: false,
commitResult: null,
clearCommitResult: vi.fn(),
upload: vi.fn(),
commit: vi.fn(),
cancel: vi.fn(),
})
render(<ImportCaddy />, { wrapper: createWrapper() })
// Check import banner is NOT visible
expect(screen.queryByTestId('import-banner')).not.toBeInTheDocument()
})
it('displays error message when error exists', () => {
// Mock the hook to return error state
mockUseImport.mockReturnValue({
session: null,
preview: null,
loading: false,
error: 'Failed to parse Caddyfile',
commitSuccess: false,
commitResult: null,
clearCommitResult: vi.fn(),
upload: vi.fn(),
commit: vi.fn(),
cancel: vi.fn(),
})
render(<ImportCaddy />, { wrapper: createWrapper() })
// Check error message is displayed
expect(screen.getByText('Failed to parse Caddyfile')).toBeInTheDocument()
})
it('shows upload form when no session exists', () => {
// Mock the hook to return null session
mockUseImport.mockReturnValue({
session: null,
preview: null,
loading: false,
error: null,
commitSuccess: false,
commitResult: null,
clearCommitResult: vi.fn(),
upload: vi.fn(),
commit: vi.fn(),
cancel: vi.fn(),
})
render(<ImportCaddy />, { wrapper: createWrapper() })
// Check upload form elements are visible
expect(screen.getByTestId('import-dropzone')).toBeInTheDocument()
expect(screen.getByTestId('multi-file-import-button')).toBeInTheDocument()
})
})