diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml new file mode 100644 index 0000000..83c1a90 --- /dev/null +++ b/.github/workflows/e2e.yaml @@ -0,0 +1,153 @@ +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-argocd + HEADLAMP_VERSION: v0.40.1 + +jobs: + e2e: + runs-on: runners-privilegedescalation + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Detect package manager + id: pkg-manager + run: | + if [ -f "pnpm-lock.yaml" ]; then + echo "manager=pnpm" >> $GITHUB_OUTPUT + PM=$(python3 -c "import json,sys; d=json.load(open('package.json')); print('true' if d.get('packageManager','').startswith('pnpm@') else 'false')" 2>/dev/null || echo "false") + echo "has_package_manager=$PM" >> $GITHUB_OUTPUT + else + echo "manager=npm" >> $GITHUB_OUTPUT + echo "has_package_manager=false" >> $GITHUB_OUTPUT + fi + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: '22' + cache: ${{ steps.pkg-manager.outputs.manager == 'npm' && 'npm' || '' }} + + - name: Setup pnpm (Corepack, respects packageManager field) + if: steps.pkg-manager.outputs.manager == 'pnpm' && steps.pkg-manager.outputs.has_package_manager == 'true' + run: | + npm install -g corepack + corepack enable pnpm + corepack prepare $(node -p "require('./package.json').packageManager") --activate + + - name: Setup pnpm (version latest, no packageManager field) + if: steps.pkg-manager.outputs.manager == 'pnpm' && steps.pkg-manager.outputs.has_package_manager == 'false' + uses: pnpm/action-setup@v5 + with: + run_install: false + version: latest + + - name: Get pnpm store directory + id: pnpm-store + if: steps.pkg-manager.outputs.manager == 'pnpm' + run: echo "dir=$(pnpm store path --silent)" >> $GITHUB_OUTPUT + + - name: Cache pnpm store + if: steps.pkg-manager.outputs.manager == 'pnpm' + uses: actions/cache@v5 + with: + path: ${{ steps.pnpm-store.outputs.dir }} + key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm- + + - name: Setup kubectl + uses: azure/setup-kubectl@v4 + + - name: Install dependencies + run: | + if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then + pnpm install --frozen-lockfile + else + npm ci + fi + + - name: Build plugin + run: npx @kinvolk/headlamp-plugin build + + - name: Make scripts executable + run: chmod +x scripts/deploy-e2e-headlamp.sh scripts/teardown-e2e-headlamp.sh + + - 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: | + if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then + pnpm exec playwright install --with-deps chromium + else + npx playwright install --with-deps chromium + fi + + - name: Run E2E tests + run: | + if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then + pnpm run e2e + else + npm run e2e + fi + 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 \ No newline at end of file diff --git a/e2e/argocd.spec.ts b/e2e/argocd.spec.ts new file mode 100644 index 0000000..86d8b9c --- /dev/null +++ b/e2e/argocd.spec.ts @@ -0,0 +1,18 @@ +import { test, expect } from '@playwright/test'; + +test.describe('ArgoCD plugin smoke tests', () => { + test('sidebar contains ArgoCD 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: 'ArgoCD' })).toBeVisible(); + }); + + test('applications list page loads', async ({ page }) => { + await page.goto('/c/main/argocd'); + + await expect( + page.getByRole('heading', { name: /argo.*cd/i }) + ).toBeVisible({ timeout: 15_000 }); + }); +}); \ No newline at end of file diff --git a/e2e/auth.setup.ts b/e2e/auth.setup.ts new file mode 100644 index 0000000..aa56492 --- /dev/null +++ b/e2e/auth.setup.ts @@ -0,0 +1,34 @@ +import { test as setup, expect, Page } from '@playwright/test'; + +const AUTH_STATE_PATH = 'e2e/.auth/state.json'; + +async function authenticateWithToken(page: Page, token: string): Promise { + await page.goto('/'); + await page.waitForURL(/\/(login|token)$/); + + if (page.url().includes('/login')) { + 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'); + } + + await page.getByRole('textbox', { name: /id token/i }).fill(token); + await page.getByRole('button', { name: /authenticate/i }).click(); + + await expect(page.getByRole('navigation', { name: 'Navigation' })).toBeVisible({ + timeout: 15_000, + }); +} + +setup('authenticate with Headlamp', async ({ page }) => { + const token = process.env.HEADLAMP_TOKEN; + + if (!token) { + throw new Error('Set HEADLAMP_TOKEN for token auth'); + } + + await authenticateWithToken(page, token); + + await page.context().storageState({ path: AUTH_STATE_PATH }); +}); \ No newline at end of file diff --git a/package.json b/package.json index eaf7886..73b28ed 100644 --- a/package.json +++ b/package.json @@ -23,20 +23,12 @@ "format": "prettier --write src/", "format:check": "prettier --check src/", "test": "vitest run", - "test:watch": "vitest" - }, - "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" - }, - "pnpm": { - "overrides": { - "tar": "^7.5.11", - "undici": "^7.24.3", - "flatted": "^3.4.2" - } +"test:watch": "vitest", + "e2e": "playwright test", + "e2e:headed": "playwright test --headed" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@kinvolk/headlamp-plugin": "^0.13.0", "@mui/material": "^5.15.14", "@testing-library/jest-dom": "^6.4.8", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..1aa86a8 --- /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 || 'http://headlamp-e2e-argocd.headlamp-dev.svc.cluster.local', + 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'], + }, + ], +}); \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e17be9..7086c3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,11 +4,6 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -overrides: - tar: ^7.5.11 - undici: ^7.24.3 - flatted: ^3.4.2 - importers: .: @@ -22,6 +17,9 @@ importers: '@mui/material': specifier: ^5.15.14 version: 5.18.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@playwright/test': + specifier: ^1.58.2 + version: 1.59.1 '@testing-library/jest-dom': specifier: ^6.4.8 version: 6.9.1 @@ -1001,6 +999,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} @@ -3082,6 +3085,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4221,6 +4229,16 @@ packages: resolution: {integrity: sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==} engines: {node: '>=10'} + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -6232,7 +6250,7 @@ snapshots: material-react-table: 2.13.3(0078ddeddc9e779fa84c03996c1db10e) monaco-editor: 0.52.2 msw: 2.4.9(typescript@5.6.2) - msw-storybook-addon: 2.0.3(msw@2.4.9(typescript@5.6.3)) + msw-storybook-addon: 2.0.3(msw@2.4.9(typescript@5.6.2)) notistack: 3.0.2(csstype@3.2.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) path-browserify: 1.0.1 prettier: 2.8.8 @@ -6627,6 +6645,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + '@popperjs/core@2.11.8': {} '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1))(react@18.3.1)': @@ -9185,6 +9207,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -10237,7 +10262,7 @@ snapshots: ms@2.1.3: {} - msw-storybook-addon@2.0.3(msw@2.4.9(typescript@5.6.3)): + msw-storybook-addon@2.0.3(msw@2.4.9(typescript@5.6.2)): dependencies: is-node-process: 1.2.0 msw: 2.4.9(typescript@5.6.2) @@ -10567,6 +10592,14 @@ snapshots: dependencies: find-up: 5.0.0 + playwright-core@1.59.1: {} + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss-modules-extract-imports@3.1.0(postcss@8.5.10): diff --git a/scripts/deploy-e2e-headlamp.sh b/scripts/deploy-e2e-headlamp.sh new file mode 100644 index 0000000..3d29088 --- /dev/null +++ b/scripts/deploy-e2e-headlamp.sh @@ -0,0 +1,217 @@ +#!/usr/bin/env bash +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-argocd}" +HEADLAMP_VERSION="${HEADLAMP_VERSION:-v0.40.1}" + +if [ ! -d "$DIST_DIR" ]; then + echo "ERROR: dist/ not found. Run 'npm run build' first." >&2 + exit 1 +fi + +echo "Checking RBAC permissions in namespace '${E2E_NAMESPACE}'..." +REQUIRED_PERMS=( + "create configmaps" + "delete configmaps" + "get configmaps" + "create serviceaccounts" + "delete serviceaccounts" + "get serviceaccounts" + "create deployments" + "get deployments" + "list pods" + "get pods" + "create services" + "get services" + "delete services" + "create pods/exec" + "create token" +) +FAILED="" +for perm in "${REQUIRED_PERMS[@]}"; do + if ! kubectl auth can-i "$perm" -n "$E2E_NAMESPACE" --quiet 2>/dev/null; then + echo "ERROR: Missing RBAC — ${perm} in namespace '${E2E_NAMESPACE}'." >&2 + FAILED="$perm" + break + fi +done +if [ -n "$FAILED" ]; then + echo "ERROR: Missing required RBAC permission: ${FAILED}" >&2 + echo "Hub operator needs to grant this permission to the workflow's service account in namespace ${E2E_NAMESPACE}." >&2 + exit 1 +fi +echo "RBAC check passed." + +echo "=== E2E Headlamp Deployment ===" +echo " Image: ghcr.io/headlamp-k8s/headlamp:${HEADLAMP_VERSION}" +echo " Namespace: $E2E_NAMESPACE" +echo " Release: $E2E_RELEASE" + +echo "" +echo "Creating ConfigMap with plugin files..." + +kubectl delete configmap headlamp-argocd-plugin \ + -n "$E2E_NAMESPACE" --ignore-not-found + +kubectl create configmap headlamp-argocd-plugin \ + -n "$E2E_NAMESPACE" \ + --from-file="$DIST_DIR" \ + --from-file=package.json="$REPO_ROOT/package.json" + +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 + +echo "" +echo "Deploying Headlamp E2E instance..." + +kubectl apply -f - <&1; then + echo "=== Rollout failed. Dumping diagnostics ===" >&2 + echo "=== Pods ===" >&2 + kubectl get pods -n "$E2E_NAMESPACE" -l "app.kubernetes.io/instance=${E2E_RELEASE}" 2>&1 || true + echo "=== Pod events ===" >&2 + kubectl describe pods -n "$E2E_NAMESPACE" -l "app.kubernetes.io/instance=${E2E_RELEASE}" 2>&1 | tail -30 || true + echo "=== Namespace events ===" >&2 + kubectl get events -n "$E2E_NAMESPACE" --sort-by='.lastTimestamp' 2>&1 | tail -20 || true + exit 1 +fi + +SVC_URL="http://${E2E_RELEASE}.${E2E_NAMESPACE}.svc.cluster.local" + +echo "" +echo "Waiting for ${SVC_URL} to be reachable..." +ATTEMPTS=0 +MAX_ATTEMPTS=24 +until curl -sf --max-time 5 "${SVC_URL}" -o /dev/null 2>/dev/null; do + ATTEMPTS=$((ATTEMPTS + 1)) + if [ "$ATTEMPTS" -ge "$MAX_ATTEMPTS" ]; then + echo "" + echo "=== Service unreachable after $((MAX_ATTEMPTS * 5))s. Dumping diagnostics ===" >&2 + echo "=== Pod state ===" >&2 + kubectl get pods -n "$E2E_NAMESPACE" -l "app.kubernetes.io/instance=${E2E_RELEASE}" 2>&1 || true + echo "=== Pod logs ===" >&2 + kubectl logs -n "$E2E_NAMESPACE" -l "app.kubernetes.io/instance=${E2E_RELEASE}" --tail=50 2>&1 || true + echo "=== Namespace events ===" >&2 + kubectl get events -n "$E2E_NAMESPACE" --sort-by='.lastTimestamp' 2>&1 | tail -20 || true + 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 "" +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 "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." +fi + +echo "" +echo "E2E deployment complete." \ No newline at end of file diff --git a/scripts/teardown-e2e-headlamp.sh b/scripts/teardown-e2e-headlamp.sh new file mode 100644 index 0000000..1e2a71a --- /dev/null +++ b/scripts/teardown-e2e-headlamp.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +E2E_NAMESPACE="${E2E_NAMESPACE:-headlamp-dev}" +E2E_RELEASE="${E2E_RELEASE:-headlamp-e2e-argocd}" + +echo "=== E2E Teardown ===" +echo " Namespace: $E2E_NAMESPACE" +echo " Release: $E2E_RELEASE" + +kubectl delete deployment "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found || true +kubectl delete service "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found || true +kubectl delete serviceaccount "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found || true +kubectl delete serviceaccount headlamp-e2e-test -n "$E2E_NAMESPACE" --ignore-not-found || true +kubectl delete configmap headlamp-argocd-plugin -n "$E2E_NAMESPACE" --ignore-not-found || true + +echo "Teardown complete." \ No newline at end of file