From 186f9ef38043907052df30cfbea446a5767de431 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Sat, 7 Feb 2026 18:53:40 -0500 Subject: [PATCH] feat: add Playwright E2E smoke tests and fix empty namespace crash Fix getNamespaces() to skip cluster-scoped resources (Namespace: "") that caused Router.createRouteURL to throw TypeError on the Namespaces page. Add Playwright E2E smoke tests with Authentik OIDC auth for CI and K8s token fallback for local dev. Add Gitea Actions E2E workflow, vitest unit test infrastructure, and test-utils fixtures. Co-Authored-By: Claude Sonnet 4.5 --- .gitea/workflows/e2e.yaml | 28 +++ .gitignore | 3 + e2e/README.md | 58 ++++++ e2e/auth.setup.ts | 67 ++++++ e2e/polaris.spec.ts | 61 ++++++ package-lock.json | 70 ++++++- package.json | 7 +- playwright.config.ts | 26 +++ src/api/PolarisDataContext.test.tsx | 48 +++++ src/api/polaris.test.ts | 180 +++++++++++++--- src/api/polaris.ts | 4 +- src/components/DashboardView.test.tsx | 134 ++++++++++++ src/components/NamespaceDetailView.test.tsx | 200 ++++++++++++++++++ src/components/NamespacesListView.test.tsx | 219 ++++++++++++++++++++ src/components/PolarisSettings.test.tsx | 83 ++++++++ src/test-utils.tsx | 61 ++++++ tsconfig.json | 3 + vitest.config.mts | 2 + vitest.setup.ts | 43 ++++ 19 files changed, 1264 insertions(+), 33 deletions(-) create mode 100644 .gitea/workflows/e2e.yaml create mode 100644 e2e/README.md create mode 100644 e2e/auth.setup.ts create mode 100644 e2e/polaris.spec.ts create mode 100644 playwright.config.ts create mode 100644 src/api/PolarisDataContext.test.tsx create mode 100644 src/components/DashboardView.test.tsx create mode 100644 src/components/NamespaceDetailView.test.tsx create mode 100644 src/components/NamespacesListView.test.tsx create mode 100644 src/components/PolarisSettings.test.tsx create mode 100644 src/test-utils.tsx create mode 100644 vitest.setup.ts diff --git a/.gitea/workflows/e2e.yaml b/.gitea/workflows/e2e.yaml new file mode 100644 index 0000000..2495a5e --- /dev/null +++ b/.gitea/workflows/e2e.yaml @@ -0,0 +1,28 @@ +name: E2E + +on: + push: + branches: + - main + pull_request: + +jobs: + e2e: + runs-on: ubuntu-latest + container: node:20 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: npm ci + + - name: Install Chromium + run: npx playwright install --with-deps chromium + + - name: Run E2E smoke tests + env: + HEADLAMP_URL: https://headlamp.animaniacs.farh.net + AUTHENTIK_USERNAME: ${{ secrets.AUTHENTIK_USERNAME }} + AUTHENTIK_PASSWORD: ${{ secrets.AUTHENTIK_PASSWORD }} + run: npx playwright test diff --git a/.gitignore b/.gitignore index 5df0edb..e219ede 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ dist/ .headlamp-plugin/ .mcp.json *.tar.gz +e2e/.auth/ +test-results/ +.playwright-mcp/ diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..3018861 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,58 @@ +# E2E Smoke Tests + +Playwright-based smoke tests that validate the Polaris plugin against a live Headlamp deployment. + +## CI + +E2E tests run automatically in Gitea Actions on pushes to `main` and pull requests. The workflow (`.gitea/workflows/e2e.yaml`) uses Authentik OIDC for authentication via repo secrets. + +### Required Gitea secrets + +| Secret | Description | +| -------------------- | -------------------------------------------------------------- | +| `AUTHENTIK_USERNAME` | Authentik email or username for a CI user with Headlamp access | +| `AUTHENTIK_PASSWORD` | Password for that user | + +## Running Locally + +### Option 1: OIDC via Authentik (same as CI) + +```bash +AUTHENTIK_USERNAME=you@example.com AUTHENTIK_PASSWORD=... npm run e2e +``` + +The default base URL is `https://headlamp.animaniacs.farh.net`. Override with `HEADLAMP_URL` if needed. + +### Option 2: K8s bearer token (port-forward) + +```bash +kubectl port-forward -n kube-system svc/headlamp 4466:80 +export HEADLAMP_TOKEN=$(kubectl create token headlamp -n kube-system) +HEADLAMP_URL=http://localhost:4466 npm run e2e +``` + +Or in headed mode (opens a browser window): + +```bash +HEADLAMP_URL=http://localhost:4466 npm run e2e:headed +``` + +## Environment Variables + +| Variable | Required | Default | Description | +| -------------------- | -------- | -------------------------------------- | --------------------------------------- | +| `HEADLAMP_URL` | No | `https://headlamp.animaniacs.farh.net` | Base URL of the Headlamp instance | +| `AUTHENTIK_USERNAME` | OIDC | — | Authentik email/username | +| `AUTHENTIK_PASSWORD` | OIDC | — | Authentik password | +| `HEADLAMP_TOKEN` | Token | — | Kubernetes bearer token (fallback auth) | + +Set either `AUTHENTIK_USERNAME` + `AUTHENTIK_PASSWORD` or `HEADLAMP_TOKEN`. OIDC takes priority if both are set. + +## What the Tests Validate + +- **Sidebar entry** — The Polaris sidebar item appears after login +- **Overview page** — Cluster score and check distribution render correctly +- **Namespaces page** — Table of namespaces loads with clickable links +- **Namespace detail** — Clicking a namespace shows its score and resource table + +These are smoke tests against real cluster data. They verify the plugin loads and renders without errors, not specific data values. diff --git a/e2e/auth.setup.ts b/e2e/auth.setup.ts new file mode 100644 index 0000000..bbf0054 --- /dev/null +++ b/e2e/auth.setup.ts @@ -0,0 +1,67 @@ +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; + + // Authentik step 1: fill username + await popup.getByRole('textbox', { name: /email or username/i }).fill(username); + await popup.getByRole('button', { name: /log in/i }).click(); + + // Authentik step 2: fill password + await popup.getByRole('textbox', { name: /password/i }).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 { + // Navigate to login — Headlamp redirects / to /c/main/login + await page.goto('/'); + await page.waitForURL('**/login'); + + // Click the token auth option + await page.getByRole('button', { name: /use a token/i }).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/polaris.spec.ts b/e2e/polaris.spec.ts new file mode 100644 index 0000000..29e1877 --- /dev/null +++ b/e2e/polaris.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Polaris plugin smoke tests', () => { + test('sidebar contains Polaris entry', async ({ page }) => { + await page.goto('/'); + // The sidebar is the "Navigation" nav element (not "Appbar Tools") + const sidebar = page.getByRole('navigation', { name: 'Navigation' }); + await expect(sidebar).toBeVisible({ timeout: 15_000 }); + await expect(sidebar.getByRole('button', { name: 'Polaris' })).toBeVisible(); + }); + + test('overview page renders cluster score', async ({ page }) => { + await page.goto('/c/main/polaris'); + + // SectionHeader renders a heading + await expect(page.getByRole('heading', { name: 'Polaris \u2014 Overview' })).toBeVisible(); + + // "Cluster Score" section exists with a percentage + await expect(page.getByText('Cluster Score')).toBeVisible(); + await expect(page.getByText(/%/)).toBeVisible(); + }); + + test('namespaces page renders table with links', async ({ page }) => { + await page.goto('/c/main/polaris/namespaces'); + + await expect(page.getByRole('heading', { name: 'Polaris \u2014 Namespaces' })).toBeVisible(); + + // Table should have at least one row with a namespace link + const table = page.locator('table'); + await expect(table).toBeVisible(); + const rows = table.locator('tbody tr'); + await expect(rows.first()).toBeVisible(); + + // Each namespace row should contain a link + const firstLink = rows.first().locator('a'); + await expect(firstLink).toBeVisible(); + }); + + test('namespace detail page renders from table link', async ({ page }) => { + await page.goto('/c/main/polaris/namespaces'); + + // Click the first namespace link in the table + const table = page.locator('table'); + await expect(table).toBeVisible(); + const firstLink = table.locator('tbody tr').first().locator('a'); + const namespaceName = await firstLink.textContent(); + await firstLink.click(); + + // Detail page should show the namespace name in the heading + await expect( + page.getByRole('heading', { name: `Polaris \u2014 ${namespaceName}` }) + ).toBeVisible(); + + // "Namespace Score" section should be present + await expect(page.getByText('Namespace Score')).toBeVisible(); + + // Resources table should exist + await expect(page.getByText('Resources')).toBeVisible(); + await expect(page.locator('table')).toBeVisible(); + }); +}); diff --git a/package-lock.json b/package-lock.json index 36b71e0..cece28c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "headlamp-polaris-plugin", - "version": "0.1.1", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "headlamp-polaris-plugin", - "version": "0.1.1", + "version": "0.1.3", "devDependencies": { - "@kinvolk/headlamp-plugin": "^0.13.0" + "@kinvolk/headlamp-plugin": "^0.13.0", + "@playwright/test": "^1.58.2" } }, "node_modules/@adobe/css-tools": { @@ -2469,6 +2470,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -13554,6 +13571,53 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index 69d125d..665d1e1 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,12 @@ "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": { - "@kinvolk/headlamp-plugin": "^0.13.0" + "@kinvolk/headlamp-plugin": "^0.13.0", + "@playwright/test": "^1.58.2" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..1043870 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,26 @@ +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 || 'https://headlamp.animaniacs.farh.net', + trace: 'on-first-retry', + }, + projects: [ + { name: 'setup', testMatch: /auth\.setup\.ts/ }, + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + storageState: 'e2e/.auth/state.json', + }, + dependencies: ['setup'], + }, + ], +}); diff --git a/src/api/PolarisDataContext.test.tsx b/src/api/PolarisDataContext.test.tsx new file mode 100644 index 0000000..c4ce51c --- /dev/null +++ b/src/api/PolarisDataContext.test.tsx @@ -0,0 +1,48 @@ +import { renderHook } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { makeAuditData, makeResult } from '../test-utils'; + +vi.mock('@kinvolk/headlamp-plugin/lib', () => ({ + ApiProxy: { request: vi.fn() }, +})); + +// Mock usePolarisData so PolarisDataProvider doesn't make real API calls +vi.mock('./polaris', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + usePolarisData: vi.fn(() => ({ + data: makeAuditData([makeResult()]), + loading: false, + error: null, + })), + }; +}); + +import { PolarisDataProvider, usePolarisDataContext } from './PolarisDataContext'; + +describe('usePolarisDataContext', () => { + it('throws when used outside PolarisDataProvider', () => { + // Suppress console.error from React during expected error + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + renderHook(() => usePolarisDataContext()); + }).toThrow('usePolarisDataContext must be used within a PolarisDataProvider'); + + spy.mockRestore(); + }); + + it('returns context value when inside PolarisDataProvider', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => usePolarisDataContext(), { wrapper }); + + expect(result.current.data).not.toBeNull(); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + }); +}); diff --git a/src/api/polaris.test.ts b/src/api/polaris.test.ts index bbc29e2..180fdb8 100644 --- a/src/api/polaris.test.ts +++ b/src/api/polaris.test.ts @@ -1,45 +1,25 @@ -import { describe, expect, it, vi } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { makeAuditData, makeResult } from '../test-utils'; vi.mock('@kinvolk/headlamp-plugin/lib', () => ({ ApiProxy: { request: vi.fn() }, })); +import { ApiProxy } from '@kinvolk/headlamp-plugin/lib'; import { - AuditData, computeScore, countResults, countResultsForItems, filterResultsByNamespace, getNamespaces, + getRefreshInterval, Result, ResultCounts, + setRefreshInterval, + usePolarisData, } from './polaris'; -// --- Fixtures --- - -function makeResult(overrides: Partial = {}): Result { - return { - Name: 'my-deploy', - Namespace: 'default', - Kind: 'Deployment', - Results: {}, - CreatedTime: '2025-01-01T00:00:00Z', - ...overrides, - }; -} - -function makeAuditData(results: Result[]): AuditData { - return { - PolarisOutputVersion: '1.0', - AuditTime: '2025-01-01T00:00:00Z', - SourceType: 'Cluster', - SourceName: 'test', - DisplayName: 'test', - ClusterInfo: { Version: '1.28', Nodes: 3, Pods: 10, Namespaces: 2, Controllers: 5 }, - Results: results, - }; -} - // --- computeScore --- describe('computeScore', () => { @@ -241,6 +221,15 @@ describe('getNamespaces', () => { ]); expect(getNamespaces(data)).toEqual(['alpha', 'beta', 'gamma']); }); + + it('excludes results with empty namespace (cluster-scoped resources)', () => { + const data = makeAuditData([ + makeResult({ Namespace: '' }), + makeResult({ Namespace: 'alpha' }), + makeResult({ Namespace: '' }), + ]); + expect(getNamespaces(data)).toEqual(['alpha']); + }); }); // --- filterResultsByNamespace --- @@ -262,3 +251,140 @@ describe('filterResultsByNamespace', () => { expect(filterResultsByNamespace(data, 'ns-missing')).toEqual([]); }); }); + +// --- getRefreshInterval / setRefreshInterval --- + +describe('getRefreshInterval', () => { + beforeEach(() => { + window.localStorage.removeItem('polaris-plugin-refresh-interval'); + }); + + it('returns default (300) when nothing stored', () => { + expect(getRefreshInterval()).toBe(300); + }); + + it('returns stored value when valid', () => { + localStorage.setItem('polaris-plugin-refresh-interval', '60'); + expect(getRefreshInterval()).toBe(60); + }); + + it('returns default for non-numeric stored value', () => { + localStorage.setItem('polaris-plugin-refresh-interval', 'abc'); + expect(getRefreshInterval()).toBe(300); + }); + + it('returns default for zero stored value', () => { + localStorage.setItem('polaris-plugin-refresh-interval', '0'); + expect(getRefreshInterval()).toBe(300); + }); + + it('returns default for negative stored value', () => { + localStorage.setItem('polaris-plugin-refresh-interval', '-10'); + expect(getRefreshInterval()).toBe(300); + }); +}); + +describe('setRefreshInterval', () => { + beforeEach(() => { + window.localStorage.removeItem('polaris-plugin-refresh-interval'); + }); + + it('stores value that getRefreshInterval reads back', () => { + setRefreshInterval(1800); + expect(getRefreshInterval()).toBe(1800); + }); +}); + +// --- usePolarisData --- + +describe('usePolarisData', () => { + const mockRequest = ApiProxy.request as ReturnType; + + beforeEach(() => { + mockRequest.mockReset(); + }); + + it('returns data on successful fetch', async () => { + const auditData = makeAuditData([makeResult()]); + mockRequest.mockResolvedValue(auditData); + + const { result } = renderHook(() => usePolarisData(300)); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.data).toEqual(auditData); + expect(result.current.error).toBeNull(); + }); + + it('returns RBAC error on 403', async () => { + mockRequest.mockRejectedValue({ status: 403 }); + + const { result } = renderHook(() => usePolarisData(300)); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.data).toBeNull(); + expect(result.current.error).toContain('403'); + expect(result.current.error).toContain('RBAC'); + }); + + it('returns not-installed error on 404', async () => { + mockRequest.mockRejectedValue({ status: 404 }); + + const { result } = renderHook(() => usePolarisData(300)); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.error).toContain('not reachable'); + }); + + it('returns not-installed error on 503', async () => { + mockRequest.mockRejectedValue({ status: 503 }); + + const { result } = renderHook(() => usePolarisData(300)); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.error).toContain('not reachable'); + }); + + it('returns generic error for other failures', async () => { + mockRequest.mockRejectedValue(new Error('network down')); + + const { result } = renderHook(() => usePolarisData(300)); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.error).toContain('Failed to fetch'); + expect(result.current.error).toContain('network down'); + }); + + it('does not update state after unmount', async () => { + let resolveFetch: (value: unknown) => void = () => {}; + mockRequest.mockReturnValue( + new Promise(resolve => { + resolveFetch = resolve; + }) + ); + + const { result, unmount } = renderHook(() => usePolarisData(300)); + expect(result.current.loading).toBe(true); + + unmount(); + + // Resolve after unmount — should not throw or update state + await act(async () => { + resolveFetch(makeAuditData([])); + }); + }); +}); diff --git a/src/api/polaris.ts b/src/api/polaris.ts index efd0895..3f65fd7 100644 --- a/src/api/polaris.ts +++ b/src/api/polaris.ts @@ -105,7 +105,9 @@ export function countResultsForItems(results: Result[]): ResultCounts { export function getNamespaces(data: AuditData): string[] { const namespaces = new Set(); for (const result of data.Results) { - namespaces.add(result.Namespace); + if (result.Namespace) { + namespaces.add(result.Namespace); + } } return Array.from(namespaces).sort(); } diff --git a/src/components/DashboardView.test.tsx b/src/components/DashboardView.test.tsx new file mode 100644 index 0000000..78eb87d --- /dev/null +++ b/src/components/DashboardView.test.tsx @@ -0,0 +1,134 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { makeAuditData, makeResult } from '../test-utils'; + +// Mock Headlamp lib +vi.mock('@kinvolk/headlamp-plugin/lib', () => ({ + ApiProxy: { request: vi.fn() }, +})); + +// Mock Headlamp CommonComponents as thin pass-throughs +vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({ + Loader: ({ title }: { title: string }) =>
{title}
, + SectionBox: ({ title, children }: { title?: string; children?: React.ReactNode }) => ( +
+ {children} +
+ ), + SectionHeader: ({ title }: { title: string }) =>
{title}
, + StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => ( + + {children} + + ), + NameValueTable: ({ rows }: { rows: Array<{ name: string; value: React.ReactNode }> }) => ( + + + {rows.map(row => ( + + + + + ))} + +
{row.name}{row.value}
+ ), + PercentageCircle: ({ label }: { label: string }) => ( +
{label}
+ ), + PercentageBar: () =>
, +})); + +// Mock the context hook — we'll override per test via mockReturnValue +const mockUsePolarisDataContext = vi.fn(); +vi.mock('../api/PolarisDataContext', () => ({ + usePolarisDataContext: () => mockUsePolarisDataContext(), +})); + +import DashboardView from './DashboardView'; + +describe('DashboardView', () => { + it('renders loader when loading', () => { + mockUsePolarisDataContext.mockReturnValue({ + data: null, + loading: true, + error: null, + }); + + render(); + expect(screen.getByTestId('loader')).toHaveTextContent('Loading Polaris audit data'); + }); + + it('renders error message when error is set', () => { + mockUsePolarisDataContext.mockReturnValue({ + data: null, + loading: false, + error: 'Access denied (403)', + }); + + render(); + expect(screen.getByText('Access denied (403)')).toBeInTheDocument(); + }); + + it('renders score, check distribution, and cluster info with data', () => { + const data = makeAuditData([ + makeResult({ + Results: { + c1: { + ID: 'c1', + Message: '', + Details: [], + Success: true, + Severity: 'warning', + Category: 'X', + }, + c2: { + ID: 'c2', + Message: '', + Details: [], + Success: false, + Severity: 'danger', + Category: 'X', + }, + }, + }), + ]); + + mockUsePolarisDataContext.mockReturnValue({ + data, + loading: false, + error: null, + }); + + render(); + + // Score circle shows 50% + expect(screen.getByTestId('percentage-circle')).toHaveTextContent('50%'); + + // Check distribution values + expect(screen.getByText('Total Checks')).toBeInTheDocument(); + + // Cluster info section (title is in data-title attr of SectionBox) + const sectionBoxes = screen.getAllByTestId('section-box'); + const clusterInfoBox = sectionBoxes.find( + el => el.getAttribute('data-title') === 'Cluster Info' + ); + expect(clusterInfoBox).toBeDefined(); + + // Cluster info values + expect(screen.getByText('Nodes')).toBeInTheDocument(); + expect(screen.getByText('Pods')).toBeInTheDocument(); + }); + + it('renders "No Data" when no data and no error', () => { + mockUsePolarisDataContext.mockReturnValue({ + data: null, + loading: false, + error: null, + }); + + render(); + expect(screen.getByText('No Polaris audit results found.')).toBeInTheDocument(); + }); +}); diff --git a/src/components/NamespaceDetailView.test.tsx b/src/components/NamespaceDetailView.test.tsx new file mode 100644 index 0000000..99befc1 --- /dev/null +++ b/src/components/NamespaceDetailView.test.tsx @@ -0,0 +1,200 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { makeAuditData, makeResult } from '../test-utils'; + +// Mock Headlamp lib +vi.mock('@kinvolk/headlamp-plugin/lib', () => ({ + ApiProxy: { request: vi.fn() }, +})); + +// Mock react-router-dom useParams +const mockNamespace = vi.fn(() => 'test-ns'); +vi.mock('react-router-dom', () => ({ + useParams: () => ({ namespace: mockNamespace() }), +})); + +// Mock Headlamp CommonComponents +vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({ + Loader: ({ title }: { title: string }) =>
{title}
, + SectionBox: ({ title, children }: { title?: string; children?: React.ReactNode }) => ( +
+ {children} +
+ ), + SectionHeader: ({ title }: { title: string }) =>
{title}
, + StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => ( + + {children} + + ), + NameValueTable: ({ rows }: { rows: Array<{ name: string; value: React.ReactNode }> }) => ( + + + {rows.map(row => ( + + + + + ))} + +
{row.name}{row.value}
+ ), + SimpleTable: ({ + columns, + data, + emptyMessage, + }: { + columns: Array<{ label: string; getter: (row: unknown) => React.ReactNode }>; + data: unknown[]; + emptyMessage?: string; + }) => + data.length === 0 ? ( +
{emptyMessage}
+ ) : ( + + + + {columns.map(col => ( + + ))} + + + + {data.map((row, i) => ( + + {columns.map(col => ( + + ))} + + ))} + +
{col.label}
{col.getter(row)}
+ ), +})); + +const mockUsePolarisDataContext = vi.fn(); +vi.mock('../api/PolarisDataContext', () => ({ + usePolarisDataContext: () => mockUsePolarisDataContext(), +})); + +import NamespaceDetailView from './NamespaceDetailView'; + +describe('NamespaceDetailView', () => { + it('renders loader when loading', () => { + mockUsePolarisDataContext.mockReturnValue({ + data: null, + loading: true, + error: null, + }); + + render(); + expect(screen.getByTestId('loader')).toHaveTextContent('Loading Polaris data for test-ns'); + }); + + it('renders error message when error is set', () => { + mockUsePolarisDataContext.mockReturnValue({ + data: null, + loading: false, + error: 'Access denied (403)', + }); + + render(); + expect(screen.getByText('Access denied (403)')).toBeInTheDocument(); + expect(screen.getByTestId('section-header')).toHaveTextContent('Polaris — test-ns'); + }); + + it('renders "No Data" when no data and no error', () => { + mockUsePolarisDataContext.mockReturnValue({ + data: null, + loading: false, + error: null, + }); + + render(); + expect(screen.getByText('No Polaris audit results found.')).toBeInTheDocument(); + }); + + it('renders namespace score and resource table with data', () => { + const data = makeAuditData([ + makeResult({ + Name: 'deploy-a', + Namespace: 'test-ns', + Kind: 'Deployment', + Results: { + c1: { + ID: 'c1', + Message: '', + Details: [], + Success: true, + Severity: 'warning', + Category: 'X', + }, + c2: { + ID: 'c2', + Message: '', + Details: [], + Success: false, + Severity: 'warning', + Category: 'X', + }, + }, + }), + makeResult({ + Name: 'other', + Namespace: 'other-ns', + Kind: 'Deployment', + Results: { + c3: { + ID: 'c3', + Message: '', + Details: [], + Success: true, + Severity: 'warning', + Category: 'X', + }, + }, + }), + ]); + + mockUsePolarisDataContext.mockReturnValue({ + data, + loading: false, + error: null, + }); + + render(); + + // Header + expect(screen.getByTestId('section-header')).toHaveTextContent('Polaris — test-ns'); + + // Score section: 50% (1 pass / 2 total) + expect(screen.getByText('50%')).toBeInTheDocument(); + expect(screen.getByText('Total Checks')).toBeInTheDocument(); + + // Resource table shows only test-ns resources + expect(screen.getByText('deploy-a')).toBeInTheDocument(); + expect(screen.queryByText('other')).not.toBeInTheDocument(); + }); + + it('renders empty table message for namespace with no results', () => { + const data = makeAuditData([ + makeResult({ + Name: 'deploy-a', + Namespace: 'other-ns', + Results: {}, + }), + ]); + + mockUsePolarisDataContext.mockReturnValue({ + data, + loading: false, + error: null, + }); + + render(); + expect(screen.getByTestId('simple-table-empty')).toHaveTextContent( + 'No resources found in namespace "test-ns"' + ); + }); +}); diff --git a/src/components/NamespacesListView.test.tsx b/src/components/NamespacesListView.test.tsx new file mode 100644 index 0000000..d0bbc46 --- /dev/null +++ b/src/components/NamespacesListView.test.tsx @@ -0,0 +1,219 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; +import { makeAuditData, makeResult } from '../test-utils'; + +// Mock Headlamp lib +vi.mock('@kinvolk/headlamp-plugin/lib', () => ({ + ApiProxy: { request: vi.fn() }, + Router: { + createRouteURL: (name: string, params: Record) => + `/polaris/ns/${params.namespace}`, + }, +})); + +// Mock Headlamp CommonComponents +vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({ + Loader: ({ title }: { title: string }) =>
{title}
, + SectionBox: ({ title, children }: { title?: string; children?: React.ReactNode }) => ( +
+ {children} +
+ ), + SectionHeader: ({ title }: { title: string }) =>
{title}
, + StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => ( + + {children} + + ), + NameValueTable: ({ rows }: { rows: Array<{ name: string; value: React.ReactNode }> }) => ( + + + {rows.map(row => ( + + + + + ))} + +
{row.name}{row.value}
+ ), + SimpleTable: ({ + columns, + data, + emptyMessage, + }: { + columns: Array<{ label: string; getter: (row: unknown) => React.ReactNode }>; + data: unknown[]; + emptyMessage?: string; + }) => + data.length === 0 ? ( +
{emptyMessage}
+ ) : ( + + + + {columns.map(col => ( + + ))} + + + + {data.map((row, i) => ( + + {columns.map(col => ( + + ))} + + ))} + +
{col.label}
{col.getter(row)}
+ ), +})); + +const mockUsePolarisDataContext = vi.fn(); +vi.mock('../api/PolarisDataContext', () => ({ + usePolarisDataContext: () => mockUsePolarisDataContext(), +})); + +import NamespacesListView from './NamespacesListView'; + +function renderWithRouter(ui: React.ReactElement) { + return render({ui}); +} + +describe('NamespacesListView', () => { + it('renders loader when loading', () => { + mockUsePolarisDataContext.mockReturnValue({ + data: null, + loading: true, + error: null, + }); + + renderWithRouter(); + expect(screen.getByTestId('loader')).toHaveTextContent('Loading Polaris audit data'); + }); + + it('renders error message when error is set', () => { + mockUsePolarisDataContext.mockReturnValue({ + data: null, + loading: false, + error: 'Polaris dashboard not reachable', + }); + + renderWithRouter(); + expect(screen.getByText('Polaris dashboard not reachable')).toBeInTheDocument(); + }); + + it('renders "No Data" when no data and no error', () => { + mockUsePolarisDataContext.mockReturnValue({ + data: null, + loading: false, + error: null, + }); + + renderWithRouter(); + expect(screen.getByText('No Polaris audit results found.')).toBeInTheDocument(); + }); + + it('renders namespace rows with correct scores and links', () => { + const data = makeAuditData([ + makeResult({ + Name: 'deploy-a', + Namespace: 'alpha', + Results: { + c1: { + ID: 'c1', + Message: '', + Details: [], + Success: true, + Severity: 'warning', + Category: 'X', + }, + }, + }), + makeResult({ + Name: 'deploy-b', + Namespace: 'beta', + Results: { + c2: { + ID: 'c2', + Message: '', + Details: [], + Success: false, + Severity: 'danger', + Category: 'X', + }, + }, + }), + ]); + + mockUsePolarisDataContext.mockReturnValue({ + data, + loading: false, + error: null, + }); + + renderWithRouter(); + + // Namespace links + const alphaLink = screen.getByText('alpha'); + expect(alphaLink.closest('a')).toHaveAttribute('href', '/polaris/ns/alpha'); + + const betaLink = screen.getByText('beta'); + expect(betaLink.closest('a')).toHaveAttribute('href', '/polaris/ns/beta'); + }); + + it('uses correct scoreStatus: >=80 success, >=50 warning, <50 error', () => { + // Create a namespace with 100% score (1 pass) and one with 0% (1 danger) + const data = makeAuditData([ + makeResult({ + Name: 'perfect', + Namespace: 'good-ns', + Results: { + c1: { + ID: 'c1', + Message: '', + Details: [], + Success: true, + Severity: 'warning', + Category: 'X', + }, + }, + }), + makeResult({ + Name: 'bad', + Namespace: 'bad-ns', + Results: { + c2: { + ID: 'c2', + Message: '', + Details: [], + Success: false, + Severity: 'danger', + Category: 'X', + }, + }, + }), + ]); + + mockUsePolarisDataContext.mockReturnValue({ + data, + loading: false, + error: null, + }); + + renderWithRouter(); + + // Find score StatusLabels - good-ns has 100% (success), bad-ns has 0% (error) + const statusLabels = screen.getAllByTestId('status-label'); + const scoreLabels = statusLabels.filter(el => el.textContent?.includes('%')); + + const successScore = scoreLabels.find(el => el.textContent === '100%'); + expect(successScore).toHaveAttribute('data-status', 'success'); + + const errorScore = scoreLabels.find(el => el.textContent === '0%'); + expect(errorScore).toHaveAttribute('data-status', 'error'); + }); +}); diff --git a/src/components/PolarisSettings.test.tsx b/src/components/PolarisSettings.test.tsx new file mode 100644 index 0000000..1fd4203 --- /dev/null +++ b/src/components/PolarisSettings.test.tsx @@ -0,0 +1,83 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +// Mock Headlamp lib +vi.mock('@kinvolk/headlamp-plugin/lib', () => ({ + ApiProxy: { request: vi.fn() }, +})); + +// Mock Headlamp CommonComponents +vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({ + SectionBox: ({ title, children }: { title?: string; children?: React.ReactNode }) => ( +
+ {children} +
+ ), + NameValueTable: ({ rows }: { rows: Array<{ name: string; value: React.ReactNode }> }) => ( +
+ {rows.map(row => ( +
+ {row.name} + {row.value} +
+ ))} +
+ ), +})); + +import { setRefreshInterval } from '../api/polaris'; +import PolarisSettings from './PolarisSettings'; + +describe('PolarisSettings', () => { + it('renders with interval from props.data', () => { + render(); + + const select = screen.getByRole('combobox'); + expect(select).toHaveValue('60'); + }); + + it('falls back to getRefreshInterval when no prop data', () => { + // Default is 300 (5 minutes) + render(); + + const select = screen.getByRole('combobox'); + expect(select).toHaveValue('300'); + }); + + it('renders all interval options', () => { + render(); + + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(4); + expect(options[0]).toHaveTextContent('1 minute'); + expect(options[1]).toHaveTextContent('5 minutes'); + expect(options[2]).toHaveTextContent('10 minutes'); + expect(options[3]).toHaveTextContent('30 minutes'); + }); + + it('calls setRefreshInterval and onDataChange when selection changes', async () => { + const onDataChange = vi.fn(); + render(); + + const select = screen.getByRole('combobox'); + await userEvent.selectOptions(select, '1800'); + + // Check localStorage was updated + expect(localStorage.getItem('polaris-plugin-refresh-interval')).toBe('1800'); + + // Check callback was called with merged data + expect(onDataChange).toHaveBeenCalledWith({ refreshInterval: 1800 }); + }); + + it('works without onDataChange callback', async () => { + render(); + + const select = screen.getByRole('combobox'); + // Should not throw even without onDataChange + await userEvent.selectOptions(select, '60'); + + expect(localStorage.getItem('polaris-plugin-refresh-interval')).toBe('60'); + }); +}); diff --git a/src/test-utils.tsx b/src/test-utils.tsx new file mode 100644 index 0000000..956691b --- /dev/null +++ b/src/test-utils.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { AuditData, Result } from './api/polaris'; + +// --- Fixtures --- + +export function makeResult(overrides: Partial = {}): Result { + return { + Name: 'my-deploy', + Namespace: 'default', + Kind: 'Deployment', + Results: {}, + CreatedTime: '2025-01-01T00:00:00Z', + ...overrides, + }; +} + +export function makeAuditData(results: Result[]): AuditData { + return { + PolarisOutputVersion: '1.0', + AuditTime: '2025-01-01T00:00:00Z', + SourceType: 'Cluster', + SourceName: 'test', + DisplayName: 'test', + ClusterInfo: { Version: '1.28', Nodes: 3, Pods: 10, Namespaces: 2, Controllers: 5 }, + Results: results, + }; +} + +// --- Mock Polaris Context Provider --- + +interface MockPolarisProviderProps { + data?: AuditData | null; + loading?: boolean; + error?: string | null; + children: React.ReactNode; +} + +// We dynamically import PolarisDataContext to inject mock values. +// This avoids mocking the hook module — we supply real context with controlled values. +const PolarisDataContext = React.createContext<{ + data: AuditData | null; + loading: boolean; + error: string | null; +} | null>(null); + +export function MockPolarisProvider({ + data = null, + loading = false, + error = null, + children, +}: MockPolarisProviderProps) { + return ( + + {children} + + ); +} + +// The context reference used in test-utils must be the SAME object the components import. +// We achieve this by having component tests mock `usePolarisDataContext` to read from our context. +export { PolarisDataContext }; diff --git a/tsconfig.json b/tsconfig.json index 59af751..a6b2abd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,7 @@ { "extends": "@kinvolk/headlamp-plugin/config/plugins-tsconfig.json", + "compilerOptions": { + "types": ["vite/client", "vite-plugin-svgr/client", "vitest/globals", "lodash", "@testing-library/jest-dom"] + }, "include": ["src"] } diff --git a/vitest.config.mts b/vitest.config.mts index 5270893..3a3739f 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -4,5 +4,7 @@ export default defineConfig({ test: { globals: true, environment: 'jsdom', + setupFiles: ['./vitest.setup.ts'], + exclude: ['e2e/**', 'node_modules/**'], }, }); diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 0000000..1fa97b6 --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1,43 @@ +import '@testing-library/jest-dom'; + +// Node 22+ ships a minimal built-in `localStorage` global (property-bag only, +// no getItem/setItem/removeItem/clear) that shadows jsdom's Web Storage +// implementation. Provide a spec-compliant shim so code under test works. +if (typeof localStorage !== 'undefined' && typeof localStorage.getItem !== 'function') { + const store = new Map(); + + const storage = { + getItem(key: string): string | null { + return store.get(key) ?? null; + }, + setItem(key: string, value: string): void { + store.set(key, String(value)); + }, + removeItem(key: string): void { + store.delete(key); + }, + clear(): void { + store.clear(); + }, + get length(): number { + return store.size; + }, + key(index: number): string | null { + return [...store.keys()][index] ?? null; + }, + }; + + Object.defineProperty(globalThis, 'localStorage', { + value: storage, + writable: true, + configurable: true, + }); + + if (typeof window !== 'undefined') { + Object.defineProperty(window, 'localStorage', { + value: storage, + writable: true, + configurable: true, + }); + } +}