diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml new file mode 100644 index 0000000..61abfb9 --- /dev/null +++ b/.github/workflows/e2e.yaml @@ -0,0 +1,99 @@ +name: E2E Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: e2e-${{ github.repository }} + cancel-in-progress: false + +env: + E2E_NAMESPACE: headlamp-dev + E2E_RELEASE: headlamp-e2e + HEADLAMP_VERSION: v0.40.1 + +jobs: + e2e: + runs-on: runners-privilegedescalation + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Setup kubectl + uses: azure/setup-kubectl@v4 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build plugin + run: pnpm build + + - name: Deploy E2E Headlamp instance + run: scripts/deploy-e2e-headlamp.sh + + - name: Load E2E environment + run: | + if [ -f .env.e2e ]; then + cat .env.e2e >> "$GITHUB_ENV" + else + echo "::error::deploy-e2e-headlamp.sh did not produce .env.e2e" + exit 1 + fi + + - name: Install Playwright browsers + run: pnpm exec playwright install --with-deps chromium + + - name: Run E2E tests + run: pnpm run e2e + env: + HEADLAMP_URL: ${{ env.HEADLAMP_URL }} + HEADLAMP_TOKEN: ${{ env.HEADLAMP_TOKEN }} + + - name: Collect deployment diagnostics on failure + if: failure() + run: | + echo "=== Pod state ===" + kubectl get pods -n "$E2E_NAMESPACE" -l "app.kubernetes.io/instance=$E2E_RELEASE" 2>&1 || true + echo "=== Pod describe ===" + kubectl describe pods -n "$E2E_NAMESPACE" -l "app.kubernetes.io/instance=$E2E_RELEASE" 2>&1 || true + echo "=== Recent namespace events ===" + kubectl get events -n "$E2E_NAMESPACE" --sort-by='.lastTimestamp' 2>&1 | tail -20 || true + + - name: Teardown E2E instance + if: always() + run: scripts/teardown-e2e-headlamp.sh + + - name: Upload Playwright report + uses: actions/upload-artifact@v7 + if: failure() + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 + + - name: Upload test results + uses: actions/upload-artifact@v7 + if: failure() + with: + name: test-results + path: test-results/ + retention-days: 7 diff --git a/deployment/e2e-ci-runner-rbac.yaml b/deployment/e2e-ci-runner-rbac.yaml new file mode 100644 index 0000000..a2116ef --- /dev/null +++ b/deployment/e2e-ci-runner-rbac.yaml @@ -0,0 +1,35 @@ +--- +# RBAC for the GitHub Actions CI runner to manage the E2E Headlamp instance. +# CI-only test fixture — NOT for production use. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: e2e-ci-runner + namespace: headlamp-dev +rules: + - apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get", "list", "create", "update", "patch", "delete", "watch"] + - apiGroups: [""] + resources: ["services", "serviceaccounts", "configmaps", "secrets", "events"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["serviceaccounts/token"] + verbs: ["create"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: e2e-ci-runner-binding + namespace: headlamp-dev +subjects: + - kind: ServiceAccount + name: runners-privilegedescalation-gha-rs-no-permission + namespace: arc-runners +roleRef: + kind: Role + name: e2e-ci-runner + apiGroup: rbac.authorization.k8s.io diff --git a/e2e/sealed-secrets.spec.ts b/e2e/sealed-secrets.spec.ts index 2af5e91..a6613e7 100644 --- a/e2e/sealed-secrets.spec.ts +++ b/e2e/sealed-secrets.spec.ts @@ -1,34 +1,40 @@ import { test, expect } from '@playwright/test'; +async function waitForSidebar(page: import('@playwright/test').Page) { + const sidebar = page.getByRole('navigation', { name: 'Navigation' }); + await expect(sidebar).toBeVisible({ timeout: 15_000 }); + await page.waitForLoadState('networkidle'); + return sidebar; +} + test.describe('Sealed Secrets plugin smoke tests', () => { test('sidebar contains sealed-secrets entry', async ({ page }) => { await page.goto('/'); - const sidebar = page.getByRole('navigation', { name: 'Navigation' }); - await expect(sidebar).toBeVisible({ timeout: 15_000 }); + const sidebar = await waitForSidebar(page); await expect(sidebar.getByRole('button', { name: /sealed.secrets/i })).toBeVisible(); }); test('sidebar sealed-secrets entry is clickable and navigates to list view', async ({ page }) => { await page.goto('/'); - const sidebar = page.getByRole('navigation', { name: 'Navigation' }); - await expect(sidebar).toBeVisible({ timeout: 15_000 }); + const sidebar = await waitForSidebar(page); const sealedSecretsEntry = sidebar.getByRole('button', { name: /sealed.secrets/i }); await expect(sealedSecretsEntry).toBeVisible(); await sealedSecretsEntry.click(); + await page.waitForLoadState('networkidle'); await expect(page).toHaveURL(/\/sealedsecrets/); await expect(page.getByRole('heading', { name: /sealed.secrets/i })).toBeVisible(); }); test('sealed secrets list page renders table or empty state', async ({ page }) => { await page.goto('/c/main/sealedsecrets'); + await waitForSidebar(page); await expect(page.getByRole('heading', { name: /sealed.secrets/i })).toBeVisible({ timeout: 15_000, }); - // Either a populated table or an empty-state indicator must be visible const hasTable = await page.locator('table').first().isVisible().catch(() => false); const hasEmptyState = await page .locator('text=/no.*sealed|no.*secret|0 item|empty/i') @@ -40,6 +46,7 @@ test.describe('Sealed Secrets plugin smoke tests', () => { test('sealing keys page renders table or empty state', async ({ page }) => { await page.goto('/c/main/sealedsecrets/keys'); + await waitForSidebar(page); await expect(page.getByRole('heading', { name: /sealing.key/i })).toBeVisible({ timeout: 15_000, @@ -56,33 +63,37 @@ test.describe('Sealed Secrets plugin smoke tests', () => { test('navigation between sealed-secrets views works', async ({ page }) => { await page.goto('/c/main/sealedsecrets'); - await expect(page.getByRole('heading', { name: /sealed.secrets/i })).toBeVisible({ - timeout: 15_000, - }); + const sidebar = await waitForSidebar(page); + + const sealedBtn = sidebar.getByRole('button', { name: /sealed.secrets/i }).first(); + await sealedBtn.click(); + await page.waitForLoadState('networkidle'); + + await expect(page.getByRole('heading', { name: /sealed.secrets/i }).first()).toBeVisible({ timeout: 15_000 }); - // Navigate to Sealing Keys via sidebar - const sidebar = page.getByRole('navigation', { name: 'Navigation' }); const keysLink = sidebar.getByRole('link', { name: /sealing.key/i }); await expect(keysLink).toBeVisible(); await keysLink.click(); + await page.waitForLoadState('networkidle'); await expect(page).toHaveURL(/\/sealedsecrets\/keys$/); - await expect(page.getByRole('heading', { name: /sealing.key/i })).toBeVisible(); + await expect(page.getByRole('heading', { name: /sealing.key/i }).first()).toBeVisible(); - // Navigate back to All Sealed Secrets const allSecretsLink = sidebar.getByRole('link', { name: /all sealed secrets/i }); await expect(allSecretsLink).toBeVisible(); await allSecretsLink.click(); + await page.waitForLoadState('networkidle'); await expect(page).toHaveURL(/\/sealedsecrets(?!\/keys)/); - await expect(page.getByRole('heading', { name: /sealed.secrets/i })).toBeVisible(); + await expect(page.getByRole('heading', { name: /sealed.secrets/i }).first()).toBeVisible(); }); test('plugin settings page shows sealed-secrets plugin entry', async ({ page }) => { await page.goto('/settings/plugins'); + await page.waitForLoadState('networkidle'); + await page.waitForSelector('table', { timeout: 10_000 }).catch(() => {}); - // Wait for plugin list to load — plugin scripts load asynchronously - const pluginEntry = page.locator('text=sealed-secrets').first(); + const pluginEntry = page.locator('text=/sealed.secrets/i').first(); await expect(pluginEntry).toBeVisible({ timeout: 30_000 }); }); }); diff --git a/scripts/deploy-e2e-headlamp.sh b/scripts/deploy-e2e-headlamp.sh index 8f7a872..43d5467 100755 --- a/scripts/deploy-e2e-headlamp.sh +++ b/scripts/deploy-e2e-headlamp.sh @@ -5,7 +5,7 @@ # a ConfigMap volume mount. No custom Docker images — the plugin is built # in CI and injected as a ConfigMap. # -# E2E resources are deployed to the `privilegedescalation-dev` namespace. Nothing +# E2E resources are deployed to the `headlamp-dev` namespace. Nothing # persists beyond the test run — teardown cleans up all created resources. # # Prerequisites: @@ -14,7 +14,7 @@ # - RBAC applied: kubectl apply -f deployment/e2e-ci-runner-rbac.yaml # # Environment: -# E2E_NAMESPACE — namespace for E2E Headlamp (default: privilegedescalation-dev) +# E2E_NAMESPACE — namespace for E2E Headlamp (default: headlamp-dev) # E2E_RELEASE — release/resource name prefix (default: headlamp-e2e) # HEADLAMP_VERSION — Headlamp image tag (default: latest) set -euo pipefail @@ -22,7 +22,7 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" DIST_DIR="$REPO_ROOT/dist" -E2E_NAMESPACE="${E2E_NAMESPACE:-privilegedescalation-dev}" +E2E_NAMESPACE="${E2E_NAMESPACE:-headlamp-dev}" E2E_RELEASE="${E2E_RELEASE:-headlamp-e2e}" HEADLAMP_VERSION="${HEADLAMP_VERSION:-latest}" diff --git a/scripts/teardown-e2e-headlamp.sh b/scripts/teardown-e2e-headlamp.sh index 477cd1a..ccc2c1e 100755 --- a/scripts/teardown-e2e-headlamp.sh +++ b/scripts/teardown-e2e-headlamp.sh @@ -4,13 +4,13 @@ # Tears down the dedicated E2E Headlamp instance deployed by deploy-e2e-headlamp.sh. # # Environment: -# E2E_NAMESPACE — namespace to clean up (default: privilegedescalation-dev) +# E2E_NAMESPACE — namespace to clean up (default: headlamp-dev) # E2E_RELEASE — release/resource name prefix (default: headlamp-e2e) set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -E2E_NAMESPACE="${E2E_NAMESPACE:-privilegedescalation-dev}" +E2E_NAMESPACE="${E2E_NAMESPACE:-headlamp-dev}" E2E_RELEASE="${E2E_RELEASE:-headlamp-e2e}" echo "=== E2E Headlamp Teardown ==="