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, + }); + } +}