docs: comprehensive documentation polish & CI/CD automation

Major Updates:
- Rewrote all docs in beginner-friendly 'ELI5' language
- Created docs index with user journey navigation
- Added complete getting-started guide for novice users
- Set up GitHub Container Registry (GHCR) automation
- Configured GitHub Pages deployment for documentation

Documentation:
- docs/index.md - Central navigation hub
- docs/getting-started.md - Step-by-step beginner guide
- docs/github-setup.md - CI/CD setup instructions
- README.md - Complete rewrite in accessible language
- CONTRIBUTING.md - Contributor guidelines
- Multiple comprehensive API and schema docs

CI/CD Workflows:
- .github/workflows/docker-build.yml - Multi-platform builds to GHCR
- .github/workflows/docs.yml - Automated docs deployment to Pages
- Supports main (latest), development (dev), and version tags
- Automated testing of built images
- Beautiful documentation site with dark theme

Benefits:
- Zero barrier to entry for new users
- Automated Docker builds (AMD64 + ARM64)
- Professional documentation site
- No Docker Hub account needed (uses GHCR)
- Complete CI/CD pipeline

All 7 implementation phases complete - project is production ready!
This commit is contained in:
Wikid82
2025-11-18 13:11:11 -05:00
parent b9dcc6c347
commit e58fcb714d
76 changed files with 16989 additions and 99 deletions

View File

