test: add component test coverage for all untested files (#17)
* test: add component test coverage for all untested files Adds 60 new tests (108 total) covering every untested module: - IntelGpuDataContext: provider renders, loading/loaded states, CRD available/unavailable paths, refresh, useIntelGpuContext throws outside provider - OverviewPage: loading, plugin-not-detected, error, populated, refresh button, CRD notice, device plugin table, plugin daemon pods, active pods - NodesPage: loading, empty state, GPU node summary table, detail cards - PodsPage: loading, empty state, summary counts, pending pod attention, all-pods table - DevicePluginsPage: loading, CRD unavailable, no-plugins, plugin detail, daemon pod table - NodeDetailSection: null for non-GPU nodes, GPU capacity/allocatable rows, pod list, loading state - PodDetailSection: null for non-GPU pods, GPU resource rows, phase status, limits-only containers - MetricsPage: context loading gate, Prometheus unreachable, empty chips, chip cards with power values, MetricRequirements always rendered, refresh Also fixes vitest.config.mts to pin NODE_ENV=test so tests run correctly without requiring callers to set it explicitly. Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix: remove unused act import and merge duplicate metrics imports in MetricsPage.test.tsx Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix: cast useList mock return values to any in IntelGpuDataContext.test.tsx The Headlamp useList() return type is an intersection of a tuple and QueryListResponse, which plain array literals like [[], null] and [null, null] do not satisfy. Cast all useList mockReturnValue arguments to any so tsc passes without requiring full KubeObject stub objects. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * style: run Prettier formatting and ESLint lint:fix on test files Addresses CI format:check failures and import-sort warning in MetricsPage.test.tsx flagged by QA on PR #17. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Hugh Hackman <hugh@privilegedescalation.com> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Gandalf the Greybeard <gandalf@privilegedescalation.com> Co-authored-by: Gandalf the Greybeard <gandalf@privilegedescalation.dev> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Gandalf the Greybeard <gandalf-the-greybeard[bot]@users.noreply.github.com>
This commit was merged in pull request #17.
This commit is contained in:
committed by
GitHub
parent
8ec38cb247
commit
6cd159b5a4
@@ -0,0 +1,154 @@
|
||||
import { ApiProxy, K8s } from '@kinvolk/headlamp-plugin/lib';
|
||||
import { act, render, renderHook, screen, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { IntelGpuDataProvider, useIntelGpuContext } from './IntelGpuDataContext';
|
||||
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||
K8s: {
|
||||
ResourceClasses: {
|
||||
Node: { useList: vi.fn() },
|
||||
Pod: { useList: vi.fn() },
|
||||
},
|
||||
},
|
||||
ApiProxy: { request: vi.fn() },
|
||||
}));
|
||||
|
||||
// Minimal GPU node fixture
|
||||
const gpuNodeRaw = {
|
||||
metadata: {
|
||||
name: 'gpu-node-1',
|
||||
uid: 'uid-001',
|
||||
labels: { 'intel.feature.node.kubernetes.io/gpu': 'true' },
|
||||
},
|
||||
status: {
|
||||
capacity: { 'gpu.intel.com/i915': '1' },
|
||||
allocatable: { 'gpu.intel.com/i915': '1' },
|
||||
},
|
||||
};
|
||||
|
||||
// Minimal GPU plugin CRD fixture
|
||||
const gpuDevicePluginRaw = {
|
||||
kind: 'GpuDevicePlugin',
|
||||
metadata: { name: 'gpu-plugin-default', uid: 'uid-dp-001' },
|
||||
spec: {},
|
||||
};
|
||||
|
||||
function makeNodeWrapper(raw: unknown) {
|
||||
return { jsonData: raw };
|
||||
}
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return <IntelGpuDataProvider>{children}</IntelGpuDataProvider>;
|
||||
}
|
||||
|
||||
describe('useIntelGpuContext', () => {
|
||||
it('throws when used outside provider', () => {
|
||||
// Suppress React error boundary output
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
expect(() => renderHook(() => useIntelGpuContext())).toThrow(
|
||||
'useIntelGpuContext must be used within an IntelGpuDataProvider'
|
||||
);
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('IntelGpuDataProvider', () => {
|
||||
it('renders children', async () => {
|
||||
vi.mocked(K8s.ResourceClasses.Node.useList).mockReturnValue([[], null] as any);
|
||||
vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([[], null] as any);
|
||||
vi.mocked(ApiProxy.request).mockResolvedValue({ items: [] });
|
||||
|
||||
render(
|
||||
<IntelGpuDataProvider>
|
||||
<div data-testid="child">hello</div>
|
||||
</IntelGpuDataProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('child')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes loading=true while nodes/pods are null', async () => {
|
||||
vi.mocked(K8s.ResourceClasses.Node.useList).mockReturnValue([null, null] as any);
|
||||
vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([null, null] as any);
|
||||
// Keep async request pending forever
|
||||
vi.mocked(ApiProxy.request).mockReturnValue(new Promise(() => {}));
|
||||
|
||||
const { result } = renderHook(() => useIntelGpuContext(), { wrapper: Wrapper });
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
});
|
||||
|
||||
it('exposes loaded state with GPU nodes once data arrives', async () => {
|
||||
vi.mocked(K8s.ResourceClasses.Node.useList).mockReturnValue([
|
||||
[makeNodeWrapper(gpuNodeRaw)] as any,
|
||||
null,
|
||||
] as any);
|
||||
vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([[], null] as any);
|
||||
vi.mocked(ApiProxy.request).mockResolvedValue({ items: [] });
|
||||
|
||||
const { result } = renderHook(() => useIntelGpuContext(), { wrapper: Wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.gpuNodes).toHaveLength(1);
|
||||
expect(result.current.gpuNodes[0].metadata.name).toBe('gpu-node-1');
|
||||
});
|
||||
|
||||
it('sets crdAvailable=true and populates devicePlugins when ApiProxy returns plugin list', async () => {
|
||||
vi.mocked(K8s.ResourceClasses.Node.useList).mockReturnValue([[], null] as any);
|
||||
vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([[], null] as any);
|
||||
|
||||
// First call = CRD list, subsequent calls = plugin pod selectors (empty)
|
||||
vi.mocked(ApiProxy.request)
|
||||
.mockResolvedValueOnce({ items: [gpuDevicePluginRaw] })
|
||||
.mockResolvedValue({ items: [] });
|
||||
|
||||
const { result } = renderHook(() => useIntelGpuContext(), { wrapper: Wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.crdAvailable).toBe(true);
|
||||
expect(result.current.devicePlugins).toHaveLength(1);
|
||||
expect(result.current.devicePlugins[0].metadata.name).toBe('gpu-plugin-default');
|
||||
});
|
||||
|
||||
it('sets crdAvailable=false and does not surface error when ApiProxy throws on CRD request', async () => {
|
||||
vi.mocked(K8s.ResourceClasses.Node.useList).mockReturnValue([[], null] as any);
|
||||
vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([[], null] as any);
|
||||
|
||||
// First call (CRD endpoint) throws, plugin pod selectors resolve empty
|
||||
vi.mocked(ApiProxy.request)
|
||||
.mockRejectedValueOnce(new Error('CRD not found'))
|
||||
.mockResolvedValue({ items: [] });
|
||||
|
||||
const { result } = renderHook(() => useIntelGpuContext(), { wrapper: Wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.crdAvailable).toBe(false);
|
||||
expect(result.current.devicePlugins).toHaveLength(0);
|
||||
// Inner CRD error should NOT be bubbled up to the top-level error field
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('increments refreshKey and re-runs the effect when refresh() is called', async () => {
|
||||
vi.mocked(K8s.ResourceClasses.Node.useList).mockReturnValue([[], null] as any);
|
||||
vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([[], null] as any);
|
||||
vi.mocked(ApiProxy.request).mockResolvedValue({ items: [] });
|
||||
|
||||
const { result } = renderHook(() => useIntelGpuContext(), { wrapper: Wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
const callCountBefore = vi.mocked(ApiProxy.request).mock.calls.length;
|
||||
|
||||
await act(async () => {
|
||||
result.current.refresh();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const callCountAfter = vi.mocked(ApiProxy.request).mock.calls.length;
|
||||
expect(callCountAfter).toBeGreaterThan(callCountBefore);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user