Merge pull request 'feat: E2E smoke tests + fix empty namespace crash' (#19) from feat/e2e-tests-and-empty-ns-fix into main

Reviewed-on: farhoodliquor/headlamp-polaris-plugin#19
This commit is contained in:
2026-02-07 19:17:11 -05:00
21 changed files with 1269 additions and 40 deletions
+28
View File
@@ -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
+3
View File
@@ -3,3 +3,6 @@ dist/
.headlamp-plugin/
.mcp.json
*.tar.gz
e2e/.auth/
test-results/
.playwright-mcp/
+2 -2
View File
@@ -13,7 +13,7 @@ Adds a **Polaris** top-level sidebar section to Headlamp with the following view
- **Namespace detail** -- per-namespace score, check counts, and a resource table showing pass/warning/danger per workload
- **External link** -- quick jump to the native Polaris dashboard via the Kubernetes service proxy (from namespace detail view)
Data is fetched from the Polaris dashboard API through the Kubernetes service proxy (`/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/results.json`). The plugin is read-only -- it never writes to the cluster.
Data is fetched from the Polaris dashboard API through the Kubernetes service proxy (`/api/v1/namespaces/polaris/services/polaris-dashboard/proxy/results.json`). The plugin is read-only -- it never writes to the cluster.
Results are refreshed on a user-configurable interval (1 / 5 / 10 / 30 minutes, default 5). The setting is available in **Settings > Plugins > Polaris** and persists in the browser's localStorage.
@@ -204,7 +204,7 @@ vitest.config.mts -- Vitest configuration (jsdom environment
The plugin fetches live audit results from the Polaris dashboard HTTP API via the Kubernetes service proxy:
```
GET /api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/results.json
GET /api/v1/namespaces/polaris/services/polaris-dashboard/proxy/results.json
```
This endpoint is served by the `polaris-dashboard` ClusterIP service, which is created by the Polaris Helm chart when `dashboard.enabled: true`. The JSON response matches Polaris's `AuditData` schema (`pkg/validator/output.go`):
+2 -2
View File
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
Headlamp plugin that surfaces Fairwinds Polaris audit results inside the Headlamp UI. Queries the Polaris dashboard API via the Kubernetes service proxy (`/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/results.json`). Target Headlamp ≥ v0.26.
Headlamp plugin that surfaces Fairwinds Polaris audit results inside the Headlamp UI. Queries the Polaris dashboard API via the Kubernetes service proxy (`/api/v1/namespaces/polaris/services/polaris-dashboard/proxy/results.json`). Target Headlamp ≥ v0.26.
## Build & Development Commands
@@ -50,7 +50,7 @@ Top-level sidebar section at `/polaris` with sub-routes for namespaces list (`/p
## Security / RBAC Requirements
The plugin reaches Polaris through the Kubernetes API server's service proxy sub-resource (`/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/...`). The Headlamp service account (or the user's bearer token when Headlamp runs in token-auth mode) must be granted:
The plugin reaches Polaris through the Kubernetes API server's service proxy sub-resource (`/api/v1/namespaces/polaris/services/polaris-dashboard/proxy/...`). The Headlamp service account (or the user's bearer token when Headlamp runs in token-auth mode) must be granted:
| Verb | API Group | Resource | Resource Name | Namespace |
|------|-----------|----------|---------------|-----------|
+58
View File
@@ -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.
+67
View File
@@ -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<void> {
// 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<void> {
// 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 });
});
+61
View File
@@ -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();
});
});
+67 -3
View File
@@ -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",
+5 -2
View File
@@ -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"
}
}
+26
View File
@@ -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'],
},
],
});
+48
View File
@@ -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<typeof import('./polaris')>();
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 }) => (
<PolarisDataProvider>{children}</PolarisDataProvider>
);
const { result } = renderHook(() => usePolarisDataContext(), { wrapper });
expect(result.current.data).not.toBeNull();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
});
});
+153 -27
View File
@@ -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> = {}): 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<typeof vi.fn>;
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([]));
});
});
});
+5 -4
View File
@@ -105,7 +105,9 @@ export function countResultsForItems(results: Result[]): ResultCounts {
export function getNamespaces(data: AuditData): string[] {
const namespaces = new Set<string>();
for (const result of data.Results) {
namespaces.add(result.Namespace);
if (result.Namespace) {
namespaces.add(result.Namespace);
}
}
return Array.from(namespaces).sort();
}
@@ -144,7 +146,7 @@ export function setRefreshInterval(seconds: number): void {
// --- Polaris dashboard proxy URL ---
export const POLARIS_DASHBOARD_PROXY =
'/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/';
'/api/v1/namespaces/polaris/services/polaris-dashboard/proxy/';
// --- Score computation ---
@@ -155,8 +157,7 @@ export function computeScore(counts: ResultCounts): number {
// --- Data fetching hook ---
const POLARIS_API_PATH =
'/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/results.json';
const POLARIS_API_PATH = '/api/v1/namespaces/polaris/services/polaris-dashboard/proxy/results.json';
interface PolarisDataState {
data: AuditData | null;
+134
View File
@@ -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 }) => <div data-testid="loader">{title}</div>,
SectionBox: ({ title, children }: { title?: string; children?: React.ReactNode }) => (
<div data-testid="section-box" data-title={title}>
{children}
</div>
),
SectionHeader: ({ title }: { title: string }) => <div data-testid="section-header">{title}</div>,
StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => (
<span data-testid="status-label" data-status={status}>
{children}
</span>
),
NameValueTable: ({ rows }: { rows: Array<{ name: string; value: React.ReactNode }> }) => (
<table data-testid="name-value-table">
<tbody>
{rows.map(row => (
<tr key={row.name}>
<td>{row.name}</td>
<td>{row.value}</td>
</tr>
))}
</tbody>
</table>
),
PercentageCircle: ({ label }: { label: string }) => (
<div data-testid="percentage-circle">{label}</div>
),
PercentageBar: () => <div data-testid="percentage-bar" />,
}));
// 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(<DashboardView />);
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(<DashboardView />);
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(<DashboardView />);
// 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(<DashboardView />);
expect(screen.getByText('No Polaris audit results found.')).toBeInTheDocument();
});
});
+200
View File
@@ -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 }) => <div data-testid="loader">{title}</div>,
SectionBox: ({ title, children }: { title?: string; children?: React.ReactNode }) => (
<div data-testid="section-box" data-title={title}>
{children}
</div>
),
SectionHeader: ({ title }: { title: string }) => <div data-testid="section-header">{title}</div>,
StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => (
<span data-testid="status-label" data-status={status}>
{children}
</span>
),
NameValueTable: ({ rows }: { rows: Array<{ name: string; value: React.ReactNode }> }) => (
<table data-testid="name-value-table">
<tbody>
{rows.map(row => (
<tr key={row.name}>
<td>{row.name}</td>
<td>{row.value}</td>
</tr>
))}
</tbody>
</table>
),
SimpleTable: ({
columns,
data,
emptyMessage,
}: {
columns: Array<{ label: string; getter: (row: unknown) => React.ReactNode }>;
data: unknown[];
emptyMessage?: string;
}) =>
data.length === 0 ? (
<div data-testid="simple-table-empty">{emptyMessage}</div>
) : (
<table data-testid="simple-table">
<thead>
<tr>
{columns.map(col => (
<th key={col.label}>{col.label}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, i) => (
<tr key={i}>
{columns.map(col => (
<td key={col.label}>{col.getter(row)}</td>
))}
</tr>
))}
</tbody>
</table>
),
}));
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(<NamespaceDetailView />);
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(<NamespaceDetailView />);
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(<NamespaceDetailView />);
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(<NamespaceDetailView />);
// 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(<NamespaceDetailView />);
expect(screen.getByTestId('simple-table-empty')).toHaveTextContent(
'No resources found in namespace "test-ns"'
);
});
});
+219
View File
@@ -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<string, string>) =>
`/polaris/ns/${params.namespace}`,
},
}));
// Mock Headlamp CommonComponents
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
Loader: ({ title }: { title: string }) => <div data-testid="loader">{title}</div>,
SectionBox: ({ title, children }: { title?: string; children?: React.ReactNode }) => (
<div data-testid="section-box" data-title={title}>
{children}
</div>
),
SectionHeader: ({ title }: { title: string }) => <div data-testid="section-header">{title}</div>,
StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => (
<span data-testid="status-label" data-status={status}>
{children}
</span>
),
NameValueTable: ({ rows }: { rows: Array<{ name: string; value: React.ReactNode }> }) => (
<table data-testid="name-value-table">
<tbody>
{rows.map(row => (
<tr key={row.name}>
<td>{row.name}</td>
<td>{row.value}</td>
</tr>
))}
</tbody>
</table>
),
SimpleTable: ({
columns,
data,
emptyMessage,
}: {
columns: Array<{ label: string; getter: (row: unknown) => React.ReactNode }>;
data: unknown[];
emptyMessage?: string;
}) =>
data.length === 0 ? (
<div data-testid="simple-table-empty">{emptyMessage}</div>
) : (
<table data-testid="simple-table">
<thead>
<tr>
{columns.map(col => (
<th key={col.label}>{col.label}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, i) => (
<tr key={i}>
{columns.map(col => (
<td key={col.label}>{col.getter(row)}</td>
))}
</tr>
))}
</tbody>
</table>
),
}));
const mockUsePolarisDataContext = vi.fn();
vi.mock('../api/PolarisDataContext', () => ({
usePolarisDataContext: () => mockUsePolarisDataContext(),
}));
import NamespacesListView from './NamespacesListView';
function renderWithRouter(ui: React.ReactElement) {
return render(<MemoryRouter>{ui}</MemoryRouter>);
}
describe('NamespacesListView', () => {
it('renders loader when loading', () => {
mockUsePolarisDataContext.mockReturnValue({
data: null,
loading: true,
error: null,
});
renderWithRouter(<NamespacesListView />);
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(<NamespacesListView />);
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(<NamespacesListView />);
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(<NamespacesListView />);
// 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(<NamespacesListView />);
// 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');
});
});
+82
View File
@@ -0,0 +1,82 @@
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 }) => (
<div data-testid="section-box" data-title={title}>
{children}
</div>
),
NameValueTable: ({ rows }: { rows: Array<{ name: string; value: React.ReactNode }> }) => (
<div data-testid="name-value-table">
{rows.map(row => (
<div key={row.name}>
<span>{row.name}</span>
<span>{row.value}</span>
</div>
))}
</div>
),
}));
import PolarisSettings from './PolarisSettings';
describe('PolarisSettings', () => {
it('renders with interval from props.data', () => {
render(<PolarisSettings data={{ refreshInterval: 60 }} />);
const select = screen.getByRole('combobox');
expect(select).toHaveValue('60');
});
it('falls back to getRefreshInterval when no prop data', () => {
// Default is 300 (5 minutes)
render(<PolarisSettings />);
const select = screen.getByRole('combobox');
expect(select).toHaveValue('300');
});
it('renders all interval options', () => {
render(<PolarisSettings />);
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(<PolarisSettings data={{ refreshInterval: 300 }} onDataChange={onDataChange} />);
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(<PolarisSettings data={{ refreshInterval: 300 }} />);
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');
});
});
+61
View File
@@ -0,0 +1,61 @@
import React from 'react';
import { AuditData, Result } from './api/polaris';
// --- Fixtures ---
export function makeResult(overrides: Partial<Result> = {}): 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 (
<PolarisDataContext.Provider value={{ data, loading, error }}>
{children}
</PolarisDataContext.Provider>
);
}
// 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 };
+3
View File
@@ -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"]
}
+2
View File
@@ -4,5 +4,7 @@ export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
exclude: ['e2e/**', 'node_modules/**'],
},
});
+43
View File
@@ -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<string, string>();
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,
});
}
}