diff --git a/src/components/BenchmarkPage.test.tsx b/src/components/BenchmarkPage.test.tsx new file mode 100644 index 0000000..73a6c53 --- /dev/null +++ b/src/components/BenchmarkPage.test.tsx @@ -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(); + 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[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(); + expect(screen.getByTestId('loader')).toHaveTextContent('Loading tns-csi data...'); + }); + + it('renders benchmark guide section', () => { + mockContext(); + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + + // 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 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(); + + 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(); + 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(); + }); +}); diff --git a/src/components/DriverStatusCard.test.tsx b/src/components/DriverStatusCard.test.tsx new file mode 100644 index 0000000..9cb2ada --- /dev/null +++ b/src/components/DriverStatusCard.test.tsx @@ -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( + + ); + expect(screen.getByText('Not detected')).toBeInTheDocument(); + }); + + it('shows "Degraded" when no pods are present', () => { + render( + + ); + expect(screen.getByText('Degraded')).toBeInTheDocument(); + }); + + it('shows "Metrics unavailable" when no metrics provided', () => { + render( + + ); + expect(screen.getByText('Metrics unavailable')).toBeInTheDocument(); + }); + + it('shows "Healthy" and "Connected" when all pods ready and WS connected', () => { + const metrics = makeSampleMetrics({ websocketConnected: 1 }); + render( + + ); + 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( + + ); + expect(screen.getByText('Disconnected')).toBeInTheDocument(); + }); + + it('shows "Unknown" when websocketConnected is null', () => { + const metrics = makeSampleMetrics({ websocketConnected: null }); + render( + + ); + expect(screen.getByText('Unknown')).toBeInTheDocument(); + }); + + it('renders CSI capabilities section when driver is present', () => { + render( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + expect(screen.getByText('No controller pod found')).toBeInTheDocument(); + }); + + it('shows "No node pods found" when nodePods is empty', () => { + render( + + ); + expect(screen.getByText('No node pods found')).toBeInTheDocument(); + }); + + it('shows WS reconnects when available in metrics', () => { + const metrics = makeSampleMetrics({ websocketReconnectsTotal: 7 }); + render( + + ); + 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( + + ); + expect(screen.getByText('Node Pods (2)')).toBeInTheDocument(); + }); +}); diff --git a/src/components/MetricsPage.test.tsx b/src/components/MetricsPage.test.tsx new file mode 100644 index 0000000..11831b1 --- /dev/null +++ b/src/components/MetricsPage.test.tsx @@ -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(); + 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[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(); + expect(screen.getByTestId('loader')).toHaveTextContent('Loading tns-csi data...'); + }); + + it('shows "Driver Not Detected" when driver not installed', () => { + mockContext({ driverInstalled: false }); + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + await waitFor(() => { + expect(screen.getByText('Operations (nfs)')).toBeInTheDocument(); + }); + expect(screen.getByText('Operations (iscsi)')).toBeInTheDocument(); + }); +}); diff --git a/src/components/OverviewPage.test.tsx b/src/components/OverviewPage.test.tsx new file mode 100644 index 0000000..2645e1c --- /dev/null +++ b/src/components/OverviewPage.test.tsx @@ -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(); + 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[0]) { + vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides)); +} + +describe('OverviewPage', () => { + beforeEach(() => { + vi.mocked(fetchControllerMetrics).mockReset(); + }); + + it('shows loader when loading', () => { + mockContext({ loading: true }); + render(); + expect(screen.getByTestId('loader')).toHaveTextContent('Loading TNS-CSI data...'); + }); + + it('shows "Driver Not Detected" when driver not installed', () => { + mockContext({ driverInstalled: false }); + render(); + 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(); + expect(screen.getByText('cluster unavailable')).toBeInTheDocument(); + }); + + it('always shows the development status notice', () => { + mockContext({ driverInstalled: true }); + render(); + 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(); + 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(); + // 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + expect(screen.getByText('PVCs (Pending)')).toBeInTheDocument(); + expect(screen.getByText('PVCs (Lost)')).toBeInTheDocument(); + }); +}); diff --git a/src/components/PVCDetailSection.test.tsx b/src/components/PVCDetailSection.test.tsx new file mode 100644 index 0000000..9003fa0 --- /dev/null +++ b/src/components/PVCDetailSection.test.tsx @@ -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[0]) { + vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides)); +} + +describe('PVCDetailSection', () => { + it('returns null when loading', () => { + mockContext({ loading: true }); + const { container } = render( + + ); + expect(container.innerHTML).toBe(''); + }); + + it('returns null when PVC is not in filtered list', () => { + mockContext({ persistentVolumeClaims: [] }); + const { container } = render( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + expect(screen.getByText('pool')).toBeInTheDocument(); + expect(screen.getByText('tank')).toBeInTheDocument(); + expect(screen.getByText('customAttr')).toBeInTheDocument(); + expect(screen.getByText('customValue')).toBeInTheDocument(); + }); +}); diff --git a/src/components/SnapshotsPage.test.tsx b/src/components/SnapshotsPage.test.tsx new file mode 100644 index 0000000..056145c --- /dev/null +++ b/src/components/SnapshotsPage.test.tsx @@ -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[0]) { + vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides)); +} + +describe('SnapshotsPage', () => { + it('shows loader when loading', () => { + mockContext({ loading: true }); + render(); + expect(screen.getByTestId('loader')).toHaveTextContent('Loading snapshots...'); + }); + + it('shows error state', () => { + mockContext({ error: 'something broke' }); + render(); + expect(screen.getByText('something broke')).toBeInTheDocument(); + expect(screen.getByText('Error')).toBeInTheDocument(); + }); + + it('shows notice when snapshot CRD is not available', () => { + mockContext({ snapshotCrdAvailable: false }); + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + expect(screen.getByText('Unknown')).toBeInTheDocument(); + }); + + it('does not render snapshot classes section when empty', () => { + mockContext({ + snapshotCrdAvailable: true, + volumeSnapshotClasses: [], + volumeSnapshots: [makeSampleSnapshot()], + }); + render(); + expect(screen.queryByText(/Snapshot Classes/)).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/StorageClassesPage.test.tsx b/src/components/StorageClassesPage.test.tsx new file mode 100644 index 0000000..2fe4fbe --- /dev/null +++ b/src/components/StorageClassesPage.test.tsx @@ -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[0]) { + vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides)); +} + +describe('StorageClassesPage', () => { + beforeEach(() => { + mockPush.mockClear(); + mockHash = ''; + }); + + it('shows loader when loading', () => { + mockContext({ loading: true }); + render(); + expect(screen.getByTestId('loader')).toHaveTextContent('Loading storage classes...'); + }); + + it('shows error state', () => { + mockContext({ error: 'fetch failed' }); + render(); + expect(screen.getByText('fetch failed')).toBeInTheDocument(); + expect(screen.getByText('Error')).toBeInTheDocument(); + }); + + it('shows empty message when no storage classes', () => { + mockContext({ storageClasses: [] }); + render(); + 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(); + 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(); + 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(); + expect(screen.getByText('StorageClass Details')).toBeInTheDocument(); + }); + + it('closes panel via close button', () => { + mockHash = '#tns-nfs'; + const sc = makeSampleStorageClass(); + mockContext({ storageClasses: [sc], persistentVolumes: [] }); + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + const cells = screen.getAllByRole('cell'); + const pvCells = cells.filter(c => c.textContent === '2'); + expect(pvCells.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/src/components/VolumesPage.test.tsx b/src/components/VolumesPage.test.tsx new file mode 100644 index 0000000..12d2e67 --- /dev/null +++ b/src/components/VolumesPage.test.tsx @@ -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[0]) { + vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides)); +} + +describe('VolumesPage', () => { + beforeEach(() => { + mockPush.mockClear(); + mockHash = ''; + }); + + it('shows loader when loading', () => { + mockContext({ loading: true }); + render(); + expect(screen.getByTestId('loader')).toHaveTextContent('Loading volumes...'); + }); + + it('shows error state', () => { + mockContext({ error: 'api error' }); + render(); + expect(screen.getByText('api error')).toBeInTheDocument(); + }); + + it('shows empty message when no PVs', () => { + mockContext({ persistentVolumes: [] }); + render(); + expect(screen.getByText('No tns-csi PersistentVolumes found.')).toBeInTheDocument(); + }); + + it('renders PV table with claim ref', () => { + const pv = makeSamplePV(); + mockContext({ persistentVolumes: [pv] }); + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + const maxBtn = screen.getByLabelText('Maximize'); + expect(maxBtn).toBeInTheDocument(); + fireEvent.click(maxBtn); + expect(screen.getByLabelText('Minimize')).toBeInTheDocument(); + }); +}); diff --git a/src/components/__mocks__/commonComponents.ts b/src/components/__mocks__/commonComponents.ts new file mode 100644 index 0000000..ee875d8 --- /dev/null +++ b/src/components/__mocks__/commonComponents.ts @@ -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}`) + ) + ); diff --git a/src/test-helpers.tsx b/src/test-helpers.tsx new file mode 100644 index 0000000..ed1d4bb --- /dev/null +++ b/src/test-helpers.tsx @@ -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 { + 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 { + 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 { + 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 { + 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 & { 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 { + 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 { + 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 { + 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, + }; +} +