diff --git a/.gitignore b/.gitignore index 29f44d7..55542df 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ 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 new file mode 100644 index 0000000..e69de29 diff --git a/e2e/auth.setup.ts b/e2e/auth.setup.ts new file mode 100644 index 0000000..2b4ecb9 --- /dev/null +++ b/e2e/auth.setup.ts @@ -0,0 +1,83 @@ +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 new file mode 100644 index 0000000..b505965 --- /dev/null +++ b/e2e/intel-gpu.spec.ts @@ -0,0 +1,95 @@ +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.getByRole('heading', { name: /intel.gpu/i })).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.getByRole('heading', { name: /intel.gpu/i })).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.getByRole('heading', { name: /device plugin/i })).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 }) => { + await page.goto('/c/main/intel-gpu'); + await expect(page.getByRole('heading', { name: /intel.gpu/i })).toBeVisible({ + timeout: 15_000, + }); + + // Navigate to GPU Nodes + const sidebar = page.getByRole('navigation', { name: 'Navigation' }); + const nodesLink = sidebar.getByRole('link', { name: /gpu nodes/i }); + await expect(nodesLink).toBeVisible(); + await nodesLink.click(); + await expect(page).toHaveURL(/\/intel-gpu\/nodes$/); + await expect(page.getByRole('heading', { name: /node/i })).toBeVisible(); + + // Navigate to GPU Pods + const podsLink = sidebar.getByRole('link', { name: /gpu pods/i }); + await expect(podsLink).toBeVisible(); + await podsLink.click(); + await expect(page).toHaveURL(/\/intel-gpu\/pods$/); + await expect(page.getByRole('heading', { name: /pod/i })).toBeVisible(); + + // Navigate to Metrics + const metricsLink = sidebar.getByRole('link', { name: /metrics/i }); + await expect(metricsLink).toBeVisible(); + await metricsLink.click(); + await expect(page).toHaveURL(/\/intel-gpu\/metrics$/); + await expect(page.getByRole('heading', { name: /metric/i })).toBeVisible(); + }); + + 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 efe360f..4dd2119 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,9 @@ "format": "prettier --write src/", "format:check": "prettier --check src/", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "e2e": "playwright test", + "e2e:headed": "playwright test --headed" }, "peerDependencies": { "react": "^18.0.0", @@ -30,6 +32,7 @@ }, "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 new file mode 100644 index 0000000..b916edf --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,27 @@ +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 new file mode 100755 index 0000000..ed55a84 --- /dev/null +++ b/scripts/deploy-e2e-headlamp.sh @@ -0,0 +1,204 @@ +#!/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 `privilegedescalation-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: privilegedescalation-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:-privilegedescalation-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 --- +echo "" +echo "Removing any existing E2E deployment (clean-start)..." +kubectl delete deployment "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found --wait +kubectl delete service "${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 new file mode 100755 index 0000000..0afe16f --- /dev/null +++ b/scripts/teardown-e2e-headlamp.sh @@ -0,0 +1,38 @@ +#!/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: privilegedescalation-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_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."