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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { IntelGpuContextValue, useIntelGpuContext } from '../api/IntelGpuDataContext';
|
||||||
|
import { GpuDevicePlugin, IntelGpuPod } from '../api/k8s';
|
||||||
|
import DevicePluginsPage from './DevicePluginsPage';
|
||||||
|
|
||||||
|
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
||||||
|
Loader: ({ title }: { title: string }) => <div data-testid="loader">{title}</div>,
|
||||||
|
SectionBox: ({ title, children }: { title: string; children?: React.ReactNode }) => (
|
||||||
|
<section>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
),
|
||||||
|
SectionHeader: ({ title }: { title: string }) => <h1>{title}</h1>,
|
||||||
|
NameValueTable: ({
|
||||||
|
rows,
|
||||||
|
}: {
|
||||||
|
rows: Array<{ name: React.ReactNode; value: React.ReactNode }>;
|
||||||
|
}) => (
|
||||||
|
<dl>
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<dt>{r.name}</dt>
|
||||||
|
<dd>{r.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
),
|
||||||
|
SimpleTable: ({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
columns: Array<{ label: string; getter: (item: unknown) => React.ReactNode }>;
|
||||||
|
data: unknown[];
|
||||||
|
}) => (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map(c => (
|
||||||
|
<th key={c.label}>{c.label}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((item, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
{columns.map(c => (
|
||||||
|
<td key={c.label}>{c.getter(item)}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
),
|
||||||
|
StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => (
|
||||||
|
<span data-status={status}>{children}</span>
|
||||||
|
),
|
||||||
|
PercentageBar: () => <div data-testid="percentage-bar" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../api/IntelGpuDataContext', () => ({
|
||||||
|
useIntelGpuContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function makeContext(overrides: Partial<IntelGpuContextValue> = {}): IntelGpuContextValue {
|
||||||
|
return {
|
||||||
|
devicePlugins: [],
|
||||||
|
pluginInstalled: false,
|
||||||
|
gpuNodes: [],
|
||||||
|
gpuPods: [],
|
||||||
|
pluginPods: [],
|
||||||
|
crdAvailable: false,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refresh: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const samplePlugin: GpuDevicePlugin = {
|
||||||
|
kind: 'GpuDevicePlugin',
|
||||||
|
metadata: {
|
||||||
|
name: 'intel-gpu-plugin',
|
||||||
|
uid: 'uid-dp-1',
|
||||||
|
creationTimestamp: '2025-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
image: 'intel/intel-gpu-plugin:latest',
|
||||||
|
sharedDevNum: 4,
|
||||||
|
enableMonitoring: true,
|
||||||
|
preferredAllocationPolicy: 'balanced',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
desiredNumberScheduled: 3,
|
||||||
|
numberReady: 3,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const pluginPod: IntelGpuPod = {
|
||||||
|
metadata: {
|
||||||
|
name: 'intel-gpu-plugin-abc12',
|
||||||
|
namespace: 'kube-system',
|
||||||
|
uid: 'uid-pp-1',
|
||||||
|
},
|
||||||
|
spec: { nodeName: 'worker-1' },
|
||||||
|
status: {
|
||||||
|
phase: 'Running',
|
||||||
|
conditions: [{ type: 'Ready', status: 'True' }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('DevicePluginsPage', () => {
|
||||||
|
it('shows loader when loading=true', () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: true }));
|
||||||
|
render(<DevicePluginsPage />);
|
||||||
|
expect(screen.getByTestId('loader')).toHaveTextContent('Loading device plugin data...');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "CRD Not Available" section when crdAvailable=false', () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(
|
||||||
|
makeContext({ loading: false, crdAvailable: false })
|
||||||
|
);
|
||||||
|
render(<DevicePluginsPage />);
|
||||||
|
expect(screen.getByText('CRD Not Available')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/GpuDevicePlugin CRD.*is not installed/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "No Device Plugins" section when crdAvailable=true but devicePlugins empty', () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(
|
||||||
|
makeContext({ loading: false, crdAvailable: true, devicePlugins: [] })
|
||||||
|
);
|
||||||
|
render(<DevicePluginsPage />);
|
||||||
|
expect(screen.getByText('No Device Plugins')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/No GpuDevicePlugin resources found/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows plugin detail section when crdAvailable=true and devicePlugins present', () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(
|
||||||
|
makeContext({
|
||||||
|
loading: false,
|
||||||
|
crdAvailable: true,
|
||||||
|
devicePlugins: [samplePlugin],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<DevicePluginsPage />);
|
||||||
|
expect(screen.getByText('GpuDevicePlugin: intel-gpu-plugin')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('intel/intel-gpu-plugin:latest')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Plugin Daemon Pods" table when pluginPods present', () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(
|
||||||
|
makeContext({
|
||||||
|
loading: false,
|
||||||
|
crdAvailable: true,
|
||||||
|
devicePlugins: [samplePlugin],
|
||||||
|
pluginPods: [pluginPod],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<DevicePluginsPage />);
|
||||||
|
expect(screen.getByText('Plugin Daemon Pods')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('intel-gpu-plugin-abc12')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error section when error is set', () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(
|
||||||
|
makeContext({ loading: false, crdAvailable: true, error: 'fetch error' })
|
||||||
|
);
|
||||||
|
render(<DevicePluginsPage />);
|
||||||
|
expect(screen.getByText('fetch error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { IntelGpuContextValue, useIntelGpuContext } from '../api/IntelGpuDataContext';
|
||||||
|
import { fetchGpuMetrics, GpuChipMetrics, GpuMetrics } from '../api/metrics';
|
||||||
|
import MetricsPage from './MetricsPage';
|
||||||
|
|
||||||
|
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
||||||
|
Loader: ({ title }: { title: string }) => <div data-testid="loader">{title}</div>,
|
||||||
|
SectionBox: ({ title, children }: { title: string; children?: React.ReactNode }) => (
|
||||||
|
<section>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
),
|
||||||
|
SectionHeader: ({ title }: { title: string }) => <h1>{title}</h1>,
|
||||||
|
NameValueTable: ({
|
||||||
|
rows,
|
||||||
|
}: {
|
||||||
|
rows: Array<{ name: React.ReactNode; value: React.ReactNode }>;
|
||||||
|
}) => (
|
||||||
|
<dl>
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<dt>{r.name}</dt>
|
||||||
|
<dd>{r.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
),
|
||||||
|
SimpleTable: ({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
columns: Array<{ label: string; getter: (item: unknown) => React.ReactNode }>;
|
||||||
|
data: unknown[];
|
||||||
|
}) => (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map(c => (
|
||||||
|
<th key={c.label}>{c.label}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((item, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
{columns.map(c => (
|
||||||
|
<td key={c.label}>{c.getter(item)}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
),
|
||||||
|
StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => (
|
||||||
|
<span data-status={status}>{children}</span>
|
||||||
|
),
|
||||||
|
PercentageBar: () => <div data-testid="percentage-bar" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../api/IntelGpuDataContext', () => ({
|
||||||
|
useIntelGpuContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../api/metrics', () => ({
|
||||||
|
fetchGpuMetrics: vi.fn(),
|
||||||
|
formatWatts: (w: number) => `${w.toFixed(1)} W`,
|
||||||
|
formatPercent: (used: number, max: number) =>
|
||||||
|
max <= 0 ? '—' : `${Math.round((used / max) * 100)}%`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function makeContext(overrides: Partial<IntelGpuContextValue> = {}): IntelGpuContextValue {
|
||||||
|
return {
|
||||||
|
devicePlugins: [],
|
||||||
|
pluginInstalled: false,
|
||||||
|
gpuNodes: [],
|
||||||
|
gpuPods: [],
|
||||||
|
pluginPods: [],
|
||||||
|
crdAvailable: false,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refresh: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMetrics(chips: GpuChipMetrics[]): GpuMetrics {
|
||||||
|
return {
|
||||||
|
chips,
|
||||||
|
fetchedAt: new Date('2025-03-21T10:00:00Z').toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleChip: GpuChipMetrics = {
|
||||||
|
nodeName: 'gpu-node-1',
|
||||||
|
chip: '0000:09:01_0',
|
||||||
|
instance: '192.168.1.10:9100',
|
||||||
|
powerWatts: 45.3,
|
||||||
|
powerMaxWatts: 120.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('MetricsPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loader when ctxLoading=true', () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: true }));
|
||||||
|
// fetchGpuMetrics should never be called in loading state
|
||||||
|
vi.mocked(fetchGpuMetrics).mockResolvedValue(null);
|
||||||
|
render(<MetricsPage />);
|
||||||
|
expect(screen.getByTestId('loader')).toHaveTextContent('Loading Intel GPU data...');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Prometheus Unreachable" section when fetchGpuMetrics returns null', async () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false }));
|
||||||
|
vi.mocked(fetchGpuMetrics).mockResolvedValue(null);
|
||||||
|
|
||||||
|
render(<MetricsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Prometheus Unreachable')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "No i915 Metrics in Prometheus" when fetchGpuMetrics returns empty chips', async () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false }));
|
||||||
|
vi.mocked(fetchGpuMetrics).mockResolvedValue(makeMetrics([]));
|
||||||
|
|
||||||
|
render(<MetricsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('No i915 Metrics in Prometheus')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows chip cards with node name when fetchGpuMetrics returns chips', async () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false }));
|
||||||
|
vi.mocked(fetchGpuMetrics).mockResolvedValue(makeMetrics([sampleChip]));
|
||||||
|
|
||||||
|
render(<MetricsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// GpuChipCard title format: "{nodeName} — {chip}"
|
||||||
|
expect(screen.getByText('gpu-node-1 — 0000:09:01_0')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always renders MetricRequirements section', async () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false }));
|
||||||
|
vi.mocked(fetchGpuMetrics).mockResolvedValue(makeMetrics([]));
|
||||||
|
|
||||||
|
render(<MetricsPage />);
|
||||||
|
|
||||||
|
// The MetricRequirements section box is titled "Metric Availability"
|
||||||
|
expect(screen.getByText('Metric Availability')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows GPU Power Summary section when chips are present', async () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false }));
|
||||||
|
vi.mocked(fetchGpuMetrics).mockResolvedValue(makeMetrics([sampleChip]));
|
||||||
|
|
||||||
|
render(<MetricsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('GPU Power Summary')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-triggers fetch when refresh button is clicked', async () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false }));
|
||||||
|
vi.mocked(fetchGpuMetrics).mockResolvedValue(makeMetrics([]));
|
||||||
|
|
||||||
|
render(<MetricsPage />);
|
||||||
|
|
||||||
|
// Wait for initial fetch to complete
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(vi.mocked(fetchGpuMetrics)).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const callsBefore = vi.mocked(fetchGpuMetrics).mock.calls.length;
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /refresh metrics/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(vi.mocked(fetchGpuMetrics).mock.calls.length).toBeGreaterThan(callsBefore);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Intel GPU — Metrics" heading', async () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false }));
|
||||||
|
vi.mocked(fetchGpuMetrics).mockResolvedValue(makeMetrics([]));
|
||||||
|
|
||||||
|
render(<MetricsPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Intel GPU — Metrics')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows power values for chip cards', async () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false }));
|
||||||
|
vi.mocked(fetchGpuMetrics).mockResolvedValue(makeMetrics([sampleChip]));
|
||||||
|
|
||||||
|
render(<MetricsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// formatWatts mock: "45.3 W" and "120.0 W"
|
||||||
|
expect(screen.getAllByText(/45\.3 W/).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { IntelGpuContextValue, useIntelGpuContext } from '../api/IntelGpuDataContext';
|
||||||
|
import { IntelGpuPod } from '../api/k8s';
|
||||||
|
import NodeDetailSection from './NodeDetailSection';
|
||||||
|
|
||||||
|
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
||||||
|
SectionBox: ({ title, children }: { title: string; children?: React.ReactNode }) => (
|
||||||
|
<section>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
),
|
||||||
|
NameValueTable: ({
|
||||||
|
rows,
|
||||||
|
}: {
|
||||||
|
rows: Array<{ name: React.ReactNode; value: React.ReactNode }>;
|
||||||
|
}) => (
|
||||||
|
<dl>
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<dt>{r.name}</dt>
|
||||||
|
<dd>{r.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
),
|
||||||
|
StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => (
|
||||||
|
<span data-status={status}>{children}</span>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../api/IntelGpuDataContext', () => ({
|
||||||
|
useIntelGpuContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function makeContext(overrides: Partial<IntelGpuContextValue> = {}): IntelGpuContextValue {
|
||||||
|
return {
|
||||||
|
devicePlugins: [],
|
||||||
|
pluginInstalled: false,
|
||||||
|
gpuNodes: [],
|
||||||
|
gpuPods: [],
|
||||||
|
pluginPods: [],
|
||||||
|
crdAvailable: false,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refresh: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A raw GPU node (matches IntelGpuNode shape) with capacity/allocatable
|
||||||
|
const gpuNodeRaw = {
|
||||||
|
kind: 'Node',
|
||||||
|
metadata: {
|
||||||
|
name: 'gpu-node-1',
|
||||||
|
labels: { 'intel.feature.node.kubernetes.io/gpu': 'true' },
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
capacity: { 'gpu.intel.com/i915': '2', cpu: '8' },
|
||||||
|
allocatable: { 'gpu.intel.com/i915': '2', cpu: '8' },
|
||||||
|
nodeInfo: {
|
||||||
|
kernelVersion: '5.15.0-generic',
|
||||||
|
osImage: 'Ubuntu 22.04.3 LTS',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// A non-GPU node — no labels, no gpu.intel.com capacity
|
||||||
|
const nonGpuNodeRaw = {
|
||||||
|
kind: 'Node',
|
||||||
|
metadata: {
|
||||||
|
name: 'plain-node-1',
|
||||||
|
labels: {},
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
capacity: { cpu: '4', memory: '8Gi' },
|
||||||
|
allocatable: { cpu: '4', memory: '8Gi' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('NodeDetailSection', () => {
|
||||||
|
it('renders nothing for a non-GPU node (no Intel GPU labels or capacity)', () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext());
|
||||||
|
const { container } = render(<NodeDetailSection resource={nonGpuNodeRaw} />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing for a non-GPU node passed via jsonData wrapper', () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext());
|
||||||
|
const { container } = render(<NodeDetailSection resource={{ jsonData: nonGpuNodeRaw }} />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "Intel GPU" section for a GPU node provided via jsonData', () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false, gpuPods: [] }));
|
||||||
|
render(<NodeDetailSection resource={{ jsonData: gpuNodeRaw }} />);
|
||||||
|
expect(screen.getByText('Intel GPU')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "Intel GPU" section for a GPU node provided directly', () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false, gpuPods: [] }));
|
||||||
|
render(<NodeDetailSection resource={gpuNodeRaw} />);
|
||||||
|
expect(screen.getByText('Intel GPU')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders capacity and allocatable rows', () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false, gpuPods: [] }));
|
||||||
|
render(<NodeDetailSection resource={gpuNodeRaw} />);
|
||||||
|
// GPU (i915) capacity and allocatable rows
|
||||||
|
expect(screen.getByText('GPU (i915) (capacity)')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('GPU (i915) (allocatable)')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "None" for GPU Workload Pods when no pods are on the node and not loading', () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false, gpuPods: [] }));
|
||||||
|
render(<NodeDetailSection resource={gpuNodeRaw} />);
|
||||||
|
expect(screen.getByText('None')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Loading…" for GPU Workload Pods when context is loading', () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: true, gpuPods: [] }));
|
||||||
|
render(<NodeDetailSection resource={gpuNodeRaw} />);
|
||||||
|
expect(screen.getByText('Loading…')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists pod names when GPU pods are scheduled on the node', () => {
|
||||||
|
const gpuPod: IntelGpuPod = {
|
||||||
|
metadata: { name: 'my-gpu-pod', namespace: 'default', uid: 'uid-pod-1' },
|
||||||
|
spec: {
|
||||||
|
nodeName: 'gpu-node-1',
|
||||||
|
containers: [{ name: 'main', resources: { requests: { 'gpu.intel.com/i915': '1' } } }],
|
||||||
|
},
|
||||||
|
status: { phase: 'Running' },
|
||||||
|
};
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(
|
||||||
|
makeContext({ loading: false, gpuPods: [gpuPod] })
|
||||||
|
);
|
||||||
|
render(<NodeDetailSection resource={gpuNodeRaw} />);
|
||||||
|
expect(screen.getByText('my-gpu-pod')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { IntelGpuContextValue, useIntelGpuContext } from '../api/IntelGpuDataContext';
|
||||||
|
import { IntelGpuNode } from '../api/k8s';
|
||||||
|
import NodesPage from './NodesPage';
|
||||||
|
|
||||||
|
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
||||||
|
Loader: ({ title }: { title: string }) => <div data-testid="loader">{title}</div>,
|
||||||
|
SectionBox: ({ title, children }: { title: string; children?: React.ReactNode }) => (
|
||||||
|
<section>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
),
|
||||||
|
SectionHeader: ({ title }: { title: string }) => <h1>{title}</h1>,
|
||||||
|
NameValueTable: ({
|
||||||
|
rows,
|
||||||
|
}: {
|
||||||
|
rows: Array<{ name: React.ReactNode; value: React.ReactNode }>;
|
||||||
|
}) => (
|
||||||
|
<dl>
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<dt>{r.name}</dt>
|
||||||
|
<dd>{r.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
),
|
||||||
|
SimpleTable: ({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
columns: Array<{ label: string; getter: (item: unknown) => React.ReactNode }>;
|
||||||
|
data: unknown[];
|
||||||
|
}) => (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map(c => (
|
||||||
|
<th key={c.label}>{c.label}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((item, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
{columns.map(c => (
|
||||||
|
<td key={c.label}>{c.getter(item)}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
),
|
||||||
|
StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => (
|
||||||
|
<span data-status={status}>{children}</span>
|
||||||
|
),
|
||||||
|
PercentageBar: () => <div data-testid="percentage-bar" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../api/IntelGpuDataContext', () => ({
|
||||||
|
useIntelGpuContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function makeContext(overrides: Partial<IntelGpuContextValue> = {}): IntelGpuContextValue {
|
||||||
|
return {
|
||||||
|
devicePlugins: [],
|
||||||
|
pluginInstalled: false,
|
||||||
|
gpuNodes: [],
|
||||||
|
gpuPods: [],
|
||||||
|
pluginPods: [],
|
||||||
|
crdAvailable: false,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refresh: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const gpuNode: IntelGpuNode = {
|
||||||
|
metadata: {
|
||||||
|
name: 'gpu-node-1',
|
||||||
|
uid: 'uid-001',
|
||||||
|
labels: { 'intel.feature.node.kubernetes.io/gpu': 'true' },
|
||||||
|
creationTimestamp: '2025-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
capacity: { 'gpu.intel.com/i915': '2', cpu: '8' },
|
||||||
|
allocatable: { 'gpu.intel.com/i915': '2', cpu: '8' },
|
||||||
|
conditions: [{ type: 'Ready', status: 'True' }],
|
||||||
|
nodeInfo: {
|
||||||
|
osImage: 'Ubuntu 22.04',
|
||||||
|
kernelVersion: '5.15.0',
|
||||||
|
kubeletVersion: 'v1.28.0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('NodesPage', () => {
|
||||||
|
it('shows loader when loading=true', () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: true }));
|
||||||
|
render(<NodesPage />);
|
||||||
|
expect(screen.getByTestId('loader')).toHaveTextContent('Loading GPU node data...');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "No GPU Nodes Found" when gpuNodes is empty', () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false, gpuNodes: [] }));
|
||||||
|
render(<NodesPage />);
|
||||||
|
expect(screen.getByText('No GPU Nodes Found')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "GPU Node Summary" section and per-node detail card when gpuNodes present', () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(
|
||||||
|
makeContext({ loading: false, gpuNodes: [gpuNode] })
|
||||||
|
);
|
||||||
|
render(<NodesPage />);
|
||||||
|
expect(screen.getByText('GPU Node Summary')).toBeInTheDocument();
|
||||||
|
// Node name appears in both the summary table and the detail card section header
|
||||||
|
expect(screen.getAllByText('gpu-node-1').length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a detail card for each GPU node', () => {
|
||||||
|
const secondNode: IntelGpuNode = {
|
||||||
|
metadata: {
|
||||||
|
name: 'gpu-node-2',
|
||||||
|
uid: 'uid-002',
|
||||||
|
labels: { 'intel.feature.node.kubernetes.io/gpu': 'true' },
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
capacity: { 'gpu.intel.com/i915': '1' },
|
||||||
|
allocatable: { 'gpu.intel.com/i915': '1' },
|
||||||
|
conditions: [{ type: 'Ready', status: 'True' }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(
|
||||||
|
makeContext({ loading: false, gpuNodes: [gpuNode, secondNode] })
|
||||||
|
);
|
||||||
|
render(<NodesPage />);
|
||||||
|
// Node names appear in both the summary table cell and the detail card heading
|
||||||
|
expect(screen.getAllByText('gpu-node-1').length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getAllByText('gpu-node-2').length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error section when error is set', () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(
|
||||||
|
makeContext({ loading: false, error: 'node fetch failed', gpuNodes: [] })
|
||||||
|
);
|
||||||
|
render(<NodesPage />);
|
||||||
|
expect(screen.getByText('node fetch failed')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { IntelGpuContextValue, useIntelGpuContext } from '../api/IntelGpuDataContext';
|
||||||
|
import { GpuDevicePlugin, IntelGpuNode, IntelGpuPod } from '../api/k8s';
|
||||||
|
import OverviewPage from './OverviewPage';
|
||||||
|
|
||||||
|
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
||||||
|
Loader: ({ title }: { title: string }) => <div data-testid="loader">{title}</div>,
|
||||||
|
SectionBox: ({ title, children }: { title: string; children?: React.ReactNode }) => (
|
||||||
|
<section>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
),
|
||||||
|
SectionHeader: ({ title }: { title: string }) => <h1>{title}</h1>,
|
||||||
|
NameValueTable: ({
|
||||||
|
rows,
|
||||||
|
}: {
|
||||||
|
rows: Array<{ name: React.ReactNode; value: React.ReactNode }>;
|
||||||
|
}) => (
|
||||||
|
<dl>
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<dt>{r.name}</dt>
|
||||||
|
<dd>{r.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
),
|
||||||
|
SimpleTable: ({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
columns: Array<{ label: string; getter: (item: unknown) => React.ReactNode }>;
|
||||||
|
data: unknown[];
|
||||||
|
}) => (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map(c => (
|
||||||
|
<th key={c.label}>{c.label}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((item, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
{columns.map(c => (
|
||||||
|
<td key={c.label}>{c.getter(item)}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
),
|
||||||
|
StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => (
|
||||||
|
<span data-status={status}>{children}</span>
|
||||||
|
),
|
||||||
|
PercentageBar: () => <div data-testid="percentage-bar" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../api/IntelGpuDataContext', () => ({
|
||||||
|
useIntelGpuContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function makeContext(overrides: Partial<IntelGpuContextValue> = {}): IntelGpuContextValue {
|
||||||
|
return {
|
||||||
|
devicePlugins: [],
|
||||||
|
pluginInstalled: false,
|
||||||
|
gpuNodes: [],
|
||||||
|
gpuPods: [],
|
||||||
|
pluginPods: [],
|
||||||
|
crdAvailable: false,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refresh: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OverviewPage', () => {
|
||||||
|
it('shows loader when loading=true', () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: true }));
|
||||||
|
render(<OverviewPage />);
|
||||||
|
expect(screen.getByTestId('loader')).toHaveTextContent('Loading Intel GPU data...');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Plugin Not Detected" when not loading, no plugin installed, no nodes', () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(
|
||||||
|
makeContext({ loading: false, pluginInstalled: false, gpuNodes: [] })
|
||||||
|
);
|
||||||
|
render(<OverviewPage />);
|
||||||
|
expect(screen.getByText('Plugin Not Detected')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error content when error is set', () => {
|
||||||
|
const errorMsg = 'something went wrong';
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(
|
||||||
|
makeContext({ loading: false, error: errorMsg, pluginInstalled: true })
|
||||||
|
);
|
||||||
|
render(<OverviewPage />);
|
||||||
|
expect(screen.getByText(errorMsg)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Intel GPU — Overview" heading when gpuNodes present and pluginInstalled', () => {
|
||||||
|
const node: IntelGpuNode = {
|
||||||
|
metadata: {
|
||||||
|
name: 'gpu-node-1',
|
||||||
|
labels: { 'intel.feature.node.kubernetes.io/gpu': 'true' },
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
capacity: { 'gpu.intel.com/i915': '1' },
|
||||||
|
allocatable: { 'gpu.intel.com/i915': '1' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(
|
||||||
|
makeContext({ loading: false, pluginInstalled: true, gpuNodes: [node] })
|
||||||
|
);
|
||||||
|
render(<OverviewPage />);
|
||||||
|
expect(screen.getByText('Intel GPU — Overview')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls refresh() when refresh button is clicked', () => {
|
||||||
|
const refresh = vi.fn();
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(
|
||||||
|
makeContext({ loading: false, pluginInstalled: true, refresh })
|
||||||
|
);
|
||||||
|
render(<OverviewPage />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /refresh intel gpu data/i }));
|
||||||
|
expect(refresh).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows CRD notice when crdAvailable=false and pluginInstalled=true', () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(
|
||||||
|
makeContext({ loading: false, pluginInstalled: true, crdAvailable: false })
|
||||||
|
);
|
||||||
|
render(<OverviewPage />);
|
||||||
|
expect(screen.getByText('Notice')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/GpuDevicePlugin CRD not found/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Device Plugin Status" table when crdAvailable=true and devicePlugins present', () => {
|
||||||
|
const plugin: GpuDevicePlugin = {
|
||||||
|
kind: 'GpuDevicePlugin',
|
||||||
|
metadata: { name: 'my-plugin', uid: 'uid-1' },
|
||||||
|
spec: { enableMonitoring: true, sharedDevNum: 2 },
|
||||||
|
status: { desiredNumberScheduled: 1, numberReady: 1 },
|
||||||
|
};
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(
|
||||||
|
makeContext({
|
||||||
|
loading: false,
|
||||||
|
pluginInstalled: true,
|
||||||
|
crdAvailable: true,
|
||||||
|
devicePlugins: [plugin],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<OverviewPage />);
|
||||||
|
expect(screen.getByText('Device Plugin Status')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('my-plugin')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Plugin Daemon Pods" table when pluginPods present', () => {
|
||||||
|
const pod: IntelGpuPod = {
|
||||||
|
metadata: { name: 'plugin-pod-1', namespace: 'kube-system', uid: 'uid-pp-1' },
|
||||||
|
spec: { nodeName: 'node-1' },
|
||||||
|
status: { phase: 'Running' },
|
||||||
|
};
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(
|
||||||
|
makeContext({ loading: false, pluginInstalled: true, pluginPods: [pod] })
|
||||||
|
);
|
||||||
|
render(<OverviewPage />);
|
||||||
|
expect(screen.getByText('Plugin Daemon Pods')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('plugin-pod-1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Active GPU Pods" table when running GPU pods exist', () => {
|
||||||
|
const pod: IntelGpuPod = {
|
||||||
|
metadata: { name: 'workload-pod-1', namespace: 'default', uid: 'uid-wp-1' },
|
||||||
|
spec: {
|
||||||
|
nodeName: 'gpu-node-1',
|
||||||
|
containers: [
|
||||||
|
{
|
||||||
|
name: 'main',
|
||||||
|
resources: { requests: { 'gpu.intel.com/i915': '1' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
status: { phase: 'Running' },
|
||||||
|
};
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(
|
||||||
|
makeContext({ loading: false, pluginInstalled: true, gpuPods: [pod] })
|
||||||
|
);
|
||||||
|
render(<OverviewPage />);
|
||||||
|
expect(screen.getByText('Active GPU Pods')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('workload-pod-1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import PodDetailSection from './PodDetailSection';
|
||||||
|
|
||||||
|
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
||||||
|
SectionBox: ({ title, children }: { title: string; children?: React.ReactNode }) => (
|
||||||
|
<section>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
),
|
||||||
|
NameValueTable: ({
|
||||||
|
rows,
|
||||||
|
}: {
|
||||||
|
rows: Array<{ name: React.ReactNode; value: React.ReactNode }>;
|
||||||
|
}) => (
|
||||||
|
<dl>
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<dt>{r.name}</dt>
|
||||||
|
<dd>{r.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
),
|
||||||
|
StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => (
|
||||||
|
<span data-status={status}>{children}</span>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// PodDetailSection does NOT use the context — no need to mock IntelGpuDataContext
|
||||||
|
|
||||||
|
// A non-GPU pod (no gpu.intel.com resources)
|
||||||
|
const nonGpuPodRaw = {
|
||||||
|
kind: 'Pod',
|
||||||
|
metadata: { name: 'plain-pod', namespace: 'default' },
|
||||||
|
spec: {
|
||||||
|
containers: [{ name: 'main', resources: { requests: { cpu: '100m', memory: '128Mi' } } }],
|
||||||
|
},
|
||||||
|
status: { phase: 'Running' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// A GPU-requesting pod
|
||||||
|
const gpuPodRaw = {
|
||||||
|
kind: 'Pod',
|
||||||
|
metadata: { name: 'gpu-workload', namespace: 'default' },
|
||||||
|
spec: {
|
||||||
|
nodeName: 'gpu-node-1',
|
||||||
|
containers: [
|
||||||
|
{
|
||||||
|
name: 'trainer',
|
||||||
|
resources: {
|
||||||
|
requests: { 'gpu.intel.com/i915': '1', cpu: '2' },
|
||||||
|
limits: { 'gpu.intel.com/i915': '1', cpu: '2' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
status: { phase: 'Running' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// A pod with limits only (no requests)
|
||||||
|
const gpuPodLimitsOnly = {
|
||||||
|
kind: 'Pod',
|
||||||
|
metadata: { name: 'limits-only-pod', namespace: 'default' },
|
||||||
|
spec: {
|
||||||
|
containers: [
|
||||||
|
{
|
||||||
|
name: 'app',
|
||||||
|
resources: {
|
||||||
|
limits: { 'gpu.intel.com/i915': '1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
status: { phase: 'Pending' },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('PodDetailSection', () => {
|
||||||
|
it('renders nothing for a non-GPU pod', () => {
|
||||||
|
const { container } = render(<PodDetailSection resource={nonGpuPodRaw} />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing for a non-GPU pod passed via jsonData', () => {
|
||||||
|
const { container } = render(<PodDetailSection resource={{ jsonData: nonGpuPodRaw }} />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "Intel GPU Resources" section for a GPU-requesting pod via jsonData', () => {
|
||||||
|
render(<PodDetailSection resource={{ jsonData: gpuPodRaw }} />);
|
||||||
|
expect(screen.getByText('Intel GPU Resources')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "Intel GPU Resources" section for a GPU-requesting pod provided directly', () => {
|
||||||
|
render(<PodDetailSection resource={gpuPodRaw} />);
|
||||||
|
expect(screen.getByText('Intel GPU Resources')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows container GPU resource request rows', () => {
|
||||||
|
render(<PodDetailSection resource={gpuPodRaw} />);
|
||||||
|
// Row label: "{containerName} → {resourceName} request"
|
||||||
|
expect(screen.getByText('trainer → GPU (i915) request')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows phase status label for Running phase', () => {
|
||||||
|
render(<PodDetailSection resource={gpuPodRaw} />);
|
||||||
|
const statusEl = screen.getByText('Running');
|
||||||
|
expect(statusEl).toHaveAttribute('data-status', 'success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows phase status label for Pending phase', () => {
|
||||||
|
render(<PodDetailSection resource={gpuPodLimitsOnly} />);
|
||||||
|
const statusEl = screen.getByText('Pending');
|
||||||
|
expect(statusEl).toHaveAttribute('data-status', 'warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still renders when a container has limits only and no requests', () => {
|
||||||
|
render(<PodDetailSection resource={gpuPodLimitsOnly} />);
|
||||||
|
expect(screen.getByText('Intel GPU Resources')).toBeInTheDocument();
|
||||||
|
// limits-only pod: the request row should show '—' since requests key is absent
|
||||||
|
expect(screen.getByText('app → GPU (i915) request')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows scheduled node name', () => {
|
||||||
|
render(<PodDetailSection resource={gpuPodRaw} />);
|
||||||
|
expect(screen.getByText('gpu-node-1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows GPU container count', () => {
|
||||||
|
render(<PodDetailSection resource={gpuPodRaw} />);
|
||||||
|
const label = screen.getByText('GPU Containers');
|
||||||
|
expect(label).toBeInTheDocument();
|
||||||
|
// The value '1' is rendered in the sibling <dd>; verify via parent row
|
||||||
|
expect(label.closest('div')).toHaveTextContent('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { IntelGpuContextValue, useIntelGpuContext } from '../api/IntelGpuDataContext';
|
||||||
|
import { IntelGpuPod } from '../api/k8s';
|
||||||
|
import PodsPage from './PodsPage';
|
||||||
|
|
||||||
|
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
||||||
|
Loader: ({ title }: { title: string }) => <div data-testid="loader">{title}</div>,
|
||||||
|
SectionBox: ({ title, children }: { title: string; children?: React.ReactNode }) => (
|
||||||
|
<section>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
),
|
||||||
|
SectionHeader: ({ title }: { title: string }) => <h1>{title}</h1>,
|
||||||
|
NameValueTable: ({
|
||||||
|
rows,
|
||||||
|
}: {
|
||||||
|
rows: Array<{ name: React.ReactNode; value: React.ReactNode }>;
|
||||||
|
}) => (
|
||||||
|
<dl>
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<dt>{r.name}</dt>
|
||||||
|
<dd>{r.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
),
|
||||||
|
SimpleTable: ({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
columns: Array<{ label: string; getter: (item: unknown) => React.ReactNode }>;
|
||||||
|
data: unknown[];
|
||||||
|
}) => (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map(c => (
|
||||||
|
<th key={c.label}>{c.label}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((item, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
{columns.map(c => (
|
||||||
|
<td key={c.label}>{c.getter(item)}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
),
|
||||||
|
StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => (
|
||||||
|
<span data-status={status}>{children}</span>
|
||||||
|
),
|
||||||
|
PercentageBar: () => <div data-testid="percentage-bar" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../api/IntelGpuDataContext', () => ({
|
||||||
|
useIntelGpuContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function makeContext(overrides: Partial<IntelGpuContextValue> = {}): IntelGpuContextValue {
|
||||||
|
return {
|
||||||
|
devicePlugins: [],
|
||||||
|
pluginInstalled: false,
|
||||||
|
gpuNodes: [],
|
||||||
|
gpuPods: [],
|
||||||
|
pluginPods: [],
|
||||||
|
crdAvailable: false,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refresh: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRunningPod(name: string): IntelGpuPod {
|
||||||
|
return {
|
||||||
|
metadata: { name, namespace: 'default', uid: `uid-${name}` },
|
||||||
|
spec: {
|
||||||
|
nodeName: 'gpu-node-1',
|
||||||
|
containers: [
|
||||||
|
{
|
||||||
|
name: 'main',
|
||||||
|
resources: { requests: { 'gpu.intel.com/i915': '1' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
status: { phase: 'Running' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePendingPod(name: string): IntelGpuPod {
|
||||||
|
return {
|
||||||
|
metadata: { name, namespace: 'default', uid: `uid-${name}` },
|
||||||
|
spec: {
|
||||||
|
containers: [
|
||||||
|
{
|
||||||
|
name: 'main',
|
||||||
|
resources: { requests: { 'gpu.intel.com/i915': '1' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
phase: 'Pending',
|
||||||
|
containerStatuses: [
|
||||||
|
{
|
||||||
|
name: 'main',
|
||||||
|
ready: false,
|
||||||
|
restartCount: 0,
|
||||||
|
state: { waiting: { reason: 'Unschedulable' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PodsPage', () => {
|
||||||
|
it('shows loader when loading=true', () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: true }));
|
||||||
|
render(<PodsPage />);
|
||||||
|
expect(screen.getByTestId('loader')).toHaveTextContent('Loading GPU pod data...');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "No GPU Pods Found" when gpuPods is empty', () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false, gpuPods: [] }));
|
||||||
|
render(<PodsPage />);
|
||||||
|
expect(screen.getByText('No GPU Pods Found')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows summary section with total count when pods present', () => {
|
||||||
|
const pods = [makeRunningPod('pod-1'), makeRunningPod('pod-2')];
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false, gpuPods: pods }));
|
||||||
|
render(<PodsPage />);
|
||||||
|
expect(screen.getByText('Summary')).toBeInTheDocument();
|
||||||
|
// 'Total GPU Pods' label is present; '2' appears in multiple places (row value + status label)
|
||||||
|
expect(screen.getByText('Total GPU Pods')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('2').length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Attention: Pending GPU Pods" section when pending pods exist', () => {
|
||||||
|
const pods = [makePendingPod('pending-pod-1')];
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false, gpuPods: pods }));
|
||||||
|
render(<PodsPage />);
|
||||||
|
expect(screen.getByText('Attention: Pending GPU Pods')).toBeInTheDocument();
|
||||||
|
// Pod name appears in both the main "All GPU Pods" table and the pending attention table
|
||||||
|
expect(screen.getAllByText('pending-pod-1').length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error section when error is set', () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(
|
||||||
|
makeContext({ loading: false, error: 'pod list failed', gpuPods: [] })
|
||||||
|
);
|
||||||
|
render(<PodsPage />);
|
||||||
|
expect(screen.getByText('pod list failed')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "All GPU Pods" table with pod name when pods present', () => {
|
||||||
|
const pods = [makeRunningPod('my-workload')];
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false, gpuPods: pods }));
|
||||||
|
render(<PodsPage />);
|
||||||
|
expect(screen.getByText('All GPU Pods')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('my-workload')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,5 +6,8 @@ export default defineConfig({
|
|||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
setupFiles: ['./vitest.setup.ts'],
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
exclude: ['e2e/**', 'node_modules/**'],
|
exclude: ['e2e/**', 'node_modules/**'],
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user