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:
privilegedescalation-engineer[bot]
2026-03-21 12:53:04 +00:00
committed by GitHub
parent 8ec38cb247
commit 6cd159b5a4
9 changed files with 1344 additions and 0 deletions
+154
View File
@@ -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);
});
});
});