feat(e2e): consolidate E2E infra + add waitForSidebar (PRI-701) #66

Closed
privilegedescalation-engineer[bot] wants to merge 4 commits from gandalf/e2e-fix-sealed-secrets into main
5 changed files with 165 additions and 20 deletions
+99
View File
@@ -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
+35
View File
@@ -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
+26 -15
View File
@@ -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 });
});
});
+3 -3
View File
@@ -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}"
+2 -2
View File
@@ -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 ==="