@@ -0,0 +1,160 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import ImportReviewTable from '../ImportReviewTable'
import { mockImportPreview } from '../../test/mockData'
describe('ImportReviewTable', () => {
const mockOnCommit = vi.fn(() => Promise.resolve())
const mockOnCancel = vi.fn()
afterEach(() => {
vi.clearAllMocks()
})
it('displays hosts to import', () => {
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={[]}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
expect(screen.getByText('Hosts to Import (1)')).toBeInTheDocument()
expect(screen.getByText('test.example.com')).toBeInTheDocument()
})
it('displays conflicts with resolution dropdowns', () => {
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={mockImportPreview.conflicts}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
expect(screen.getByText(/Conflicts Detected \(1\)/)).toBeInTheDocument()
expect(screen.getByText('app.local.dev')).toBeInTheDocument()
expect(screen.getByText('-- Choose action --')).toBeInTheDocument()
})
it('displays errors', () => {
const errors = ['Invalid Caddyfile syntax', 'Missing required field']
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={[]}
errors={errors}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
expect(screen.getByText('Errors')).toBeInTheDocument()
expect(screen.getByText('Invalid Caddyfile syntax')).toBeInTheDocument()
expect(screen.getByText('Missing required field')).toBeInTheDocument()
})
it('disables commit button until all conflicts are resolved', () => {
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={mockImportPreview.conflicts}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
const commitButton = screen.getByText('Commit Import')
expect(commitButton).toBeDisabled()
})
it('enables commit button when all conflicts are resolved', async () => {
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={mockImportPreview.conflicts}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
const dropdown = screen.getAllByRole('combobox')[0]
fireEvent.change(dropdown, { target: { value: 'skip' } })
await waitFor(() => {
const commitButton = screen.getByText('Commit Import')
expect(commitButton).not.toBeDisabled()
})
})
it('calls onCommit with resolutions', async () => {
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={mockImportPreview.conflicts}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
const dropdown = screen.getAllByRole('combobox')[0]
fireEvent.change(dropdown, { target: { value: 'overwrite' } })
const commitButton = screen.getByText('Commit Import')
fireEvent.click(commitButton)
await waitFor(() => {
expect(mockOnCommit).toHaveBeenCalledWith({
'app.local.dev': 'overwrite',
})
})
})
it('calls onCancel when cancel button is clicked', () => {
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={[]}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
fireEvent.click(screen.getByText('Cancel'))
expect(mockOnCancel).toHaveBeenCalledOnce()
})
it('shows conflict indicator on conflicting hosts', () => {
render(
<ImportReviewTable
hosts={[
{
domain_names: 'app.local.dev',
forward_scheme: 'http',
forward_host: 'localhost',
forward_port: 3000,
ssl_forced: false,
http2_support: false,
websocket_support: false,
},
]}
conflicts={['app.local.dev']}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
expect(screen.getByText('Conflict')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import Layout from '../Layout'
describe('Layout', () => {
it('renders the application title', () => {
render(
<BrowserRouter>
<Layout>
<div>Test Content</div>
</Layout>
</BrowserRouter>
)
expect(screen.getByText('Caddy Proxy Manager+')).toBeInTheDocument()
})
it('renders all navigation items', () => {
render(
<BrowserRouter>
<Layout>
<div>Test Content</div>
</Layout>
</BrowserRouter>
)
expect(screen.getByText('Dashboard')).toBeInTheDocument()
expect(screen.getByText('Proxy Hosts')).toBeInTheDocument()
expect(screen.getByText('Remote Servers')).toBeInTheDocument()
expect(screen.getByText('Import Caddyfile')).toBeInTheDocument()
expect(screen.getByText('Settings')).toBeInTheDocument()
})
it('renders children content', () => {
render(
<BrowserRouter>
<Layout>
<div data-testid="test-content">Test Content</div>
</Layout>
</BrowserRouter>
)
expect(screen.getByTestId('test-content')).toBeInTheDocument()
})
it('displays version information', () => {
render(
<BrowserRouter>
<Layout>
<div>Test Content</div>
</Layout>
</BrowserRouter>
)
expect(screen.getByText('Version 0.1.0')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,130 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import ProxyHostForm from '../ProxyHostForm'
import { mockRemoteServers } from '../../test/mockData'
// Mock the API
vi.mock('../../services/api', () => ({
remoteServersAPI: {
list: vi.fn(() => Promise.resolve(mockRemoteServers)),
},
}))
describe('ProxyHostForm', () => {
const mockOnSubmit = vi.fn(() => Promise.resolve())
const mockOnCancel = vi.fn()
afterEach(() => {
vi.clearAllMocks()
})
it('renders create form with empty fields', async () => {
render(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await waitFor(() => {
expect(screen.getByText('Add Proxy Host')).toBeInTheDocument()
})
expect(screen.getByPlaceholderText('example.com, www.example.com')).toHaveValue('')
})
it('renders edit form with pre-filled data', async () => {
const mockHost = {
uuid: '123',
domain_names: 'test.com',
forward_scheme: 'https',
forward_host: '192.168.1.100',
forward_port: 8443,
ssl_forced: true,
http2_support: true,
hsts_enabled: true,
hsts_subdomains: true,
block_exploits: true,
websocket_support: false,
enabled: true,
created_at: '2025-11-18T10:00:00Z',
updated_at: '2025-11-18T10:00:00Z',
}
render(
<ProxyHostForm host={mockHost} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await waitFor(() => {
expect(screen.getByText('Edit Proxy Host')).toBeInTheDocument()
})
expect(screen.getByDisplayValue('test.com')).toBeInTheDocument()
expect(screen.getByDisplayValue('192.168.1.100')).toBeInTheDocument()
})
it('loads remote servers for quick select', async () => {
render(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await waitFor(() => {
expect(screen.getByText(/Local Docker Registry/)).toBeInTheDocument()
})
})
it('calls onCancel when cancel button is clicked', async () => {
render(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await waitFor(() => {
expect(screen.getByText('Cancel')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('Cancel'))
expect(mockOnCancel).toHaveBeenCalledOnce()
})
it('submits form with correct data', async () => {
render(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
const hostInput = screen.getByPlaceholderText('192.168.1.100')
const portInput = screen.getByDisplayValue('80')
fireEvent.change(domainInput, { target: { value: 'newsite.com' } })
fireEvent.change(hostInput, { target: { value: '10.0.0.1' } })
fireEvent.change(portInput, { target: { value: '9000' } })
fireEvent.click(screen.getByText('Create'))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(
expect.objectContaining({
domain_names: 'newsite.com',
forward_host: '10.0.0.1',
forward_port: 9000,
})
)
})
})
it('handles SSL and WebSocket checkboxes', async () => {
render(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await waitFor(() => {
expect(screen.getByLabelText('Force SSL')).toBeInTheDocument()
})
const sslCheckbox = screen.getByLabelText('Force SSL')
const wsCheckbox = screen.getByLabelText('WebSocket Support')
expect(sslCheckbox).not.toBeChecked()
expect(wsCheckbox).not.toBeChecked()
fireEvent.click(sslCheckbox)
fireEvent.click(wsCheckbox)
expect(sslCheckbox).toBeChecked()
expect(wsCheckbox).toBeChecked()
})
})

View File

@@ -0,0 +1,124 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import RemoteServerForm from '../RemoteServerForm'
// Mock the API
vi.mock('../../services/api', () => ({
remoteServersAPI: {
test: vi.fn(() => Promise.resolve({ reachable: true, address: 'localhost:8080' })),
},
}))
describe('RemoteServerForm', () => {
const mockOnSubmit = vi.fn(() => Promise.resolve())
const mockOnCancel = vi.fn()
afterEach(() => {
vi.clearAllMocks()
})
it('renders create form', () => {
render(
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
expect(screen.getByText('Add Remote Server')).toBeInTheDocument()
expect(screen.getByPlaceholderText('My Production Server')).toHaveValue('')
})
it('renders edit form with pre-filled data', () => {
const mockServer = {
uuid: '123',
name: 'Test Server',
provider: 'docker',
host: 'localhost',
port: 5000,
username: 'admin',
enabled: true,
reachable: true,
created_at: '2025-11-18T10:00:00Z',
updated_at: '2025-11-18T10:00:00Z',
}
render(
<RemoteServerForm server={mockServer} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
expect(screen.getByText('Edit Remote Server')).toBeInTheDocument()
expect(screen.getByDisplayValue('Test Server')).toBeInTheDocument()
expect(screen.getByDisplayValue('localhost')).toBeInTheDocument()
expect(screen.getByDisplayValue('5000')).toBeInTheDocument()
})
it('shows test connection button only in edit mode', () => {
const { rerender } = render(
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
expect(screen.queryByText('Test Connection')).not.toBeInTheDocument()
const mockServer = {
uuid: '123',
name: 'Test Server',
provider: 'docker',
host: 'localhost',
port: 5000,
enabled: true,
reachable: false,
created_at: '2025-11-18T10:00:00Z',
updated_at: '2025-11-18T10:00:00Z',
}
rerender(
<RemoteServerForm server={mockServer} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
expect(screen.getByText('Test Connection')).toBeInTheDocument()
})
it('calls onCancel when cancel button is clicked', () => {
render(
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
fireEvent.click(screen.getByText('Cancel'))
expect(mockOnCancel).toHaveBeenCalledOnce()
})
it('submits form with correct data', async () => {
render(
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
const nameInput = screen.getByPlaceholderText('My Production Server')
const hostInput = screen.getByPlaceholderText('192.168.1.100')
const portInput = screen.getByDisplayValue('80')
fireEvent.change(nameInput, { target: { value: 'New Server' } })
fireEvent.change(hostInput, { target: { value: '10.0.0.5' } })
fireEvent.change(portInput, { target: { value: '9090' } })
fireEvent.click(screen.getByText('Create'))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(
expect.objectContaining({
name: 'New Server',
host: '10.0.0.5',
port: 9090,
})
)
})
})
it('handles provider selection', () => {
render(
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
const providerSelect = screen.getByDisplayValue('Generic')
fireEvent.change(providerSelect, { target: { value: 'docker' } })
expect(providerSelect).toHaveValue('docker')
})
})