test: add component tests for all 8 UI components

Add 92 new tests across 8 test files covering DriverStatusCard,
SnapshotsPage, PVCDetailSection, StorageClassesPage, VolumesPage,
MetricsPage, OverviewPage, and BenchmarkPage. Includes shared
test-helpers.tsx with fixtures and a lightweight CommonComponents
mock. Total tests: 67 → 159.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 17:08:09 +00:00
parent 54a33d70b0
commit cdff1d1a07
10 changed files with 1659 additions and 0 deletions
+240
View File
@@ -0,0 +1,240 @@
import { fireEvent, render, screen, waitFor, act } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
ApiProxy: {
request: vi.fn().mockResolvedValue({}),
},
ConfigStore: class {
get() { return {}; }
set() {}
update() {}
useConfig() { return () => ({}); }
},
}));
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
);
vi.mock('../api/TnsCsiDataContext');
vi.mock('../api/kbench', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/kbench')>();
return {
...actual,
createPvc: vi.fn().mockResolvedValue(undefined),
createJob: vi.fn().mockResolvedValue(undefined),
deleteJob: vi.fn().mockResolvedValue(undefined),
deletePvc: vi.fn().mockResolvedValue(undefined),
getJobPhase: vi.fn().mockResolvedValue({ phase: 'Active', job: {} }),
fetchKbenchLogs: vi.fn().mockResolvedValue(''),
listKbenchJobs: vi.fn().mockResolvedValue([]),
generateJobName: vi.fn().mockReturnValue('kbench-abc123'),
generatePvcName: vi.fn().mockReturnValue('kbench-abc123-pvc'),
};
});
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
import {
createPvc,
createJob,
deleteJob,
deletePvc,
getJobPhase,
fetchKbenchLogs,
listKbenchJobs,
parseKbenchLog,
} from '../api/kbench';
import { defaultContext, makeSampleStorageClass } from '../test-helpers';
import BenchmarkPage from './BenchmarkPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
}
describe('BenchmarkPage', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(listKbenchJobs).mockResolvedValue([]);
});
it('shows loader when loading', () => {
mockContext({ loading: true });
render(<BenchmarkPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading tns-csi data...');
});
it('renders benchmark guide section', () => {
mockContext();
render(<BenchmarkPage />);
expect(screen.getByText('Benchmark Guide')).toBeInTheDocument();
expect(screen.getByText(/Do not cancel mid-run/)).toBeInTheDocument();
});
it('renders Run New Benchmark form', () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
render(<BenchmarkPage />);
expect(screen.getByText('Run New Benchmark')).toBeInTheDocument();
expect(screen.getByLabelText('Select storage class for benchmark')).toBeInTheDocument();
});
it('populates SC dropdown with storage class names', () => {
const sc1 = makeSampleStorageClass({ metadata: { name: 'sc-a' } });
const sc2 = makeSampleStorageClass({ metadata: { name: 'sc-b' } });
mockContext({ storageClasses: [sc1, sc2] });
render(<BenchmarkPage />);
const select = screen.getByLabelText('Select storage class for benchmark') as HTMLSelectElement;
expect(select.options.length).toBe(2);
expect(select.options[0].value).toBe('sc-a');
expect(select.options[1].value).toBe('sc-b');
});
it('shows "No tns-csi storage classes found" when empty', () => {
mockContext({ storageClasses: [] });
render(<BenchmarkPage />);
const select = screen.getByLabelText('Select storage class for benchmark') as HTMLSelectElement;
expect(select.options[0].text).toContain('No tns-csi storage classes');
});
it('shows confirmation dialog when Run Benchmark is clicked', () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
render(<BenchmarkPage />);
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
expect(screen.getByText('Confirm Benchmark')).toBeInTheDocument();
expect(screen.getByText(/~33Gi PVC/)).toBeInTheDocument();
});
it('cancels confirmation dialog', () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
render(<BenchmarkPage />);
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
fireEvent.click(screen.getByLabelText('Cancel benchmark'));
expect(screen.queryByText('Confirm Benchmark')).not.toBeInTheDocument();
});
it('starts benchmark on confirmation and calls createPvc', async () => {
vi.useFakeTimers();
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
// PVC bind check
vi.mocked(ApiProxy.request).mockResolvedValue({ status: { phase: 'Bound' } });
render(<BenchmarkPage />);
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
await act(async () => {
fireEvent.click(screen.getByLabelText('Confirm and start benchmark'));
});
expect(vi.mocked(createPvc)).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
it('shows failed state when PVC creation fails', async () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
vi.mocked(createPvc).mockRejectedValueOnce(new Error('quota exceeded'));
render(<BenchmarkPage />);
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
await act(async () => {
fireEvent.click(screen.getByLabelText('Confirm and start benchmark'));
});
await waitFor(() => {
expect(screen.getByText(/quota exceeded/)).toBeInTheDocument();
});
expect(screen.getByText('Failed')).toBeInTheDocument();
});
it('renders past benchmarks section', async () => {
mockContext();
vi.mocked(listKbenchJobs).mockResolvedValueOnce([]);
render(<BenchmarkPage />);
await waitFor(() => {
expect(screen.getByText('Past Benchmarks')).toBeInTheDocument();
});
expect(screen.getByText('No past benchmark jobs found.')).toBeInTheDocument();
});
it('renders past benchmark jobs in table', async () => {
mockContext();
vi.mocked(listKbenchJobs).mockResolvedValueOnce([
{
jobName: 'kbench-old',
namespace: 'default',
storageClass: 'tns-nfs',
phase: 'Complete',
startedAt: '2025-01-01T00:00:00Z',
},
]);
render(<BenchmarkPage />);
await waitFor(() => {
expect(screen.getByText('kbench-old')).toBeInTheDocument();
});
expect(screen.getByText('Complete')).toBeInTheDocument();
});
it('disables Run Benchmark button when no storage classes', () => {
mockContext({ storageClasses: [] });
render(<BenchmarkPage />);
const btn = screen.getByLabelText('Start kbench storage benchmark');
expect(btn).toBeDisabled();
});
it('shows confirmation dialog with selected SC and namespace', () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
render(<BenchmarkPage />);
// Change namespace
const nsInput = screen.getByLabelText('Kubernetes namespace for benchmark job') as HTMLInputElement;
fireEvent.change(nsInput, { target: { value: 'bench-ns' } });
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
// Confirm dialog shows SC and namespace in <strong> tags
expect(screen.getByText('Confirm Benchmark')).toBeInTheDocument();
expect(screen.getByLabelText('Confirm and start benchmark')).toBeInTheDocument();
// Namespace is shown in the dialog
const dialogText = screen.getByText(/bench-ns/);
expect(dialogText).toBeInTheDocument();
});
it('can change test size and mode', () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
render(<BenchmarkPage />);
const sizeInput = screen.getByLabelText('FIO test size') as HTMLInputElement;
fireEvent.change(sizeInput, { target: { value: '10G' } });
expect(sizeInput.value).toBe('10G');
const modeSelect = screen.getByLabelText('Benchmark mode') as HTMLSelectElement;
fireEvent.change(modeSelect, { target: { value: 'quick' } });
expect(modeSelect.value).toBe('quick');
});
it('shows failed state when job creation fails', async () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
vi.mocked(createPvc).mockResolvedValueOnce(undefined);
// PVC binds immediately
vi.mocked(ApiProxy.request).mockResolvedValue({ status: { phase: 'Bound' } });
vi.mocked(createJob).mockRejectedValueOnce(new Error('job already exists'));
render(<BenchmarkPage />);
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
await act(async () => {
fireEvent.click(screen.getByLabelText('Confirm and start benchmark'));
});
await waitFor(() => {
expect(screen.getByText(/job already exists/)).toBeInTheDocument();
});
expect(screen.getByText('Failed')).toBeInTheDocument();
});
});
+183
View File
@@ -0,0 +1,183 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
);
import DriverStatusCard from './DriverStatusCard';
import { makeSamplePod, sampleCSIDriver, makeSampleMetrics } from '../test-helpers';
describe('DriverStatusCard', () => {
it('shows "Not detected" when no CSI driver is present', () => {
render(
<DriverStatusCard
csiDriver={null}
controllerPods={[]}
nodePods={[]}
/>
);
expect(screen.getByText('Not detected')).toBeInTheDocument();
});
it('shows "Degraded" when no pods are present', () => {
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[]}
nodePods={[]}
/>
);
expect(screen.getByText('Degraded')).toBeInTheDocument();
});
it('shows "Metrics unavailable" when no metrics provided', () => {
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[makeSamplePod()]}
nodePods={[makeSamplePod({ name: 'tns-csi-node-abc' })]}
/>
);
expect(screen.getByText('Metrics unavailable')).toBeInTheDocument();
});
it('shows "Healthy" and "Connected" when all pods ready and WS connected', () => {
const metrics = makeSampleMetrics({ websocketConnected: 1 });
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[makeSamplePod()]}
nodePods={[makeSamplePod({ name: 'tns-csi-node-abc' })]}
metrics={metrics}
/>
);
expect(screen.getByText('Healthy')).toBeInTheDocument();
expect(screen.getByText('Connected')).toBeInTheDocument();
expect(screen.getByText('tns.csi.io installed')).toBeInTheDocument();
});
it('shows "Disconnected" when WS is disconnected', () => {
const metrics = makeSampleMetrics({ websocketConnected: 0 });
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[makeSamplePod()]}
nodePods={[makeSamplePod({ name: 'tns-csi-node-abc' })]}
metrics={metrics}
/>
);
expect(screen.getByText('Disconnected')).toBeInTheDocument();
});
it('shows "Unknown" when websocketConnected is null', () => {
const metrics = makeSampleMetrics({ websocketConnected: null });
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[makeSamplePod()]}
nodePods={[makeSamplePod({ name: 'tns-csi-node-abc' })]}
metrics={metrics}
/>
);
expect(screen.getByText('Unknown')).toBeInTheDocument();
});
it('renders CSI capabilities section when driver is present', () => {
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[]}
nodePods={[]}
/>
);
expect(screen.getByText('CSI Driver Capabilities')).toBeInTheDocument();
expect(screen.getByText('false')).toBeInTheDocument(); // attachRequired
expect(screen.getByText('true')).toBeInTheDocument(); // podInfoOnMount
expect(screen.getByText('Persistent')).toBeInTheDocument();
});
it('does not render CSI capabilities when no driver', () => {
render(
<DriverStatusCard
csiDriver={null}
controllerPods={[]}
nodePods={[]}
/>
);
expect(screen.queryByText('CSI Driver Capabilities')).not.toBeInTheDocument();
});
it('renders pod rows with image, restarts, and ready status', () => {
const pod = makeSamplePod({
metadata: { name: 'ctrl-pod-1', creationTimestamp: '2025-01-01T00:00:00Z' },
status: {
phase: 'Running',
conditions: [{ type: 'Ready', status: 'True' }],
containerStatuses: [
{ name: 'tns-csi', ready: true, restartCount: 2, image: 'fenio/tns-csi:v0.6.0' },
],
},
});
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[pod]}
nodePods={[]}
/>
);
expect(screen.getByText('ctrl-pod-1')).toBeInTheDocument();
expect(screen.getByText('fenio/tns-csi:v0.6.0')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument(); // restarts
expect(screen.getByText('Running')).toBeInTheDocument();
});
it('shows "No controller pod found" when controllerPods is empty', () => {
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[]}
nodePods={[makeSamplePod({ name: 'node-1' })]}
/>
);
expect(screen.getByText('No controller pod found')).toBeInTheDocument();
});
it('shows "No node pods found" when nodePods is empty', () => {
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[makeSamplePod()]}
nodePods={[]}
/>
);
expect(screen.getByText('No node pods found')).toBeInTheDocument();
});
it('shows WS reconnects when available in metrics', () => {
const metrics = makeSampleMetrics({ websocketReconnectsTotal: 7 });
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[makeSamplePod()]}
nodePods={[makeSamplePod({ name: 'node-1' })]}
metrics={metrics}
/>
);
expect(screen.getByText('WS Reconnects')).toBeInTheDocument();
expect(screen.getByText('7')).toBeInTheDocument();
});
it('shows node pods count in section title', () => {
const node1 = makeSamplePod({ name: 'tns-csi-node-1' });
const node2 = makeSamplePod({ name: 'tns-csi-node-2' });
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[makeSamplePod()]}
nodePods={[node1, node2]}
/>
);
expect(screen.getByText('Node Pods (2)')).toBeInTheDocument();
});
});
+161
View File
@@ -0,0 +1,161 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
);
vi.mock('../api/TnsCsiDataContext');
vi.mock('../api/metrics', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/metrics')>();
return {
...actual,
fetchControllerMetrics: vi.fn(),
};
});
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { fetchControllerMetrics } from '../api/metrics';
import { defaultContext, makeSamplePod, makeSampleMetrics } from '../test-helpers';
import MetricsPage from './MetricsPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
}
describe('MetricsPage', () => {
beforeEach(() => {
vi.mocked(fetchControllerMetrics).mockReset();
});
it('shows loader when context is loading', () => {
mockContext({ loading: true });
render(<MetricsPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading tns-csi data...');
});
it('shows "Driver Not Detected" when driver not installed', () => {
mockContext({ driverInstalled: false });
render(<MetricsPage />);
expect(screen.getByText('Driver Not Detected')).toBeInTheDocument();
expect(screen.getByText(/TNS-CSI driver not found/)).toBeInTheDocument();
});
it('shows "No controller pod found" when driver installed but no pods', () => {
mockContext({ driverInstalled: true, controllerPods: [] });
render(<MetricsPage />);
expect(screen.getByText('Metrics Unavailable')).toBeInTheDocument();
expect(screen.getByText(/No controller pod found/)).toBeInTheDocument();
});
it('shows metrics error when fetch fails', async () => {
const pod = makeSamplePod();
mockContext({ driverInstalled: true, controllerPods: [pod] });
vi.mocked(fetchControllerMetrics).mockRejectedValueOnce(new Error('connection refused'));
render(<MetricsPage />);
await waitFor(() => {
expect(screen.getByText('connection refused')).toBeInTheDocument();
});
});
it('renders three metric cards when fetch succeeds', async () => {
const pod = makeSamplePod();
const metrics = makeSampleMetrics();
mockContext({ driverInstalled: true, controllerPods: [pod] });
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
render(<MetricsPage />);
await waitFor(() => {
expect(screen.getByText('WebSocket Health')).toBeInTheDocument();
});
expect(screen.getByText('Volume Operations')).toBeInTheDocument();
expect(screen.getByText('CSI Operations')).toBeInTheDocument();
});
it('displays correct WebSocket metric data', async () => {
const pod = makeSamplePod();
const metrics = makeSampleMetrics({
websocketConnected: 1,
websocketReconnectsTotal: 42,
websocketMessagesTotal: [{ labels: {}, value: 250 }],
// Zero out other metrics to avoid number collisions
volumeOperationsTotal: [],
volumeCapacityBytes: [],
csiOperationsTotal: [],
});
mockContext({ driverInstalled: true, controllerPods: [pod] });
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
render(<MetricsPage />);
await waitFor(() => {
expect(screen.getByText('Connected')).toBeInTheDocument();
});
expect(screen.getByText('42')).toBeInTheDocument(); // reconnects
expect(screen.getByText('250')).toBeInTheDocument(); // messages
});
it('displays CSI operations broken down by method', async () => {
const pod = makeSamplePod();
const metrics = makeSampleMetrics({
csiOperationsTotal: [
{ labels: { method: 'CreateVolume' }, value: 77 },
{ labels: { method: 'DeleteVolume' }, value: 13 },
],
// Zero out other metrics to avoid number collisions
volumeOperationsTotal: [],
volumeCapacityBytes: [],
websocketMessagesTotal: [],
});
mockContext({ driverInstalled: true, controllerPods: [pod] });
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
render(<MetricsPage />);
await waitFor(() => {
expect(screen.getByText('CreateVolume')).toBeInTheDocument();
});
expect(screen.getByText('77')).toBeInTheDocument();
expect(screen.getByText('DeleteVolume')).toBeInTheDocument();
expect(screen.getByText('13')).toBeInTheDocument();
});
it('refresh button triggers refetch', async () => {
const pod = makeSamplePod();
const metrics = makeSampleMetrics();
mockContext({ driverInstalled: true, controllerPods: [pod] });
vi.mocked(fetchControllerMetrics).mockResolvedValue(metrics);
render(<MetricsPage />);
await waitFor(() => {
expect(screen.getByText('WebSocket Health')).toBeInTheDocument();
});
const initialCallCount = vi.mocked(fetchControllerMetrics).mock.calls.length;
fireEvent.click(screen.getByLabelText('Refresh metrics'));
await waitFor(() => {
expect(vi.mocked(fetchControllerMetrics).mock.calls.length).toBeGreaterThan(initialCallCount);
});
});
it('shows "Updated" timestamp after successful fetch', async () => {
const pod = makeSamplePod();
const metrics = makeSampleMetrics();
mockContext({ driverInstalled: true, controllerPods: [pod] });
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
render(<MetricsPage />);
await waitFor(() => {
expect(screen.getByText(/Updated:/)).toBeInTheDocument();
});
});
it('shows volume operations grouped by protocol', async () => {
const pod = makeSamplePod();
const metrics = makeSampleMetrics({
volumeOperationsTotal: [
{ labels: { protocol: 'nfs' }, value: 15 },
{ labels: { protocol: 'iscsi' }, value: 8 },
],
});
mockContext({ driverInstalled: true, controllerPods: [pod] });
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
render(<MetricsPage />);
await waitFor(() => {
expect(screen.getByText('Operations (nfs)')).toBeInTheDocument();
});
expect(screen.getByText('Operations (iscsi)')).toBeInTheDocument();
});
});
+276
View File
@@ -0,0 +1,276 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
);
vi.mock('../api/TnsCsiDataContext');
vi.mock('../api/metrics', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/metrics')>();
return {
...actual,
fetchControllerMetrics: vi.fn(),
};
});
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { fetchControllerMetrics } from '../api/metrics';
import {
defaultContext,
makeSamplePod,
makeSamplePV,
makeSamplePVC,
makeSampleStorageClass,
makeSampleMetrics,
sampleCSIDriver,
} from '../test-helpers';
import OverviewPage from './OverviewPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
}
describe('OverviewPage', () => {
beforeEach(() => {
vi.mocked(fetchControllerMetrics).mockReset();
});
it('shows loader when loading', () => {
mockContext({ loading: true });
render(<OverviewPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading TNS-CSI data...');
});
it('shows "Driver Not Detected" when driver not installed', () => {
mockContext({ driverInstalled: false });
render(<OverviewPage />);
expect(screen.getByText('Driver Not Detected')).toBeInTheDocument();
expect(screen.getByText(/CSIDriver tns.csi.io not found/)).toBeInTheDocument();
});
it('shows error section when error is present', () => {
mockContext({ error: 'cluster unavailable' });
render(<OverviewPage />);
expect(screen.getByText('cluster unavailable')).toBeInTheDocument();
});
it('always shows the development status notice', () => {
mockContext({ driverInstalled: true });
render(<OverviewPage />);
expect(screen.getByText(/active early development/)).toBeInTheDocument();
});
it('renders storage summary with SC/PV counts', () => {
const sc = makeSampleStorageClass();
const pv = makeSamplePV();
const pvc = makeSamplePVC();
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [sc],
persistentVolumes: [pv],
persistentVolumeClaims: [pvc],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
});
render(<OverviewPage />);
expect(screen.getByText('Storage Summary')).toBeInTheDocument();
expect(screen.getByText('Storage Classes')).toBeInTheDocument();
expect(screen.getByText('Persistent Volumes')).toBeInTheDocument();
});
it('renders capacity aggregation from PVs', () => {
const pv1 = makeSamplePV({
metadata: { name: 'pv-1' },
spec: { ...makeSamplePV().spec, capacity: { storage: '100Gi' } },
});
const pv2 = makeSamplePV({
metadata: { name: 'pv-2' },
spec: { ...makeSamplePV().spec, capacity: { storage: '50Gi' } },
});
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [makeSampleStorageClass()],
persistentVolumes: [pv1, pv2],
persistentVolumeClaims: [],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
});
render(<OverviewPage />);
// 150 GiB total
expect(screen.getByText('150.0 GiB')).toBeInTheDocument();
});
it('renders protocol distribution bar', () => {
const sc1 = makeSampleStorageClass({ parameters: { protocol: 'nfs' } });
const sc2 = makeSampleStorageClass({
metadata: { name: 'tns-nvmeof' },
parameters: { protocol: 'nvmeof' },
});
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [sc1, sc2],
persistentVolumes: [],
persistentVolumeClaims: [],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
});
render(<OverviewPage />);
expect(screen.getByText('Protocol Distribution')).toBeInTheDocument();
expect(screen.getByTestId('percentage-bar')).toBeInTheDocument();
});
it('renders pool capacity table when poolStats are present', () => {
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
poolStats: [
{ name: 'tank', status: 'ONLINE', size: 1e12, allocated: 5e11, free: 5e11 },
],
});
render(<OverviewPage />);
expect(screen.getByText('Pool Capacity')).toBeInTheDocument();
expect(screen.getByText('tank')).toBeInTheDocument();
expect(screen.getByText('ONLINE')).toBeInTheDocument();
});
it('shows pool stats error hint', () => {
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
poolStatsError: 'API key invalid',
});
render(<OverviewPage />);
expect(screen.getByText('Pool Capacity Unavailable')).toBeInTheDocument();
expect(screen.getByText('API key invalid')).toBeInTheDocument();
expect(screen.getByText(/TrueNAS API key/)).toBeInTheDocument();
});
it('shows Prometheus fallback capacity by pool when no poolStats and metrics available', async () => {
const pod = makeSamplePod();
const pv = makeSamplePV();
const metrics = makeSampleMetrics({
volumeCapacityBytes: [
{ labels: { volume_id: 'tank/vol-001' }, value: 107374182400 },
],
});
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [makeSampleStorageClass()],
persistentVolumes: [pv],
persistentVolumeClaims: [],
controllerPods: [pod],
nodePods: [makeSamplePod({ name: 'node-1' })],
poolStats: [],
poolStatsError: null,
});
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
render(<OverviewPage />);
await waitFor(() => {
expect(screen.getByText('Provisioned Capacity by Pool')).toBeInTheDocument();
});
});
it('renders non-bound PVCs table', () => {
const pendingPvc = makeSamplePVC({
metadata: { name: 'pending-pvc', namespace: 'test', creationTimestamp: '2025-01-01T00:00:00Z' },
status: { phase: 'Pending' },
});
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [pendingPvc],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
});
render(<OverviewPage />);
expect(screen.getByText('Attention: Non-Bound PVCs')).toBeInTheDocument();
expect(screen.getByText('pending-pvc')).toBeInTheDocument();
expect(screen.getByText('Pending')).toBeInTheDocument();
});
it('does not show non-bound PVCs section when all PVCs are bound', () => {
const pvc = makeSamplePVC({ status: { phase: 'Bound' } });
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [pvc],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
});
render(<OverviewPage />);
expect(screen.queryByText('Attention: Non-Bound PVCs')).not.toBeInTheDocument();
});
it('refresh button calls context.refresh()', () => {
const refreshFn = vi.fn();
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [],
controllerPods: [],
nodePods: [],
refresh: refreshFn,
});
render(<OverviewPage />);
fireEvent.click(screen.getByLabelText('Refresh tns-csi data'));
expect(refreshFn).toHaveBeenCalledTimes(1);
});
it('shows metrics unavailable when fetchControllerMetrics fails', async () => {
const pod = makeSamplePod();
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [],
controllerPods: [pod],
nodePods: [makeSamplePod({ name: 'node-1' })],
});
vi.mocked(fetchControllerMetrics).mockRejectedValueOnce(new Error('timeout'));
render(<OverviewPage />);
await waitFor(() => {
expect(screen.getByText('Metrics Unavailable')).toBeInTheDocument();
});
expect(screen.getByText('timeout')).toBeInTheDocument();
});
it('shows PVC status breakdown with Pending and Lost counts', () => {
const boundPvc = makeSamplePVC({ metadata: { name: 'pvc-1', namespace: 'ns' }, status: { phase: 'Bound' } });
const pendingPvc = makeSamplePVC({ metadata: { name: 'pvc-2', namespace: 'ns' }, status: { phase: 'Pending' } });
const lostPvc = makeSamplePVC({ metadata: { name: 'pvc-3', namespace: 'ns' }, status: { phase: 'Lost' } });
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [boundPvc, pendingPvc, lostPvc],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
});
render(<OverviewPage />);
expect(screen.getByText('PVCs (Pending)')).toBeInTheDocument();
expect(screen.getByText('PVCs (Lost)')).toBeInTheDocument();
});
});
+94
View File
@@ -0,0 +1,94 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
);
vi.mock('../api/TnsCsiDataContext');
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { defaultContext, makeSamplePV, makeSamplePVC } from '../test-helpers';
import PVCDetailSection from './PVCDetailSection';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
}
describe('PVCDetailSection', () => {
it('returns null when loading', () => {
mockContext({ loading: true });
const { container } = render(
<PVCDetailSection resource={{ metadata: { name: 'my-pvc', namespace: 'default' } }} />
);
expect(container.innerHTML).toBe('');
});
it('returns null when PVC is not in filtered list', () => {
mockContext({ persistentVolumeClaims: [] });
const { container } = render(
<PVCDetailSection resource={{ metadata: { name: 'other-pvc', namespace: 'default' } }} />
);
expect(container.innerHTML).toBe('');
});
it('returns null when PVC has no bound PV', () => {
const pvc = makeSamplePVC({ metadata: { name: 'orphan-pvc', namespace: 'default' } });
mockContext({
persistentVolumeClaims: [pvc],
persistentVolumes: [], // no PVs to match
});
const { container } = render(
<PVCDetailSection resource={{ metadata: { name: 'orphan-pvc', namespace: 'default' } }} />
);
expect(container.innerHTML).toBe('');
});
it('renders storage details when PVC and PV are found', () => {
const pvc = makeSamplePVC();
const pv = makeSamplePV();
mockContext({
persistentVolumeClaims: [pvc],
persistentVolumes: [pv],
});
render(
<PVCDetailSection resource={{ metadata: { name: 'my-pvc', namespace: 'default' } }} />
);
expect(screen.getByText('TNS-CSI Storage Details')).toBeInTheDocument();
expect(screen.getByText('tns.csi.io')).toBeInTheDocument();
expect(screen.getByText('NFS')).toBeInTheDocument();
expect(screen.getByText('10.0.0.1')).toBeInTheDocument();
expect(screen.getByText('tns-nfs')).toBeInTheDocument();
expect(screen.getByText('tank/vol-001')).toBeInTheDocument();
});
it('renders custom volume attributes (excluding protocol and server)', () => {
const pv = makeSamplePV({
spec: {
...makeSamplePV().spec,
csi: {
driver: 'tns.csi.io',
volumeHandle: 'tank/vol-001',
volumeAttributes: {
protocol: 'nfs',
server: '10.0.0.1',
pool: 'tank',
customAttr: 'customValue',
},
},
},
});
const pvc = makeSamplePVC();
mockContext({
persistentVolumeClaims: [pvc],
persistentVolumes: [pv],
});
render(
<PVCDetailSection resource={{ metadata: { name: 'my-pvc', namespace: 'default' } }} />
);
expect(screen.getByText('pool')).toBeInTheDocument();
expect(screen.getByText('tank')).toBeInTheDocument();
expect(screen.getByText('customAttr')).toBeInTheDocument();
expect(screen.getByText('customValue')).toBeInTheDocument();
});
});
+106
View File
@@ -0,0 +1,106 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
);
vi.mock('../api/TnsCsiDataContext');
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { defaultContext, makeSampleSnapshot, makeSampleSnapshotClass } from '../test-helpers';
import SnapshotsPage from './SnapshotsPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
}
describe('SnapshotsPage', () => {
it('shows loader when loading', () => {
mockContext({ loading: true });
render(<SnapshotsPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading snapshots...');
});
it('shows error state', () => {
mockContext({ error: 'something broke' });
render(<SnapshotsPage />);
expect(screen.getByText('something broke')).toBeInTheDocument();
expect(screen.getByText('Error')).toBeInTheDocument();
});
it('shows notice when snapshot CRD is not available', () => {
mockContext({ snapshotCrdAvailable: false });
render(<SnapshotsPage />);
expect(screen.getByText('Volume Snapshot CRDs Not Installed')).toBeInTheDocument();
expect(
screen.getByText(/VolumeSnapshot CRDs.*not found/)
).toBeInTheDocument();
});
it('shows empty message when snapshots list is empty', () => {
mockContext({ snapshotCrdAvailable: true, volumeSnapshots: [] });
render(<SnapshotsPage />);
expect(screen.getByText('No tns-csi VolumeSnapshots found.')).toBeInTheDocument();
});
it('renders snapshot classes when available', () => {
const vsc = makeSampleSnapshotClass();
mockContext({
snapshotCrdAvailable: true,
volumeSnapshotClasses: [vsc],
volumeSnapshots: [],
});
render(<SnapshotsPage />);
expect(screen.getByText('Snapshot Classes (1)')).toBeInTheDocument();
expect(screen.getByText('tns-snap-class')).toBeInTheDocument();
expect(screen.getByText('tns.csi.io')).toBeInTheDocument();
});
it('renders populated snapshots with readyToUse=true', () => {
const snap = makeSampleSnapshot();
mockContext({
snapshotCrdAvailable: true,
volumeSnapshots: [snap],
});
render(<SnapshotsPage />);
expect(screen.getByText('snap-001')).toBeInTheDocument();
expect(screen.getByText('my-pvc')).toBeInTheDocument();
expect(screen.getByText('Yes')).toBeInTheDocument();
expect(screen.getByText('100Gi')).toBeInTheDocument();
});
it('renders snapshot with readyToUse=false', () => {
const snap = makeSampleSnapshot({
status: { readyToUse: false, restoreSize: '50Gi' },
});
mockContext({
snapshotCrdAvailable: true,
volumeSnapshots: [snap],
});
render(<SnapshotsPage />);
expect(screen.getByText('No')).toBeInTheDocument();
});
it('renders snapshot with readyToUse=undefined as Unknown', () => {
const snap = makeSampleSnapshot({
status: { readyToUse: undefined },
});
mockContext({
snapshotCrdAvailable: true,
volumeSnapshots: [snap],
});
render(<SnapshotsPage />);
expect(screen.getByText('Unknown')).toBeInTheDocument();
});
it('does not render snapshot classes section when empty', () => {
mockContext({
snapshotCrdAvailable: true,
volumeSnapshotClasses: [],
volumeSnapshots: [makeSampleSnapshot()],
});
render(<SnapshotsPage />);
expect(screen.queryByText(/Snapshot Classes/)).not.toBeInTheDocument();
});
});
+158
View File
@@ -0,0 +1,158 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
);
let mockHash = '';
const mockPush = vi.fn();
vi.mock('react-router-dom', () => ({
useLocation: () => ({ pathname: '/tns-csi/storage-classes', hash: mockHash }),
useHistory: () => ({ push: mockPush }),
}));
vi.mock('../api/TnsCsiDataContext');
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { defaultContext, makeSampleStorageClass, makeSamplePV } from '../test-helpers';
import StorageClassesPage from './StorageClassesPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
}
describe('StorageClassesPage', () => {
beforeEach(() => {
mockPush.mockClear();
mockHash = '';
});
it('shows loader when loading', () => {
mockContext({ loading: true });
render(<StorageClassesPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading storage classes...');
});
it('shows error state', () => {
mockContext({ error: 'fetch failed' });
render(<StorageClassesPage />);
expect(screen.getByText('fetch failed')).toBeInTheDocument();
expect(screen.getByText('Error')).toBeInTheDocument();
});
it('shows empty message when no storage classes', () => {
mockContext({ storageClasses: [] });
render(<StorageClassesPage />);
expect(screen.getByText('No tns-csi StorageClasses found.')).toBeInTheDocument();
});
it('renders table with all columns populated', () => {
const sc = makeSampleStorageClass();
const pv = makeSamplePV();
mockContext({
storageClasses: [sc],
persistentVolumes: [pv],
});
render(<StorageClassesPage />);
expect(screen.getByText('tns-nfs')).toBeInTheDocument();
expect(screen.getByText('NFS')).toBeInTheDocument();
expect(screen.getByText('tank')).toBeInTheDocument();
expect(screen.getByText('10.0.0.1')).toBeInTheDocument();
expect(screen.getByText('Delete')).toBeInTheDocument();
expect(screen.getByText('Yes')).toBeInTheDocument(); // expansion
expect(screen.getByText('1')).toBeInTheDocument(); // PV count
});
it('opens detail panel when clicking SC name', () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
render(<StorageClassesPage />);
fireEvent.click(screen.getByText('tns-nfs'));
expect(mockPush).toHaveBeenCalledWith('/tns-csi/storage-classes#tns-nfs');
});
it('renders detail panel when hash is set', () => {
mockHash = '#tns-nfs';
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc], persistentVolumes: [] });
render(<StorageClassesPage />);
expect(screen.getByText('StorageClass Details')).toBeInTheDocument();
});
it('closes panel via close button', () => {
mockHash = '#tns-nfs';
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc], persistentVolumes: [] });
render(<StorageClassesPage />);
fireEvent.click(screen.getByLabelText('Close panel'));
expect(mockPush).toHaveBeenCalledWith('/tns-csi/storage-classes');
});
it('closes panel via backdrop click', () => {
mockHash = '#tns-nfs';
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc], persistentVolumes: [] });
render(<StorageClassesPage />);
fireEvent.click(screen.getByLabelText('Close panel backdrop'));
expect(mockPush).toHaveBeenCalledWith('/tns-csi/storage-classes');
});
it('closes panel on Escape key', () => {
mockHash = '#tns-nfs';
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc], persistentVolumes: [] });
render(<StorageClassesPage />);
fireEvent.keyDown(window, { key: 'Escape' });
expect(mockPush).toHaveBeenCalledWith('/tns-csi/storage-classes');
});
it('shows NFS protocol notes in detail panel', () => {
mockHash = '#tns-nfs';
const sc = makeSampleStorageClass({ parameters: { protocol: 'nfs', pool: 'tank', server: '10.0.0.1' } });
mockContext({ storageClasses: [sc], persistentVolumes: [] });
render(<StorageClassesPage />);
expect(screen.getByText('Protocol Notes')).toBeInTheDocument();
expect(screen.getByText(/nfs-common/)).toBeInTheDocument();
});
it('shows NVMe-oF protocol notes', () => {
mockHash = '#tns-nvmeof';
const sc = makeSampleStorageClass({
metadata: { name: 'tns-nvmeof' },
parameters: { protocol: 'nvmeof', pool: 'tank', server: '10.0.0.1' },
});
mockContext({ storageClasses: [sc], persistentVolumes: [] });
render(<StorageClassesPage />);
expect(screen.getByText(/nvme-cli/)).toBeInTheDocument();
});
it('shows iSCSI protocol notes', () => {
mockHash = '#tns-iscsi';
const sc = makeSampleStorageClass({
metadata: { name: 'tns-iscsi' },
parameters: { protocol: 'iscsi', pool: 'tank', server: '10.0.0.1' },
});
mockContext({ storageClasses: [sc], persistentVolumes: [] });
render(<StorageClassesPage />);
expect(screen.getByText(/open-iscsi/)).toBeInTheDocument();
});
it('shows PV count for each storage class', () => {
const sc1 = makeSampleStorageClass({ metadata: { name: 'sc-a' } });
const sc2 = makeSampleStorageClass({ metadata: { name: 'sc-b' } });
const pv1 = makeSamplePV({ spec: { ...makeSamplePV().spec, storageClassName: 'sc-a' } });
const pv2 = makeSamplePV({
metadata: { name: 'pv-2' },
spec: { ...makeSamplePV().spec, storageClassName: 'sc-a' },
});
mockContext({
storageClasses: [sc1, sc2],
persistentVolumes: [pv1, pv2],
});
render(<StorageClassesPage />);
const cells = screen.getAllByRole('cell');
const pvCells = cells.filter(c => c.textContent === '2');
expect(pvCells.length).toBeGreaterThanOrEqual(1);
});
});
+144
View File
@@ -0,0 +1,144 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
);
let mockHash = '';
const mockPush = vi.fn();
vi.mock('react-router-dom', () => ({
useLocation: () => ({ pathname: '/tns-csi/volumes', hash: mockHash }),
useHistory: () => ({ push: mockPush }),
}));
vi.mock('../api/TnsCsiDataContext');
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { defaultContext, makeSamplePV } from '../test-helpers';
import VolumesPage from './VolumesPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
}
describe('VolumesPage', () => {
beforeEach(() => {
mockPush.mockClear();
mockHash = '';
});
it('shows loader when loading', () => {
mockContext({ loading: true });
render(<VolumesPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading volumes...');
});
it('shows error state', () => {
mockContext({ error: 'api error' });
render(<VolumesPage />);
expect(screen.getByText('api error')).toBeInTheDocument();
});
it('shows empty message when no PVs', () => {
mockContext({ persistentVolumes: [] });
render(<VolumesPage />);
expect(screen.getByText('No tns-csi PersistentVolumes found.')).toBeInTheDocument();
});
it('renders PV table with claim ref', () => {
const pv = makeSamplePV();
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
expect(screen.getByText('pv-test-001')).toBeInTheDocument();
expect(screen.getByText('default/my-pvc')).toBeInTheDocument();
expect(screen.getByText('NFS')).toBeInTheDocument();
expect(screen.getByText('100Gi')).toBeInTheDocument();
expect(screen.getByText('RWO')).toBeInTheDocument();
expect(screen.getByText('Bound')).toBeInTheDocument();
});
it('renders "—" for PV without claimRef', () => {
const pv = makeSamplePV({
spec: {
...makeSamplePV().spec,
claimRef: undefined,
},
});
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
const cells = screen.getAllByRole('cell');
const dashCells = cells.filter(c => c.textContent === '—');
expect(dashCells.length).toBeGreaterThanOrEqual(1);
});
it('opens detail panel when clicking PV name', () => {
const pv = makeSamplePV();
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
fireEvent.click(screen.getByText('pv-test-001'));
expect(mockPush).toHaveBeenCalledWith('/tns-csi/volumes#pv-test-001');
});
it('renders detail panel with CSI attributes', () => {
mockHash = '#pv-test-001';
const pv = makeSamplePV();
mockContext({ persistentVolumes: [pv], persistentVolumeClaims: [] });
render(<VolumesPage />);
expect(screen.getByText('Volume Details')).toBeInTheDocument();
expect(screen.getByText('CSI Attributes')).toBeInTheDocument();
expect(screen.getByText('tank/vol-001')).toBeInTheDocument();
});
it('shows Bound PVC section in detail panel when claimRef exists', () => {
mockHash = '#pv-test-001';
const pv = makeSamplePV();
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
expect(screen.getByText('Bound PVC')).toBeInTheDocument();
expect(screen.getByText('my-pvc')).toBeInTheDocument();
});
it('shows Adoption section when annotation is present', () => {
mockHash = '#pv-adoptable';
const pv = makeSamplePV({
metadata: {
name: 'pv-adoptable',
annotations: { 'tns-csi.io/adoptable': 'true' },
},
});
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
expect(screen.getByText('Adoption')).toBeInTheDocument();
expect(screen.getByText(/adopted cross-cluster/)).toBeInTheDocument();
});
it('closes panel on Escape key', () => {
mockHash = '#pv-test-001';
const pv = makeSamplePV();
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
fireEvent.keyDown(window, { key: 'Escape' });
expect(mockPush).toHaveBeenCalledWith('/tns-csi/volumes');
});
it('closes panel via backdrop click', () => {
mockHash = '#pv-test-001';
const pv = makeSamplePV();
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
fireEvent.click(screen.getByLabelText('Close panel backdrop'));
expect(mockPush).toHaveBeenCalledWith('/tns-csi/volumes');
});
it('renders maximize/minimize button in panel', () => {
mockHash = '#pv-test-001';
const pv = makeSamplePV();
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
const maxBtn = screen.getByLabelText('Maximize');
expect(maxBtn).toBeInTheDocument();
fireEvent.click(maxBtn);
expect(screen.getByLabelText('Minimize')).toBeInTheDocument();
});
});
@@ -0,0 +1,87 @@
/**
* Lightweight mock implementations of @kinvolk/headlamp-plugin/lib/CommonComponents.
* Used via vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => commonComponentsMock).
*
* Uses React.createElement instead of JSX since this file is .ts (not .tsx).
*/
import React from 'react';
type RC = React.ReactNode;
export const Loader = ({ title }: { title?: string }) =>
React.createElement('div', { 'data-testid': 'loader' }, title);
export const SectionBox = ({ title, children }: { title?: string; children?: RC }) =>
React.createElement('div', { 'data-testid': 'section-box', 'data-title': title },
title ? React.createElement('h3', null, title) : null,
children
);
export const SectionHeader = ({ title }: { title: string }) =>
React.createElement('h1', { 'data-testid': 'section-header' }, title);
export const SimpleTable = ({
columns,
data,
emptyMessage,
}: {
columns: Array<{ label: string; getter: (item: unknown) => RC }>;
data: unknown[];
emptyMessage?: string;
}) => {
if (data.length === 0 && emptyMessage) {
return React.createElement('div', { 'data-testid': 'empty-table' }, emptyMessage);
}
return React.createElement('table', { 'data-testid': 'simple-table' },
React.createElement('thead', null,
React.createElement('tr', null,
columns.map(col => React.createElement('th', { key: col.label }, col.label))
)
),
React.createElement('tbody', null,
data.map((item, i) =>
React.createElement('tr', { key: i },
columns.map(col => React.createElement('td', { key: col.label }, col.getter(item)))
)
)
)
);
};
export const NameValueTable = ({
rows,
}: {
rows: Array<{ name: string; value: RC }>;
}) =>
React.createElement('table', { 'data-testid': 'name-value-table' },
React.createElement('tbody', null,
rows.map(row =>
React.createElement('tr', { key: row.name },
React.createElement('td', null, row.name),
React.createElement('td', null, row.value)
)
)
)
);
export const StatusLabel = ({
status,
children,
}: {
status: string;
children?: RC;
}) =>
React.createElement('span', { 'data-testid': 'status-label', 'data-status': status }, children);
export const PercentageBar = ({
data,
}: {
data: Array<{ name: string; value: number }>;
total: number;
}) =>
React.createElement('div', { 'data-testid': 'percentage-bar' },
data.map(d =>
React.createElement('span', { key: d.name }, `${d.name}: ${d.value}`)
)
);
+210
View File
@@ -0,0 +1,210 @@
/**
* Shared test helpers: mock factories, fixtures, and context setup
* for component tests.
*/
import { vi } from 'vitest';
import type { TnsCsiContextValue } from './api/TnsCsiDataContext';
import type {
CSIDriver,
TnsCsiPersistentVolume,
TnsCsiPersistentVolumeClaim,
TnsCsiPod,
TnsCsiStorageClass,
VolumeSnapshot,
VolumeSnapshotClass,
} from './api/k8s';
import type { TnsCsiMetrics } from './api/metrics';
// ---------------------------------------------------------------------------
// Default context value (everything empty / zeroed)
// ---------------------------------------------------------------------------
export function defaultContext(overrides?: Partial<TnsCsiContextValue>): TnsCsiContextValue {
return {
csiDriver: null,
driverInstalled: false,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [],
controllerPods: [],
nodePods: [],
volumeSnapshots: [],
volumeSnapshotClasses: [],
snapshotCrdAvailable: false,
poolStats: [],
poolStatsError: null,
loading: false,
error: null,
refresh: vi.fn(),
...overrides,
};
}
// ---------------------------------------------------------------------------
// Sample fixtures
// ---------------------------------------------------------------------------
export const sampleCSIDriver: CSIDriver = {
metadata: { name: 'tns.csi.io' },
spec: {
attachRequired: false,
podInfoOnMount: true,
volumeLifecycleModes: ['Persistent'],
},
};
export function makeSampleStorageClass(overrides?: Partial<TnsCsiStorageClass>): TnsCsiStorageClass {
return {
metadata: { name: 'tns-nfs', creationTimestamp: '2025-01-01T00:00:00Z' },
provisioner: 'tns.csi.io',
reclaimPolicy: 'Delete',
volumeBindingMode: 'Immediate',
allowVolumeExpansion: true,
parameters: {
protocol: 'nfs',
pool: 'tank',
server: '10.0.0.1',
},
...overrides,
};
}
export const sampleStorageClass = makeSampleStorageClass();
export function makeSamplePV(overrides?: Partial<TnsCsiPersistentVolume>): TnsCsiPersistentVolume {
return {
metadata: {
name: 'pv-test-001',
creationTimestamp: '2025-01-01T00:00:00Z',
},
spec: {
csi: {
driver: 'tns.csi.io',
volumeHandle: 'tank/vol-001',
volumeAttributes: {
protocol: 'nfs',
server: '10.0.0.1',
pool: 'tank',
},
},
capacity: { storage: '100Gi' },
accessModes: ['ReadWriteOnce'],
persistentVolumeReclaimPolicy: 'Delete',
storageClassName: 'tns-nfs',
claimRef: { name: 'my-pvc', namespace: 'default' },
},
status: { phase: 'Bound' },
...overrides,
};
}
export const samplePV = makeSamplePV();
export function makeSamplePVC(overrides?: Partial<TnsCsiPersistentVolumeClaim>): TnsCsiPersistentVolumeClaim {
return {
metadata: {
name: 'my-pvc',
namespace: 'default',
creationTimestamp: '2025-01-01T00:00:00Z',
},
spec: {
storageClassName: 'tns-nfs',
accessModes: ['ReadWriteOnce'],
resources: { requests: { storage: '100Gi' } },
volumeName: 'pv-test-001',
},
status: {
phase: 'Bound',
capacity: { storage: '100Gi' },
},
...overrides,
};
}
export const samplePVC = makeSamplePVC();
export function makeSamplePod(overrides?: Partial<TnsCsiPod> & { name?: string }): TnsCsiPod {
const name = overrides?.name ?? overrides?.metadata?.name ?? 'tns-csi-controller-abc';
return {
metadata: {
name,
creationTimestamp: '2025-01-01T00:00:00Z',
...overrides?.metadata,
},
spec: {
nodeName: 'node-1',
...overrides?.spec,
},
status: {
phase: 'Running',
conditions: [{ type: 'Ready', status: 'True' }],
containerStatuses: [
{
name: 'tns-csi',
ready: true,
restartCount: 0,
image: 'fenio/tns-csi:v0.5.0',
},
],
...overrides?.status,
},
};
}
export const samplePod = makeSamplePod();
export function makeSampleSnapshot(overrides?: Partial<VolumeSnapshot>): VolumeSnapshot {
return {
metadata: {
name: 'snap-001',
namespace: 'default',
creationTimestamp: '2025-01-01T00:00:00Z',
},
spec: {
source: { persistentVolumeClaimName: 'my-pvc' },
volumeSnapshotClassName: 'tns-snap-class',
},
status: {
readyToUse: true,
restoreSize: '100Gi',
},
...overrides,
};
}
export function makeSampleSnapshotClass(overrides?: Partial<VolumeSnapshotClass>): VolumeSnapshotClass {
return {
metadata: {
name: 'tns-snap-class',
creationTimestamp: '2025-01-01T00:00:00Z',
},
driver: 'tns.csi.io',
deletionPolicy: 'Delete',
...overrides,
};
}
export function makeSampleMetrics(overrides?: Partial<TnsCsiMetrics>): TnsCsiMetrics {
return {
websocketConnected: 1,
websocketReconnectsTotal: 3,
websocketMessagesTotal: [{ labels: {}, value: 100 }],
websocketMessageDurationSeconds: [],
volumeOperationsTotal: [
{ labels: { protocol: 'nfs' }, value: 10 },
{ labels: { protocol: 'iscsi' }, value: 5 },
],
volumeOperationsDurationSeconds: [],
volumeCapacityBytes: [
{ labels: { volume_id: 'tank/vol-001' }, value: 107374182400 },
],
csiOperationsTotal: [
{ labels: { method: 'CreateVolume' }, value: 10 },
{ labels: { method: 'DeleteVolume' }, value: 2 },
],
csiOperationsDurationSeconds: [],
...overrides,
};
}