diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml deleted file mode 100644 index 49ab07f..0000000 --- a/.github/workflows/e2e.yaml +++ /dev/null @@ -1,103 +0,0 @@ -name: E2E Tests - -on: - push: - branches: [main] - pull_request: - branches: [main] - workflow_dispatch: - -permissions: - contents: read - -# Only one E2E run at a time: the shared E2E_RELEASE (headlamp-e2e) in -# headlamp-dev cannot be shared across concurrent runs. -# cancel-in-progress: false (queue, don't cancel) — cancelling in-flight -# runs may skip the if: always() teardown, leaving dangling cluster resources. -concurrency: - group: e2e-${{ github.repository }} - cancel-in-progress: false - -env: - E2E_NAMESPACE: headlamp-dev - E2E_RELEASE: headlamp-e2e - # Pin to a known-good Headlamp version. Using :latest is risky because - # the tag can change between CI runs, causing flaky failures when a newer - # image is pulled on some nodes but not others (IfNotPresent pull policy). - # Update this when Headlamp is upgraded in production (kube-system). - 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' - cache: 'npm' - - - name: Setup kubectl - uses: azure/setup-kubectl@v4 - - - name: Install dependencies - run: npm ci - - - name: Build plugin - run: npx @kinvolk/headlamp-plugin 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: npx playwright install --with-deps chromium - - - name: Run E2E tests - run: npm 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/.gitignore b/.gitignore index 55542df..3bfc146 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,3 @@ node_modules/ dist/ *.tar.gz -.playwright-mcp/ -e2e/.auth/state.json -.env.e2e -test-results/ -playwright-report/ diff --git a/e2e/.auth/.gitkeep b/e2e/.auth/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/e2e/auth.setup.ts b/e2e/auth.setup.ts deleted file mode 100644 index 2b4ecb9..0000000 --- a/e2e/auth.setup.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { test as setup, expect, Page } from '@playwright/test'; - -const AUTH_STATE_PATH = 'e2e/.auth/state.json'; - -async function authenticateWithOIDC(page: Page, username: string, password: string): Promise { - // Navigate to login — Headlamp redirects / to /c/main/login - await page.goto('/'); - await page.waitForURL('**/login'); - - // Click "Sign In" and capture the Authentik popup - const popupPromise = page.waitForEvent('popup'); - await page.getByRole('button', { name: /sign in/i }).click(); - const popup = await popupPromise; - - // Wait for the Authentik popup to fully load before interacting - await popup.waitForLoadState('domcontentloaded'); - await popup.waitForLoadState('networkidle'); - - // Authentik step 1: fill username — wait for the form to render - const usernameField = popup.getByRole('textbox', { name: /email or username/i }); - await usernameField.waitFor({ state: 'visible', timeout: 15_000 }); - await usernameField.fill(username); - await popup.getByRole('button', { name: /log in/i }).click(); - - // Authentik step 2: fill password — wait for the next step to load - await popup.waitForLoadState('networkidle'); - const passwordField = popup.getByRole('textbox', { name: /password/i }); - await passwordField.waitFor({ state: 'visible', timeout: 15_000 }); - await passwordField.fill(password); - await popup.getByRole('button', { name: /continue|log in/i }).click(); - - // Wait for the popup to close (Authentik redirects back, Headlamp processes callback) - await popup.waitForEvent('close', { timeout: 15_000 }); - - // Original page should now be authenticated — wait for sidebar - await expect(page.getByRole('navigation', { name: 'Navigation' })).toBeVisible({ - timeout: 15_000, - }); -} - -async function authenticateWithToken(page: Page, token: string): Promise { - await page.goto('/'); - // Headlamp goes to /token directly when no OIDC is configured, - // or through /login when OIDC is configured - await page.waitForURL(/\/(login|token)$/); - - if (page.url().includes('/login')) { - // OIDC login page — click "use a token" to reach token auth. - // Wait explicitly before clicking so failures surface at 15 s - // with a clear message rather than silently timing out at 60 s. - const useTokenBtn = page.getByRole('button', { name: /use a token/i }); - await useTokenBtn.waitFor({ state: 'visible', timeout: 15_000 }); - await useTokenBtn.click(); - await page.waitForURL('**/token'); - } - - // Fill the "ID token" field and submit - await page.getByRole('textbox', { name: /id token/i }).fill(token); - await page.getByRole('button', { name: /authenticate/i }).click(); - - // Wait for the main UI to load - await expect(page.getByRole('navigation', { name: 'Navigation' })).toBeVisible({ - timeout: 15_000, - }); -} - -setup('authenticate with Headlamp', async ({ page }) => { - const username = process.env.AUTHENTIK_USERNAME; - const password = process.env.AUTHENTIK_PASSWORD; - const token = process.env.HEADLAMP_TOKEN; - - if (username && password) { - await authenticateWithOIDC(page, username, password); - } else if (token) { - await authenticateWithToken(page, token); - } else { - throw new Error( - 'Set AUTHENTIK_USERNAME + AUTHENTIK_PASSWORD for OIDC auth, or HEADLAMP_TOKEN for token auth' - ); - } - - await page.context().storageState({ path: AUTH_STATE_PATH }); -}); diff --git a/e2e/intel-gpu.spec.ts b/e2e/intel-gpu.spec.ts deleted file mode 100644 index 3aae3ef..0000000 --- a/e2e/intel-gpu.spec.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test.describe('Intel GPU plugin smoke tests', () => { - test('sidebar contains intel-gpu entry', async ({ page }) => { - await page.goto('/'); - const sidebar = page.getByRole('navigation', { name: 'Navigation' }); - await expect(sidebar).toBeVisible({ timeout: 15_000 }); - await expect(sidebar.getByRole('button', { name: 'intel-gpu' })).toBeVisible(); - }); - - test('sidebar intel-gpu entry is clickable and navigates to overview', async ({ page }) => { - await page.goto('/'); - const sidebar = page.getByRole('navigation', { name: 'Navigation' }); - await expect(sidebar).toBeVisible({ timeout: 15_000 }); - - const gpuEntry = sidebar.getByRole('button', { name: 'intel-gpu' }); - await expect(gpuEntry).toBeVisible(); - await gpuEntry.click(); - - // Should navigate to the overview route - await expect(page).toHaveURL(/\/intel-gpu$/); - await expect( - page.locator('main').getByRole('heading', { name: 'Intel GPU — Overview' }) - ).toBeVisible(); - }); - - test('overview page renders GPU device list or empty state', async ({ page }) => { - await page.goto('/c/main/intel-gpu'); - - // Overview heading should be present - await expect( - page.locator('main').getByRole('heading', { name: 'Intel GPU — Overview' }) - ).toBeVisible({ timeout: 15_000 }); - - // Either a populated table/list 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.*gpu|no.*device|0 node|empty/i') - .first() - .isVisible() - .catch(() => false); - expect(hasTable || hasEmptyState).toBe(true); - }); - - test('device plugins page renders or shows empty state', async ({ page }) => { - await page.goto('/c/main/intel-gpu/device-plugins'); - - await expect( - page.locator('main').getByRole('heading', { name: 'Intel GPU — Device Plugins' }) - ).toBeVisible({ timeout: 15_000 }); - - const hasTable = await page.locator('table').first().isVisible().catch(() => false); - const hasEmptyState = await page - .locator('text=/no.*plugin|no.*device|empty/i') - .first() - .isVisible() - .catch(() => false); - expect(hasTable || hasEmptyState).toBe(true); - }); - - test('navigation between plugin views works', async ({ page }) => { - // Headlamp sidebar child links only appear when already on a child route, - // not after clicking the parent entry from the overview. Test route - // accessibility via direct navigation — each route must render its heading. - await page.goto('/c/main/intel-gpu'); - await expect( - page.locator('main').getByRole('heading', { name: 'Intel GPU — Overview' }) - ).toBeVisible({ timeout: 15_000 }); - - await page.goto('/c/main/intel-gpu/nodes'); - await expect( - page.locator('main').getByRole('heading', { name: 'Intel GPU — Nodes' }) - ).toBeVisible({ timeout: 15_000 }); - - await page.goto('/c/main/intel-gpu/pods'); - await expect( - page.locator('main').getByRole('heading', { name: 'Intel GPU — Pods' }) - ).toBeVisible({ timeout: 15_000 }); - - await page.goto('/c/main/intel-gpu/metrics'); - await expect( - page.locator('main').getByRole('heading', { name: 'Intel GPU — Metrics' }) - ).toBeVisible({ timeout: 15_000 }); - }); - - test('plugin settings page shows intel-gpu plugin entry', async ({ page }) => { - await page.goto('/settings/plugins'); - - // Wait for plugin list to load — plugin scripts load asynchronously - const pluginEntry = page.locator('text=intel-gpu').first(); - await expect(pluginEntry).toBeVisible({ timeout: 30_000 }); - }); -}); diff --git a/package.json b/package.json index 5648d6c..99aef8c 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,7 @@ "format": "prettier --write src/", "format:check": "prettier --check src/", "test": "vitest run", - "test:watch": "vitest", - "e2e": "playwright test", - "e2e:headed": "playwright test --headed" + "test:watch": "vitest" }, "peerDependencies": { "react": "^18.0.0", @@ -32,7 +30,6 @@ }, "devDependencies": { "@kinvolk/headlamp-plugin": "^0.13.0", - "@playwright/test": "^1.58.2", "@testing-library/jest-dom": "^6.4.8", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.2", diff --git a/playwright.config.ts b/playwright.config.ts deleted file mode 100644 index b916edf..0000000 --- a/playwright.config.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; - -export default defineConfig({ - testDir: './e2e', - timeout: 30_000, - expect: { timeout: 10_000 }, - fullyParallel: false, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 1 : 0, - reporter: 'list', - use: { - baseURL: process.env.HEADLAMP_URL || (() => { throw new Error('HEADLAMP_URL is required — run scripts/deploy-e2e-headlamp.sh first'); })(), - trace: 'on-first-retry', - screenshot: 'only-on-failure', - }, - projects: [ - { name: 'setup', testMatch: /auth\.setup\.ts/, timeout: 60_000 }, - { - name: 'chromium', - use: { - ...devices['Desktop Chrome'], - storageState: 'e2e/.auth/state.json', - }, - dependencies: ['setup'], - }, - ], -}); diff --git a/scripts/deploy-e2e-headlamp.sh b/scripts/deploy-e2e-headlamp.sh deleted file mode 100755 index 783c758..0000000 --- a/scripts/deploy-e2e-headlamp.sh +++ /dev/null @@ -1,209 +0,0 @@ -#!/usr/bin/env bash -# deploy-e2e-headlamp.sh -# -# Deploys a stock Headlamp instance with the intel-gpu plugin loaded via -# 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 `headlamp-dev` namespace. Nothing -# persists beyond the test run — teardown cleans up all created resources. -# -# Prerequisites: -# - Plugin built (dist/ exists with plugin-main.js + package.json) -# - kubectl configured with cluster access -# - RBAC applied: kubectl apply -f deployment/e2e-ci-runner-rbac.yaml -# -# Environment: -# 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 - -REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -DIST_DIR="$REPO_ROOT/dist" - -E2E_NAMESPACE="${E2E_NAMESPACE:-headlamp-dev}" -E2E_RELEASE="${E2E_RELEASE:-headlamp-e2e}" -HEADLAMP_VERSION="${HEADLAMP_VERSION:-latest}" - -if [ ! -d "$DIST_DIR" ]; then - echo "ERROR: dist/ not found. Run 'npm run build' first." >&2 - exit 1 -fi - -# --- Preflight: verify RBAC before touching the cluster --- -echo "Checking RBAC permissions in namespace '${E2E_NAMESPACE}'..." -if ! kubectl auth can-i delete configmaps -n "$E2E_NAMESPACE" --quiet 2>/dev/null; then - echo "ERROR: Missing RBAC — cannot delete configmaps in namespace '${E2E_NAMESPACE}'." >&2 - echo " Apply RBAC first: kubectl apply -f deployment/e2e-ci-runner-rbac.yaml" >&2 - exit 1 -fi - -echo "=== E2E Headlamp Deployment ===" -echo " Image: ghcr.io/headlamp-k8s/headlamp:${HEADLAMP_VERSION}" -echo " Namespace: $E2E_NAMESPACE" -echo " Release: $E2E_RELEASE" - -# --- Create ConfigMap from built plugin --- -echo "" -echo "Creating ConfigMap with plugin files..." - -# Delete existing ConfigMap if present (idempotent redeploy) -kubectl delete configmap headlamp-intel-gpu-plugin \ - -n "$E2E_NAMESPACE" --ignore-not-found - -# Create ConfigMap from dist/ contents and package.json -kubectl create configmap headlamp-intel-gpu-plugin \ - -n "$E2E_NAMESPACE" \ - --from-file="$DIST_DIR" \ - --from-file=package.json="$REPO_ROOT/package.json" - -# --- Tear down any existing E2E deployment for a clean start --- -# Deleting the Deployment forces a fresh pod (new ReplicaSet) regardless of -# whether the pod spec changed. The ServiceAccount is also deleted for a clean -# token state. The Service is NOT deleted — leaving it in place avoids an -# Endpoints UID race (FailedToUpdateEndpoint) that causes DNS resolution -# failures. kubectl apply below upserts the Service in-place, and the new -# pod's IP is added to the existing Endpoints automatically. -echo "" -echo "Removing any existing E2E deployment (clean-start)..." -kubectl delete deployment "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found --wait -kubectl delete serviceaccount "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found --wait - -# --- Deploy Headlamp via kubectl apply --- -echo "" -echo "Deploying Headlamp E2E instance..." - -kubectl apply -f - </dev/null; do - ATTEMPTS=$((ATTEMPTS + 1)) - if [ "$ATTEMPTS" -ge "$MAX_ATTEMPTS" ]; then - echo "ERROR: ${SVC_URL} not reachable after $((MAX_ATTEMPTS * 5))s" >&2 - exit 1 - fi - echo " [${ATTEMPTS}/${MAX_ATTEMPTS}] not yet reachable, retrying in 5s..." - sleep 5 -done -echo "" -echo "E2E Headlamp is ready at: ${SVC_URL}" -echo " export HEADLAMP_URL=${SVC_URL}" - -# --- Generate a token for test auth --- -echo "" -echo "Creating service account token for E2E auth..." -kubectl create serviceaccount headlamp-e2e-test \ - -n "$E2E_NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - - -TOKEN=$(kubectl create token headlamp-e2e-test -n "$E2E_NAMESPACE" --duration=1h 2>/dev/null || echo "") -if [ -n "$TOKEN" ]; then - echo " export HEADLAMP_TOKEN=" - echo "" - echo "HEADLAMP_URL=${SVC_URL}" > "$REPO_ROOT/.env.e2e" - echo "HEADLAMP_TOKEN=${TOKEN}" >> "$REPO_ROOT/.env.e2e" - echo "Wrote .env.e2e with HEADLAMP_URL and HEADLAMP_TOKEN" -else - echo " WARNING: Could not generate token. Set HEADLAMP_TOKEN manually or use OIDC." -fi - -echo "" -echo "E2E deployment complete." diff --git a/scripts/teardown-e2e-headlamp.sh b/scripts/teardown-e2e-headlamp.sh deleted file mode 100755 index d9bdf43..0000000 --- a/scripts/teardown-e2e-headlamp.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash -# teardown-e2e-headlamp.sh -# -# Tears down the dedicated E2E Headlamp instance deployed by deploy-e2e-headlamp.sh. -# -# Environment: -# 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:-headlamp-dev}" -E2E_RELEASE="${E2E_RELEASE:-headlamp-e2e}" - -echo "=== E2E Headlamp Teardown ===" -echo " Namespace: $E2E_NAMESPACE" -echo " Release: $E2E_RELEASE" - -echo "Removing Headlamp Deployment, Service, and ServiceAccount..." -kubectl delete deployment "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found -kubectl delete service "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found -kubectl delete serviceaccount "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found - -echo "Cleaning up ConfigMap..." -kubectl delete configmap headlamp-intel-gpu-plugin -n "$E2E_NAMESPACE" --ignore-not-found - -echo "Cleaning up test service account..." -kubectl delete serviceaccount headlamp-e2e-test -n "$E2E_NAMESPACE" --ignore-not-found - -# Clean up .env.e2e if present -if [ -f "$REPO_ROOT/.env.e2e" ]; then - rm "$REPO_ROOT/.env.e2e" - echo "Removed .env.e2e" -fi - -echo "" -echo "E2E teardown complete."