From 8a3695023597f5a93b82221aaee9632055156310 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Tue, 5 May 2026 05:01:46 +0000 Subject: [PATCH 1/3] Add E2E test infrastructure for rook plugin - playwright.config.ts with authenticated test projects - e2e/auth.setup.ts authenticates via OIDC or token - e2e/rook.spec.ts smoke tests for sidebar, overview page, storage classes navigation, and plugin settings - scripts/deploy-e2e-headlamp.sh deploys Headlamp + rook in headlamp-dev - scripts/teardown-e2e-headlamp.sh cleans up after tests - e2e.yaml uses reusable workflow from .github repo - @playwright/test ^1.58.2 devDep added - PRI-640 Co-Authored-By: Paperclip --- .github/workflows/e2e.yaml | 23 ++++ .gitignore | 6 + e2e/auth.setup.ts | 69 +++++++++++ e2e/rook.spec.ts | 54 +++++++++ package.json | 5 +- playwright.config.ts | 27 +++++ pnpm-lock.yaml | 38 +++++++ scripts/deploy-e2e-headlamp.sh | 189 +++++++++++++++++++++++++++++++ scripts/teardown-e2e-headlamp.sh | 37 ++++++ 9 files changed, 447 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/e2e.yaml create mode 100644 e2e/auth.setup.ts create mode 100644 e2e/rook.spec.ts create mode 100644 playwright.config.ts create mode 100755 scripts/deploy-e2e-headlamp.sh create mode 100755 scripts/teardown-e2e-headlamp.sh diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml new file mode 100644 index 0000000..7157fd1 --- /dev/null +++ b/.github/workflows/e2e.yaml @@ -0,0 +1,23 @@ +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 + +jobs: + e2e: + uses: privilegedescalation/.github/.github/workflows/plugin-e2e.yaml@main + with: + node-version: '22' + headlamp-version: v0.40.1 + e2e-namespace: headlamp-dev diff --git a/.gitignore b/.gitignore index 407a5ad..bed1664 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,9 @@ dist/ .env.local .eslintcache .playwright-mcp/ + +# E2E +e2e/.auth/ +.env.e2e +playwright-report/ +test-results/ diff --git a/e2e/auth.setup.ts b/e2e/auth.setup.ts new file mode 100644 index 0000000..fcd9f7f --- /dev/null +++ b/e2e/auth.setup.ts @@ -0,0 +1,69 @@ +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 { + await page.goto('/'); + await page.waitForURL('**/login'); + + const popupPromise = page.waitForEvent('popup'); + await page.getByRole('button', { name: /sign in/i }).click(); + const popup = await popupPromise; + + await popup.waitForLoadState('domcontentloaded'); + await popup.waitForLoadState('networkidle'); + + 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(); + + 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(); + + await popup.waitForEvent('close', { timeout: 15_000 }); + + await expect(page.getByRole('navigation', { name: 'Navigation' })).toBeVisible({ + timeout: 15_000, + }); +} + +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 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/rook.spec.ts b/e2e/rook.spec.ts new file mode 100644 index 0000000..081d599 --- /dev/null +++ b/e2e/rook.spec.ts @@ -0,0 +1,54 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Rook plugin smoke tests', () => { + test('sidebar contains Rook 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: /rook/i })).toBeVisible(); + }); + + test('Rook sidebar entry navigates to overview', async ({ page }) => { + await page.goto('/'); + const sidebar = page.getByRole('navigation', { name: 'Navigation' }); + await expect(sidebar).toBeVisible({ timeout: 15_000 }); + + const rookEntry = sidebar.getByRole('button', { name: /rook/i }); + await expect(rookEntry).toBeVisible(); + await rookEntry.click(); + + await expect(page).toHaveURL(/\/rook-ceph/); + await expect(page.getByRole('heading', { name: /overview/i })).toBeVisible(); + }); + + test('overview page renders content', async ({ page }) => { + await page.goto('/c/main/rook-ceph'); + + await expect(page.getByRole('heading', { name: /overview/i })).toBeVisible({ + timeout: 15_000, + }); + + const hasContent = await page.locator('text=/cluster|ceph|status/i').first().isVisible().catch(() => false); + const hasDashboard = await page.locator('[class*="Mui"]').first().isVisible().catch(() => false); + expect(hasContent || hasDashboard).toBe(true); + }); + + test('navigation to storage classes view works', async ({ page }) => { + await page.goto('/c/main/rook-ceph'); + + const sidebar = page.getByRole('navigation', { name: 'Navigation' }); + const storageClassesLink = sidebar.getByRole('link', { name: /storage classes/i }); + await expect(storageClassesLink).toBeVisible({ timeout: 10_000 }); + await storageClassesLink.click(); + + await expect(page).toHaveURL(/\/rook-ceph\/storage-classes/); + await expect(page.getByRole('heading', { name: /storage class/i })).toBeVisible({ timeout: 15_000 }); + }); + + test('plugin settings page shows rook plugin entry', async ({ page }) => { + await page.goto('/settings/plugins'); + + const pluginEntry = page.locator('text=rook').first(); + await expect(pluginEntry).toBeVisible({ timeout: 30_000 }); + }); +}); diff --git a/package.json b/package.json index 5dec980..a263c3a 100644 --- a/package.json +++ b/package.json @@ -22,12 +22,15 @@ "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" }, "devDependencies": { "@headlamp-k8s/eslint-config": "^0.6.0", "@kinvolk/headlamp-plugin": "^0.13.0", "@mui/material": "^5.15.14", + "@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/pnpm-lock.yaml b/pnpm-lock.yaml index 6a1bd54..ddc43ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@mui/material': specifier: ^5.15.14 version: 5.18.0(@emotion/react@11.14.0(@types/react@18.3.28)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.28)(react@18.3.1))(@types/react@18.3.28)(react@18.3.1))(@types/react@18.3.28)(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 @@ -988,6 +991,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==} @@ -3049,6 +3057,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} @@ -4185,6 +4198,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'} @@ -6522,6 +6545,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)': @@ -9033,6 +9060,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -10413,6 +10443,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 100755 index 0000000..30edb91 --- /dev/null +++ b/scripts/deploy-e2e-headlamp.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +# deploy-e2e-headlamp.sh +# +# Deploys a stock Headlamp instance with the rook plugin loaded via +# a ConfigMap volume mount. +# +# 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 +# +# 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 'pnpm build' first." >&2 + exit 1 +fi + +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 + exit 1 +fi + +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-rook-plugin \ + -n "$E2E_NAMESPACE" --ignore-not-found + +kubectl create configmap headlamp-rook-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 - </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 "" +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." diff --git a/scripts/teardown-e2e-headlamp.sh b/scripts/teardown-e2e-headlamp.sh new file mode 100755 index 0000000..218d74b --- /dev/null +++ b/scripts/teardown-e2e-headlamp.sh @@ -0,0 +1,37 @@ +#!/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-rook-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 + +if [ -f "$REPO_ROOT/.env.e2e" ]; then + rm "$REPO_ROOT/.env.e2e" + echo "Removed .env.e2e" +fi + +echo "" +echo "E2E teardown complete." -- 2.52.0 From b6941756f75fd838f2fc755686d159ed7f619840 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Tue, 5 May 2026 06:10:19 +0000 Subject: [PATCH 2/3] Fix E2E workflow: use pnpm-capable reusable workflow branch The reusable plugin-e2e.yaml@main lacks pnpm support. Switching to the PR branch that has pnpm detector, Corepack setup, and pnpm commands. Will revert to @main once PR #141 merges. - PRI-619 E2E fix Co-Authored-By: Paperclip --- .github/workflows/e2e.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 7157fd1..4ee85a4 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -16,7 +16,7 @@ concurrency: jobs: e2e: - uses: privilegedescalation/.github/.github/workflows/plugin-e2e.yaml@main + uses: privilegedescalation/.github/.github/workflows/plugin-e2e.yaml@hugh/add-pnpm-support-plugin-e2e with: node-version: '22' headlamp-version: v0.40.1 -- 2.52.0 From 8d2ec06e4103351312bed845cdd3f85a395b4614 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Tue, 5 May 2026 06:50:21 +0000 Subject: [PATCH 3/3] fix(e2e): add waitForSidebar helper and networkidle waits for reliability Add waitForSidebar helper function with explicit sidebar visibility wait and networkidle state to ensure page is fully loaded before assertions. This addresses flaky E2E tests where elements were not consistently found due to timing issues during page transitions. --- e2e/rook.spec.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/e2e/rook.spec.ts b/e2e/rook.spec.ts index 081d599..bb6f15d 100644 --- a/e2e/rook.spec.ts +++ b/e2e/rook.spec.ts @@ -1,28 +1,35 @@ 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('Rook plugin smoke tests', () => { test('sidebar contains Rook 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: /rook/i })).toBeVisible(); }); test('Rook sidebar entry navigates to overview', 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 rookEntry = sidebar.getByRole('button', { name: /rook/i }); await expect(rookEntry).toBeVisible(); await rookEntry.click(); - await expect(page).toHaveURL(/\/rook-ceph/); + await page.waitForLoadState('networkidle'); + await expect(page).toHaveURL(/rook-ceph/); await expect(page.getByRole('heading', { name: /overview/i })).toBeVisible(); }); test('overview page renders content', async ({ page }) => { await page.goto('/c/main/rook-ceph'); + await waitForSidebar(page); await expect(page.getByRole('heading', { name: /overview/i })).toBeVisible({ timeout: 15_000, @@ -41,12 +48,14 @@ test.describe('Rook plugin smoke tests', () => { await expect(storageClassesLink).toBeVisible({ timeout: 10_000 }); await storageClassesLink.click(); - await expect(page).toHaveURL(/\/rook-ceph\/storage-classes/); + await page.waitForLoadState('networkidle'); + await expect(page).toHaveURL(/rook-ceph\/storage-classes/); await expect(page.getByRole('heading', { name: /storage class/i })).toBeVisible({ timeout: 15_000 }); }); test('plugin settings page shows rook plugin entry', async ({ page }) => { await page.goto('/settings/plugins'); + await page.waitForLoadState('networkidle'); const pluginEntry = page.locator('text=rook').first(); await expect(pluginEntry).toBeVisible({ timeout: 30_000 }); -- 2.52.0