diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bc2162d --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Headlamp E2E Test Configuration + +# Headlamp instance URL +HEADLAMP_URL=https://headlamp.animaniacs.farh.net + +# Authentication: Choose ONE of the following methods + +# Option 1: OIDC Authentication (Authentik) +# AUTHENTIK_USERNAME=your-username +# AUTHENTIK_PASSWORD=your-password + +# Option 2: Token Authentication +# HEADLAMP_TOKEN=your-headlamp-token diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml new file mode 100644 index 0000000..c21d238 --- /dev/null +++ b/.github/workflows/e2e.yaml @@ -0,0 +1,53 @@ +name: E2E Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Run E2E tests + run: npm run e2e + env: + HEADLAMP_URL: ${{ secrets.HEADLAMP_URL || 'https://headlamp.animaniacs.farh.net' }} + HEADLAMP_TOKEN: ${{ secrets.HEADLAMP_TOKEN }} + AUTHENTIK_USERNAME: ${{ secrets.AUTHENTIK_USERNAME }} + AUTHENTIK_PASSWORD: ${{ secrets.AUTHENTIK_PASSWORD }} + + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: failure() + with: + name: test-results + path: test-results/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index e219ede..7604097 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ dist/ e2e/.auth/ test-results/ .playwright-mcp/ +.env +.env.local diff --git a/e2e/README.md b/e2e/README.md index 3018861..8ca4379 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -4,14 +4,20 @@ Playwright-based smoke tests that validate the Polaris plugin against a live Hea ## CI -E2E tests run automatically in Gitea Actions on pushes to `main` and pull requests. The workflow (`.gitea/workflows/e2e.yaml`) uses Authentik OIDC for authentication via repo secrets. +E2E tests run automatically in GitHub Actions on pushes to `main` and pull requests. The workflow (`.github/workflows/e2e.yaml`) uses either Authentik OIDC or token-based authentication via repository secrets. -### Required Gitea secrets +### Required GitHub Secrets -| Secret | Description | -| -------------------- | -------------------------------------------------------------- | -| `AUTHENTIK_USERNAME` | Authentik email or username for a CI user with Headlamp access | -| `AUTHENTIK_PASSWORD` | Password for that user | +Configure these in GitHub repository settings (Settings → Secrets and variables → Actions): + +| Secret | Required | Description | +| -------------------- | -------- | -------------------------------------------------------------- | +| `HEADLAMP_URL` | Optional | Headlamp instance URL (defaults to `https://headlamp.animaniacs.farh.net`) | +| `AUTHENTIK_USERNAME` | OIDC | Authentik email or username for a CI user with Headlamp access | +| `AUTHENTIK_PASSWORD` | OIDC | Password for that user | +| `HEADLAMP_TOKEN` | Token | Kubernetes service account token (alternative to OIDC) | + +Set either `AUTHENTIK_USERNAME` + `AUTHENTIK_PASSWORD` **or** `HEADLAMP_TOKEN`. OIDC takes priority if both are set. ## Running Locally @@ -56,3 +62,233 @@ Set either `AUTHENTIK_USERNAME` + `AUTHENTIK_PASSWORD` or `HEADLAMP_TOKEN`. OIDC - **Namespace detail** — Clicking a namespace shows its score and resource table These are smoke tests against real cluster data. They verify the plugin loads and renders without errors, not specific data values. + +## Test Coverage + +### Current Tests (`polaris.spec.ts`) + +1. **`sidebar contains Polaris entry`** + - Verifies Polaris appears in the navigation sidebar + - Ensures plugin successfully registered sidebar entry + +2. **`overview page renders cluster score`** + - Navigates to `/c/main/polaris` + - Checks for "Polaris — Overview" heading + - Verifies cluster score percentage is displayed + - Validates data fetching and rendering + +3. **`namespaces page renders table with namespace buttons`** + - Navigates to `/c/main/polaris/namespaces` + - Checks for "Polaris — Namespaces" heading + - Verifies table is visible with at least one row + - Ensures namespace buttons are clickable + +4. **`namespace detail drawer opens from table button`** + - Clicks first namespace button in table + - Verifies drawer opens with namespace name in heading + - Checks "Namespace Score" section is visible + - Confirms "Resources" table is displayed + - Validates URL hash is updated with namespace name + +5. **`namespace detail drawer closes with Escape key`** + - Opens namespace drawer + - Presses Escape key + - Verifies drawer closes + - Checks URL hash is cleared + +6. **`namespace detail drawer opens from URL hash`** + - Navigates directly to `/c/main/polaris/namespaces#` + - Verifies drawer automatically opens + - Checks namespace details are displayed + +## Prerequisites + +### Cluster Requirements + +1. **Polaris Deployment** + ```bash + # Verify Polaris is running + kubectl -n polaris get pods + kubectl -n polaris get svc polaris-dashboard + ``` + +2. **Polaris Audit Data** + ```bash + # Check if Polaris has generated audit results + kubectl get --raw /api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/results.json | jq '.AuditTime' + ``` + +3. **RBAC Permissions** + - Headlamp service account (or test user) needs `get` on `services/proxy` for `polaris-dashboard` + - See main README for RBAC setup + +### Local Setup + +```bash +# 1. Install dependencies +npm install +npx playwright install chromium + +# 2. Create .env file (optional, for persistent config) +cp .env.example .env + +# 3. Set environment variables +export HEADLAMP_URL=https://your-headlamp-instance.com +export HEADLAMP_TOKEN=$(kubectl create token headlamp -n kube-system) + +# 4. Run tests +npm run e2e +``` + +## Debugging + +### Run in Headed Mode + +See the browser UI while tests run: + +```bash +npm run e2e:headed +``` + +### Enable Debug Mode + +Step through tests with Playwright Inspector: + +```bash +npx playwright test --debug +``` + +### Generate Trace + +Record full trace for failed tests: + +```bash +npx playwright test --trace on +npx playwright show-trace test-results//trace.zip +``` + +### Screenshot on Failure + +Tests automatically capture screenshots on failure in `test-results/` + +### Common Issues + +**Auth fails with "Sign In button not found":** +- Check HEADLAMP_URL is correct +- Verify Headlamp is accessible +- Ensure OIDC is configured if using Authentik + +**Polaris sidebar entry not found:** +- Plugin may not be installed: Check Settings → Plugins in Headlamp +- Plugin may have failed to load: Check browser console +- Clear browser cache and hard refresh + +**Cluster score not displayed:** +- Polaris may not have audit data yet +- Check Polaris is running: `kubectl -n polaris get pods` +- Verify service proxy: `kubectl get --raw /api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/results.json` + +**Namespace table empty:** +- Polaris hasn't run audit yet (wait a few minutes) +- Check Polaris logs: `kubectl -n polaris logs -l app.kubernetes.io/name=polaris` + +## Writing New Tests + +### Example: Testing Plugin Settings + +```typescript +test('plugin settings page shows Polaris configuration', async ({ page }) => { + await page.goto('/c/main/settings/plugins'); + + // Find and click Polaris plugin + await page.getByText('headlamp-polaris-plugin').click(); + + // Check settings are visible + await expect(page.getByText('Polaris Settings')).toBeVisible(); + await expect(page.getByText('Refresh Interval')).toBeVisible(); + await expect(page.getByText('Dashboard URL')).toBeVisible(); +}); +``` + +### Example: Testing App Bar Badge + +```typescript +test('app bar displays Polaris score badge', async ({ page }) => { + await page.goto('/c/main'); + + // Badge should be visible in app bar + const badge = page.getByRole('button', { name: /Polaris: \d+%/ }); + await expect(badge).toBeVisible(); + + // Clicking should navigate to overview + await badge.click(); + await expect(page).toHaveURL(/\/c\/main\/polaris$/); +}); +``` + +### Example: Testing Dark Mode + +```typescript +test('plugin UI adapts to dark mode', async ({ page }) => { + await page.goto('/c/main/polaris'); + + // Toggle dark mode + await page.getByRole('button', { name: /theme/i }).click(); + + // Check background color changes + const body = page.locator('body'); + await expect(body).toHaveCSS('background-color', 'rgb(18, 18, 18)'); + + // Plugin components should adapt + const sectionBox = page.locator('[class*="MuiPaper"]').first(); + await expect(sectionBox).not.toHaveCSS('background-color', 'rgb(255, 255, 255)'); +}); +``` + +## CI/CD Integration + +Tests run automatically in GitHub Actions on pushes to `main` and pull requests. See `.github/workflows/e2e.yaml` for workflow configuration. + +### Required Secrets + +Configure these in GitHub repository settings (Settings → Secrets and variables → Actions): + +- `HEADLAMP_URL` (optional): Headlamp instance URL +- `AUTHENTIK_USERNAME` + `AUTHENTIK_PASSWORD` (for OIDC auth) +- OR `HEADLAMP_TOKEN` (for token-based auth) + +### Workflow Overview + +1. Checkout code +2. Setup Node.js 20 with npm cache +3. Install dependencies (`npm ci`) +4. Install Playwright browsers (`chromium` only) +5. Run auth setup (creates session in `e2e/.auth/state.json`) +6. Run all E2E tests +7. Upload artifacts on failure: + - `playwright-report/` - HTML test report + - `test-results/` - Screenshots, traces, videos + +### Manual Trigger + +You can manually trigger E2E tests from GitHub Actions: +1. Go to Actions → E2E Tests +2. Click "Run workflow" +3. Select branch and run + +## Best Practices + +1. **Use semantic selectors**: `getByRole`, `getByText` over CSS selectors +2. **Wait for visibility**: Use `await expect(...).toBeVisible()` instead of `waitForTimeout` +3. **Keep tests independent**: Each test should work in isolation +4. **Test user flows**: Complete journeys, not just page loads +5. **Clean up state**: Close drawers/modals after tests +6. **Use storage state**: Reuse auth across tests (already configured) +7. **Parallelize carefully**: Currently disabled due to shared state + +## Resources + +- [Playwright Documentation](https://playwright.dev/) +- [Playwright Best Practices](https://playwright.dev/docs/best-practices) +- [Headlamp Plugin Development](https://headlamp.dev/docs/latest/development/plugins/) +- [Project Main README](../README.md) diff --git a/e2e/appbar.spec.ts b/e2e/appbar.spec.ts new file mode 100644 index 0000000..8b4b6c0 --- /dev/null +++ b/e2e/appbar.spec.ts @@ -0,0 +1,94 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Polaris app bar badge', () => { + test('badge displays cluster score in app bar', async ({ page }) => { + await page.goto('/c/main'); + + // Wait for page to load + await expect(page.getByRole('navigation', { name: 'Navigation' })).toBeVisible(); + + // Badge should be visible in app bar with score percentage + const badge = page.getByRole('button', { name: /Polaris: \d+%/ }); + await expect(badge).toBeVisible({ timeout: 15_000 }); + + // Badge should show shield emoji + await expect(badge).toContainText('🛡️'); + }); + + test('clicking badge navigates to overview page', async ({ page }) => { + await page.goto('/c/main'); + + // Find and click the badge + const badge = page.getByRole('button', { name: /Polaris: \d+%/ }); + await expect(badge).toBeVisible({ timeout: 15_000 }); + await badge.click(); + + // Should navigate to Polaris overview + await expect(page).toHaveURL(/\/c\/main\/polaris$/); + await expect(page.getByRole('heading', { name: 'Polaris — Overview' })).toBeVisible(); + }); + + test('badge color reflects score level', async ({ page }) => { + await page.goto('/c/main'); + + // Get the badge + const badge = page.getByRole('button', { name: /Polaris: \d+%/ }); + await expect(badge).toBeVisible({ timeout: 15_000 }); + + // Extract score from button text + const badgeText = await badge.textContent(); + const scoreMatch = badgeText?.match(/(\d+)%/); + expect(scoreMatch).toBeTruthy(); + + const score = parseInt(scoreMatch![1]); + + // Check background color matches score level + const bgColor = await badge.evaluate(el => + window.getComputedStyle(el).backgroundColor + ); + + if (score >= 80) { + // Green: rgb(76, 175, 80) or #4caf50 + expect(bgColor).toMatch(/rgb\(76,\s*175,\s*80\)/); + } else if (score >= 50) { + // Orange: rgb(255, 152, 0) or #ff9800 + expect(bgColor).toMatch(/rgb\(255,\s*152,\s*0\)/); + } else { + // Red: rgb(244, 67, 54) or #f44336 + expect(bgColor).toMatch(/rgb\(244,\s*67,\s*54\)/); + } + }); + + test('badge updates when navigating between clusters', async ({ page }) => { + // This test assumes multi-cluster setup; skip if only one cluster + await page.goto('/c/main'); + + // Get initial badge score + const badge = page.getByRole('button', { name: /Polaris: \d+%/ }); + await expect(badge).toBeVisible({ timeout: 15_000 }); + const initialScore = await badge.textContent(); + + // Try to switch clusters (if available) + const clusterSelector = page.getByRole('button', { name: /cluster/i }); + if (await clusterSelector.isVisible()) { + // Note: This part will only work in multi-cluster setups + // For single-cluster, this test will just verify badge persists + await clusterSelector.click(); + + // Select different cluster if available + const clusterOptions = page.getByRole('menuitem'); + const count = await clusterOptions.count(); + + if (count > 1) { + await clusterOptions.nth(1).click(); + + // Badge should update or disappear (if new cluster doesn't have Polaris) + // This is just verifying no crash occurs + await page.waitForTimeout(2000); + } + } + + // Badge should still be functional + await expect(badge).toBeEnabled(); + }); +}); diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts new file mode 100644 index 0000000..3856433 --- /dev/null +++ b/e2e/settings.spec.ts @@ -0,0 +1,88 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Polaris plugin settings', () => { + test('settings page shows configuration options', async ({ page }) => { + await page.goto('/c/main/settings/plugins'); + + // Find Polaris plugin in the list + const pluginCard = page.locator('text=headlamp-polaris-plugin').first(); + await expect(pluginCard).toBeVisible(); + + // Click to view settings (if settings are displayed inline, they should already be visible) + // Note: Headlamp v0.39.0+ shows settings inline on the plugins page + await expect(page.getByText('Polaris Settings')).toBeVisible({ timeout: 15_000 }); + }); + + test('refresh interval setting is configurable', async ({ page }) => { + await page.goto('/c/main/settings/plugins'); + + // Navigate to Polaris settings + await expect(page.getByText('Polaris Settings')).toBeVisible({ timeout: 15_000 }); + + // Find the refresh interval dropdown + const intervalSelect = page.locator('select').filter({ hasText: /minute|second/ }); + await expect(intervalSelect).toBeVisible(); + + // Get current value + const currentValue = await intervalSelect.inputValue(); + + // Change to a different value + const newValue = currentValue === '300' ? '600' : '300'; + await intervalSelect.selectOption(newValue); + + // Value should be updated + await expect(intervalSelect).toHaveValue(newValue); + }); + + test('dashboard URL setting is configurable', async ({ page }) => { + await page.goto('/c/main/settings/plugins'); + + // Navigate to Polaris settings + await expect(page.getByText('Polaris Settings')).toBeVisible({ timeout: 15_000 }); + + // Find the dashboard URL input + const urlInput = page.getByPlaceholder(/polaris-dashboard/); + await expect(urlInput).toBeVisible(); + + // Input should have the default proxy URL or custom URL + const currentUrl = await urlInput.inputValue(); + expect(currentUrl).toBeTruthy(); + + // Examples text should be visible + await expect(page.getByText('Examples:')).toBeVisible(); + await expect(page.getByText(/K8s proxy:/)).toBeVisible(); + }); + + test('connection test button is available', async ({ page }) => { + await page.goto('/c/main/settings/plugins'); + + // Navigate to Polaris settings + await expect(page.getByText('Polaris Settings')).toBeVisible({ timeout: 15_000 }); + + // Find and verify test connection button + const testButton = page.getByRole('button', { name: /test connection/i }); + await expect(testButton).toBeVisible(); + await expect(testButton).toBeEnabled(); + }); + + test('connection test works with valid URL', async ({ page }) => { + await page.goto('/c/main/settings/plugins'); + + // Navigate to Polaris settings + await expect(page.getByText('Polaris Settings')).toBeVisible({ timeout: 15_000 }); + + // Click test connection + const testButton = page.getByRole('button', { name: /test connection/i }); + await testButton.click(); + + // Wait for either success or error message + // Note: This will succeed if Polaris is accessible, fail otherwise + await page.waitForSelector('text=/Connected successfully|Connection failed/', { + timeout: 15_000, + }); + + // Either success or failure is acceptable (depends on environment) + const result = await page.textContent('body'); + expect(result).toMatch(/(Connected successfully|Connection failed)/); + }); +});