From 7b7c56f7b1a5283bb2c9910c6b6fa7320aace51a Mon Sep 17 00:00:00 2001 From: utkarsh patrikar Date: Fri, 12 Jun 2026 01:27:11 +0530 Subject: [PATCH 01/10] feat(watch): add localized Docker and Kubernetes system usage stats to live dashboard --- src/__tests__/stats.test.ts | 276 ++++++++++++++++++++++++++++++++++++ src/docker/containers.ts | 97 +++++++++++++ src/kubernetes/pods.ts | 187 +++++++++++++++++++++++- src/ui/WatchDashboard.tsx | 40 +++++- 4 files changed, 597 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/stats.test.ts diff --git a/src/__tests__/stats.test.ts b/src/__tests__/stats.test.ts new file mode 100644 index 0000000..48ad479 --- /dev/null +++ b/src/__tests__/stats.test.ts @@ -0,0 +1,276 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + parseK8sCpuQuantity, + parseK8sMemoryQuantity, + formatK8sBytes, + getK8sClusterStats +} from '../kubernetes/pods'; +import { + getDockerSystemStats, + formatDockerBytes +} from '../docker/containers'; + +const mockContainersList = vi.fn(); +const mockContainerStats = vi.fn(); +const mockDockerInfo = vi.fn(); + +vi.mock('../docker/client', () => { + return { + getDockerClient: () => ({ + listContainers: mockContainersList, + getContainer: (id: string) => ({ + stats: mockContainerStats, + }), + info: mockDockerInfo, + }), + }; +}); + +const mockListClusterCustomObject = vi.fn(); +const mockListPodForAllNamespaces = vi.fn(); + +vi.mock('../kubernetes/client', () => { + return { + getK8sApi: () => ({ + listPodForAllNamespaces: mockListPodForAllNamespaces, + }), + getCustomObjectsApi: () => ({ + listClusterCustomObject: mockListClusterCustomObject, + }), + }; +}); + +describe('Kubernetes resource quantity parsing', () => { + describe('parseK8sCpuQuantity', () => { + it('parses millicores', () => { + expect(parseK8sCpuQuantity('450m')).toBe(450); + expect(parseK8sCpuQuantity('100m')).toBe(100); + }); + + it('parses cores', () => { + expect(parseK8sCpuQuantity('2')).toBe(2000); + expect(parseK8sCpuQuantity('0.5')).toBe(500); + }); + + it('parses nanocores', () => { + expect(parseK8sCpuQuantity('125000000n')).toBe(125); + }); + + it('parses microcores', () => { + expect(parseK8sCpuQuantity('125000u')).toBe(125); + }); + + it('handles numeric input', () => { + expect(parseK8sCpuQuantity(0.5)).toBe(500); + }); + + it('handles empty or malformed inputs', () => { + expect(parseK8sCpuQuantity('')).toBe(0); + expect(parseK8sCpuQuantity('abc')).toBe(0); + }); + }); + + describe('parseK8sMemoryQuantity', () => { + it('parses binary power values', () => { + expect(parseK8sMemoryQuantity('2Ki')).toBe(2 * 1024); + expect(parseK8sMemoryQuantity('5Mi')).toBe(5 * 1024 * 1024); + expect(parseK8sMemoryQuantity('1Gi')).toBe(1024 * 1024 * 1024); + }); + + it('parses decimal power values', () => { + expect(parseK8sMemoryQuantity('2k')).toBe(2000); + expect(parseK8sMemoryQuantity('5M')).toBe(5000000); + expect(parseK8sMemoryQuantity('1G')).toBe(1000000000); + }); + + it('handles numeric input', () => { + expect(parseK8sMemoryQuantity(1024)).toBe(1024); + }); + + it('handles empty or malformed inputs', () => { + expect(parseK8sMemoryQuantity('')).toBe(0); + expect(parseK8sMemoryQuantity('abc')).toBe(0); + }); + }); +}); + +describe('Byte formatting', () => { + it('formats K8s bytes with binary suffixes', () => { + expect(formatK8sBytes(0)).toBe('0B'); + expect(formatK8sBytes(512)).toBe('512B'); + expect(formatK8sBytes(1024)).toBe('1KiB'); + expect(formatK8sBytes(1.5 * 1024 * 1024)).toBe('1.5MiB'); + expect(formatK8sBytes(2 * 1024 * 1024 * 1024)).toBe('2GiB'); + }); + + it('formats Docker bytes with decimal suffixes', () => { + expect(formatDockerBytes(0)).toBe('0B'); + expect(formatDockerBytes(500)).toBe('500B'); + expect(formatDockerBytes(1000)).toBe('1KB'); + expect(formatDockerBytes(1.5 * 1000 * 1000)).toBe('1.5MB'); + expect(formatDockerBytes(2 * 1000 * 1000 * 1000)).toBe('2GB'); + }); +}); + +describe('getDockerSystemStats', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calculates aggregate stats for running containers', async () => { + mockContainersList.mockResolvedValueOnce([ + { Id: 'cont1', Names: ['/c1'] }, + { Id: 'cont2', Names: ['/c2'] }, + ]); + + mockContainerStats + // First container stats + .mockResolvedValueOnce({ + cpu_stats: { + cpu_usage: { total_usage: 100 }, + system_cpu_usage: 1000, + online_cpus: 2, + }, + precpu_stats: { + cpu_usage: { total_usage: 50 }, + system_cpu_usage: 500, + }, + memory_stats: { + usage: 1000000, + stats: { cache: 100000 }, + limit: 8000000, + }, + }) + // Second container stats + .mockResolvedValueOnce({ + cpu_stats: { + cpu_usage: { total_usage: 200 }, + system_cpu_usage: 1000, + online_cpus: 1, + }, + precpu_stats: { + cpu_usage: { total_usage: 150 }, + system_cpu_usage: 500, + }, + memory_stats: { + usage: 2000000, + stats: { inactive_file: 200000 }, + limit: 8000000, + }, + }); + + const stats = await getDockerSystemStats(); + expect(stats).not.toBeNull(); + // Cont 1 CPU: ((100-50) / (1000-500)) * 2 * 100 = 20% + // Cont 2 CPU: ((200-150) / (1000-500)) * 1 * 100 = 10% + // Total CPU: 30% + expect(stats?.cpu).toBeCloseTo(30); + // Cont 1 Memory: 1000000 - 100000 = 900000 + // Cont 2 Memory: 2000000 - 200000 = 1800000 + // Total Memory: 2700000 + expect(stats?.memoryUsage).toBe(2700000); + expect(stats?.memoryLimit).toBe(8000000); + }); + + it('gracefully degrades to null when listing containers fails', async () => { + mockContainersList.mockRejectedValueOnce(new Error('Docker socket not available')); + const stats = await getDockerSystemStats(); + expect(stats).toBeNull(); + }); +}); + +describe('getK8sClusterStats', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('fetches metrics-server node-level stats', async () => { + mockListClusterCustomObject.mockResolvedValueOnce({ + items: [ + { usage: { cpu: '200m', memory: '1Gi' } }, + { usage: { cpu: '300m', memory: '2Gi' } }, + ], + }); + + const stats = await getK8sClusterStats(); + expect(stats.cpu).toBe('500m'); + expect(stats.memory).toBe('3GiB'); + expect(stats.source).toBe('metrics-server'); + }); + + it('falls back to metrics-server pod-level stats when nodes query fails/empty', async () => { + mockListClusterCustomObject + .mockRejectedValueOnce(new Error('Node metrics 404')) // node metrics fails + .mockResolvedValueOnce({ + items: [ + { + containers: [ + { usage: { cpu: '100m', memory: '256Mi' } }, + ], + }, + { + containers: [ + { usage: { cpu: '150m', memory: '512Mi' } }, + ], + }, + ], + }); + + const stats = await getK8sClusterStats(); + expect(stats.cpu).toBe('250m'); + expect(stats.memory).toBe('768MiB'); + expect(stats.source).toBe('metrics-server'); + }); + + it('falls back to native pod resource requests sum when metrics-server is missing', async () => { + mockListClusterCustomObject + .mockRejectedValueOnce(new Error('No metrics endpoint')) // node metrics fails + .mockRejectedValueOnce(new Error('No metrics endpoint')); // pod metrics fails + + mockListPodForAllNamespaces.mockResolvedValueOnce({ + items: [ + { + status: { phase: 'Running' }, + spec: { + containers: [ + { resources: { requests: { cpu: '200m', memory: '512Mi' } } }, + ], + }, + }, + { + status: { phase: 'Pending' }, + spec: { + containers: [ + { resources: { requests: { cpu: '100m', memory: '256Mi' } } }, + ], + }, + }, + { + status: { phase: 'Failed' }, // should be ignored + spec: { + containers: [ + { resources: { requests: { cpu: '1000m', memory: '4Gi' } } }, + ], + }, + }, + ], + }); + + const stats = await getK8sClusterStats(); + expect(stats.cpu).toBe('300m'); + expect(stats.memory).toBe('768MiB'); + expect(stats.source).toBe('requests'); + }); + + it('gracefully degrades to N/A when all methods fail', async () => { + mockListClusterCustomObject + .mockRejectedValue(new Error('Unreachable')); + mockListPodForAllNamespaces + .mockRejectedValue(new Error('Unreachable')); + + const stats = await getK8sClusterStats(); + expect(stats.cpu).toBe('N/A'); + expect(stats.memory).toBe('N/A'); + expect(stats.source).toBe('N/A'); + }); +}); diff --git a/src/docker/containers.ts b/src/docker/containers.ts index 3a403d7..ceba597 100644 --- a/src/docker/containers.ts +++ b/src/docker/containers.ts @@ -57,3 +57,100 @@ export const getRunningContainers = async (options?: { forceAlert?: boolean }): throw error; } }; + +export interface DockerSystemStats { + cpu: number; + memoryUsage: number; + memoryLimit: number; +} + +export const formatDockerBytes = (bytes: number): string => { + if (bytes <= 0) return '0B'; + const decimalK = 1000; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(decimalK)), sizes.length - 1); + const num = bytes / Math.pow(decimalK, i); + return `${parseFloat(num.toFixed(1))}${sizes[i]}`; +}; + +export const getDockerSystemStats = async (): Promise => { + const docker = getDockerClient(); + try { + const containers = await docker.listContainers({ filters: { status: ['running'] } }); + if (containers.length === 0) { + let limit = 0; + try { + const info = await docker.info(); + limit = info.MemTotal || 0; + } catch {} + return { cpu: 0, memoryUsage: 0, memoryLimit: limit }; + } + + let totalCpu = 0; + let totalMemory = 0; + let maxLimit = 0; + + const statsPromises = containers.map(async (c) => { + try { + const container = docker.getContainer(c.Id); + const stats = await container.stats({ stream: false }); + + // Calculate CPU usage percentage + const cpuStats = stats.cpu_stats; + const precpuStats = stats.precpu_stats; + let cpuPercent = 0; + + if (cpuStats && precpuStats && cpuStats.cpu_usage && precpuStats.cpu_usage) { + const cpuDelta = (cpuStats.cpu_usage.total_usage || 0) - (precpuStats.cpu_usage.total_usage || 0); + const systemCpuDelta = (cpuStats.system_cpu_usage || 0) - (precpuStats.system_cpu_usage || 0); + const onlineCpus = cpuStats.online_cpus || cpuStats.cpu_usage.percpu_usage?.length || 1; + + if (systemCpuDelta > 0 && cpuDelta > 0) { + cpuPercent = (cpuDelta / systemCpuDelta) * onlineCpus * 100; + } + } + + // Calculate Memory usage + let memoryUsage = 0; + let limit = 0; + if (stats.memory_stats) { + memoryUsage = stats.memory_stats.usage || 0; + const cache = stats.memory_stats.stats?.cache || stats.memory_stats.stats?.inactive_file || 0; + if (memoryUsage > cache) { + memoryUsage -= cache; + } + limit = stats.memory_stats.limit || 0; + } + + return { cpuPercent, memoryUsage, limit }; + } catch (err) { + return { cpuPercent: 0, memoryUsage: 0, limit: 0 }; + } + }); + + const results = await Promise.all(statsPromises); + for (const res of results) { + totalCpu += res.cpuPercent; + totalMemory += res.memoryUsage; + if (res.limit > maxLimit) { + maxLimit = res.limit; + } + } + + if (maxLimit === 0) { + try { + const info = await docker.info(); + maxLimit = info.MemTotal || 0; + } catch {} + } + + return { + cpu: totalCpu, + memoryUsage: totalMemory, + memoryLimit: maxLimit, + }; + } catch (error) { + return null; + } +}; + diff --git a/src/kubernetes/pods.ts b/src/kubernetes/pods.ts index 41b885a..531c7be 100644 --- a/src/kubernetes/pods.ts +++ b/src/kubernetes/pods.ts @@ -1,4 +1,4 @@ -import { getK8sApi } from './client'; +import { getK8sApi, getCustomObjectsApi } from './client'; import type * as k8s from '@kubernetes/client-node'; import { triggerAlert } from '../monitor/alerts'; import { logger } from '../utils/logger'; @@ -60,3 +60,188 @@ export const getRunningPods = async (options?: { forceAlert?: boolean }): Promis throw error; } }; + +export interface K8sClusterStats { + cpu: string; + memory: string; + source: 'metrics-server' | 'requests' | 'N/A'; +} + +export function parseK8sCpuQuantity(q: string | number): number { + if (typeof q === 'number') return q * 1000; + if (!q) return 0; + const match = q.trim().match(/^([0-9.]+)([a-zA-Z]*)$/); + if (!match) return 0; + const val = parseFloat(match[1]); + const suffix = match[2]; + switch (suffix) { + case 'n': + return val / 1000000; // nanocores to millicores + case 'u': + return val / 1000; // microcores to millicores + case 'm': + return val; + case '': + return val * 1000; // cores to millicores + default: + return val * 1000; + } +} + +export function parseK8sMemoryQuantity(q: string | number): number { + if (typeof q === 'number') return q; + if (!q) return 0; + const match = q.trim().match(/^([0-9.]+)([a-zA-Z]*)$/); + if (!match) return 0; + const val = parseFloat(match[1]); + const suffix = match[2]; + + const binaryPower: Record = { + Ki: 1024, + Mi: 1024 * 1024, + Gi: 1024 * 1024 * 1024, + Ti: 1024 * 1024 * 1024 * 1024, + Pi: 1024 * 1024 * 1024 * 1024 * 1024, + }; + + const decimalPower: Record = { + k: 1000, + M: 1000 * 1000, + G: 1000 * 1000 * 1000, + T: 1000 * 1000 * 1000 * 1000, + P: 1000 * 1000 * 1000 * 1000 * 1000, + }; + + if (binaryPower[suffix]) { + return val * binaryPower[suffix]; + } + if (decimalPower[suffix]) { + return val * decimalPower[suffix]; + } + return val; +} + +export const formatK8sBytes = (bytes: number): string => { + if (bytes <= 0) return '0B'; + const k = 1024; + const sizes = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1); + const num = bytes / Math.pow(k, i); + return `${parseFloat(num.toFixed(1))}${sizes[i]}`; +}; + +export const getK8sClusterStats = async (): Promise => { + // 1. Try to fetch metrics-server node stats + try { + const customApi = getCustomObjectsApi(); + const res = await customApi.listClusterCustomObject({ + group: 'metrics.k8s.io', + version: 'v1beta1', + plural: 'nodes', + }); + + const items = (res as any)?.items || []; + if (items.length > 0) { + let totalCpuMillicores = 0; + let totalMemoryBytes = 0; + + for (const item of items) { + const cpuQty = item.usage?.cpu || '0'; + const memQty = item.usage?.memory || '0'; + totalCpuMillicores += parseK8sCpuQuantity(cpuQty); + totalMemoryBytes += parseK8sMemoryQuantity(memQty); + } + + return { + cpu: `${Math.round(totalCpuMillicores)}m`, + memory: formatK8sBytes(totalMemoryBytes), + source: 'metrics-server', + }; + } + } catch (error) { + // metrics-server might not be available or permissions missing + } + + // 2. Try to fallback to Pod Metrics + try { + const customApi = getCustomObjectsApi(); + const res = await customApi.listClusterCustomObject({ + group: 'metrics.k8s.io', + version: 'v1beta1', + plural: 'pods', + }); + + const items = (res as any)?.items || []; + if (items.length > 0) { + let totalCpuMillicores = 0; + let totalMemoryBytes = 0; + + for (const item of items) { + const containers = item.containers || []; + for (const container of containers) { + const cpuQty = container.usage?.cpu || '0'; + const memQty = container.usage?.memory || '0'; + totalCpuMillicores += parseK8sCpuQuantity(cpuQty); + totalMemoryBytes += parseK8sMemoryQuantity(memQty); + } + } + + return { + cpu: `${Math.round(totalCpuMillicores)}m`, + memory: formatK8sBytes(totalMemoryBytes), + source: 'metrics-server', + }; + } + } catch (error) { + // Pod metrics also not available + } + + // 3. Fallback to native cluster stats (sum of requests of all pods) + try { + const api = getK8sApi(); + const res = await api.listPodForAllNamespaces(); + const pods = res.items || []; + + let totalCpuMillicores = 0; + let totalMemoryBytes = 0; + + for (const pod of pods) { + const phase = pod.status?.phase; + if (phase !== 'Running' && phase !== 'Pending') { + continue; + } + + const containers = pod.spec?.containers || []; + const initContainers = pod.spec?.initContainers || []; + + for (const container of [...containers, ...initContainers]) { + const requests = container.resources?.requests; + if (requests) { + if (requests.cpu) { + totalCpuMillicores += parseK8sCpuQuantity(requests.cpu); + } + if (requests.memory) { + totalMemoryBytes += parseK8sMemoryQuantity(requests.memory); + } + } + } + } + + if (totalCpuMillicores > 0 || totalMemoryBytes > 0) { + return { + cpu: `${Math.round(totalCpuMillicores)}m`, + memory: formatK8sBytes(totalMemoryBytes), + source: 'requests', + }; + } + } catch (error) { + // Native API query failed + } + + return { + cpu: 'N/A', + memory: 'N/A', + source: 'N/A', + }; +}; + diff --git a/src/ui/WatchDashboard.tsx b/src/ui/WatchDashboard.tsx index faf666f..86608ef 100644 --- a/src/ui/WatchDashboard.tsx +++ b/src/ui/WatchDashboard.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { Box, Text } from 'ink'; -import { getRunningPods, PodData } from '../kubernetes/pods'; -import { getRunningContainers, ContainerData } from '../docker/containers'; +import { getRunningPods, PodData, getK8sClusterStats, K8sClusterStats } from '../kubernetes/pods'; +import { getRunningContainers, ContainerData, getDockerSystemStats, DockerSystemStats, formatDockerBytes } from '../docker/containers'; const StatusBadge = ({ status, type }: { status: string, type: 'pod' | 'container' }) => { const isRunning = type === 'pod' ? status === 'Running' : status === 'running'; @@ -20,6 +20,8 @@ const StatusBadge = ({ status, type }: { status: string, type: 'pod' | 'containe export const WatchDashboard = () => { const [pods, setPods] = useState([]); const [containers, setContainers] = useState([]); + const [k8sStats, setK8sStats] = useState(null); + const [dockerStats, setDockerStats] = useState(null); const [error, setError] = useState<{ type: string; message: string } | null>(null); useEffect(() => { @@ -43,9 +45,29 @@ export const WatchDashboard = () => { } }; + const fetchK8sStats = async () => { + try { + const stats = await getK8sClusterStats(); + setK8sStats(stats); + } catch (err) { + setK8sStats(null); + } + }; + + const fetchDockerStats = async () => { + try { + const stats = await getDockerSystemStats(); + setDockerStats(stats); + } catch (err) { + setDockerStats(null); + } + }; + const fetchData = () => { fetchPods(); fetchContainers(); + fetchK8sStats(); + fetchDockerStats(); }; fetchData(); @@ -75,6 +97,13 @@ export const WatchDashboard = () => { Kubernetes Pods ({pods.length}) + + + {k8sStats + ? `${k8sStats.source === 'requests' ? 'k8s Requests' : 'k8s Stats'}: CPU: ${k8sStats.cpu} | Mem: ${k8sStats.memory}` + : 'k8s Stats: CPU: N/A | Mem: N/A'} + + {pods.length === 0 && !error?.type.includes('k8s') ? ( No pods found. ) : ( @@ -91,6 +120,13 @@ export const WatchDashboard = () => { Docker Containers ({containers.length}) + + + {dockerStats + ? `Docker Stats: CPU: ${dockerStats.cpu.toFixed(1)}% | Mem: ${formatDockerBytes(dockerStats.memoryUsage)} / ${formatDockerBytes(dockerStats.memoryLimit)}` + : 'Docker Stats: CPU: N/A | Mem: N/A'} + + {containers.length === 0 && !error?.type.includes('docker') ? ( No containers found. ) : ( From 3a8ad706a8c6f6ef43e451ac95e5447033d01b7d Mon Sep 17 00:00:00 2001 From: utkarsh patrikar Date: Fri, 12 Jun 2026 01:29:25 +0530 Subject: [PATCH 02/10] refactor(watch): simplify nesting, lower cyclomatic complexity, and add full JSDoc blocks to comply with coding_style.md --- src/docker/containers.ts | 126 +++++++++++-------- src/kubernetes/pods.ts | 255 ++++++++++++++++++++++++++------------- 2 files changed, 249 insertions(+), 132 deletions(-) diff --git a/src/docker/containers.ts b/src/docker/containers.ts index ceba597..93891fd 100644 --- a/src/docker/containers.ts +++ b/src/docker/containers.ts @@ -59,11 +59,19 @@ export const getRunningContainers = async (options?: { forceAlert?: boolean }): }; export interface DockerSystemStats { + /** The aggregated CPU usage percentage. */ cpu: number; + /** The aggregated memory usage in bytes. */ memoryUsage: number; + /** The maximum memory limit across running containers or total host memory. */ memoryLimit: number; } +/** + * Formats a byte number to decimal-scaled string (e.g. "1.4GB"). + * @param bytes The raw number of bytes. + * @returns Decimal-formatted bytes string. + */ export const formatDockerBytes = (bytes: number): string => { if (bytes <= 0) return '0B'; const decimalK = 1000; @@ -73,62 +81,81 @@ export const formatDockerBytes = (bytes: number): string => { return `${parseFloat(num.toFixed(1))}${sizes[i]}`; }; +/** + * Calculates a single container's CPU usage percentage based on its stats and pre-stats. + * @param cpuStats The current CPU stats of the container. + * @param precpuStats The previous CPU stats of the container. + * @returns The calculated CPU usage percentage. + */ +const calculateCpuPercent = (cpuStats: any, precpuStats: any): number => { + if (!cpuStats || !precpuStats || !cpuStats.cpu_usage || !precpuStats.cpu_usage) { + return 0; + } + const cpuDelta = (cpuStats.cpu_usage.total_usage || 0) - (precpuStats.cpu_usage.total_usage || 0); + const systemCpuDelta = (cpuStats.system_cpu_usage || 0) - (precpuStats.system_cpu_usage || 0); + const onlineCpus = cpuStats.online_cpus || cpuStats.cpu_usage.percpu_usage?.length || 1; + + if (systemCpuDelta > 0 && cpuDelta > 0) { + return (cpuDelta / systemCpuDelta) * onlineCpus * 100; + } + return 0; +}; + +/** + * Calculates a single container's Memory usage in bytes subtracting cache memory. + * @param memoryStats The memory stats of the container. + * @returns Calculated memory usage in bytes. + */ +const calculateMemoryUsage = (memoryStats: any): number => { + if (!memoryStats) return 0; + let usage = memoryStats.usage || 0; + const cache = memoryStats.stats?.cache || memoryStats.stats?.inactive_file || 0; + if (usage > cache) { + usage -= cache; + } + return usage; +}; + +/** + * Fetches stats for a single container. + * @param docker The Dockerode client. + * @param containerId The ID of the container. + * @returns Stats containing CPU percentage, memory usage, and memory limit. + */ +const fetchContainerStats = async (docker: any, containerId: string) => { + try { + const container = docker.getContainer(containerId); + const stats = await container.stats({ stream: false }); + return { + cpuPercent: calculateCpuPercent(stats.cpu_stats, stats.precpu_stats), + memoryUsage: calculateMemoryUsage(stats.memory_stats), + limit: stats.memory_stats?.limit || 0, + }; + } catch { + return { cpuPercent: 0, memoryUsage: 0, limit: 0 }; + } +}; + +/** + * Aggregates CPU and Memory resource usage stats for all running Docker containers. + * @returns The aggregated Docker stats, or null if the client fails to connect. + */ export const getDockerSystemStats = async (): Promise => { const docker = getDockerClient(); try { const containers = await docker.listContainers({ filters: { status: ['running'] } }); if (containers.length === 0) { - let limit = 0; - try { - const info = await docker.info(); - limit = info.MemTotal || 0; - } catch {} - return { cpu: 0, memoryUsage: 0, memoryLimit: limit }; + const info = await docker.info().catch(() => ({ MemTotal: 0 })); + return { cpu: 0, memoryUsage: 0, memoryLimit: info.MemTotal || 0 }; } + const statsPromises = containers.map(c => fetchContainerStats(docker, c.Id)); + const results = await Promise.all(statsPromises); + let totalCpu = 0; let totalMemory = 0; let maxLimit = 0; - - const statsPromises = containers.map(async (c) => { - try { - const container = docker.getContainer(c.Id); - const stats = await container.stats({ stream: false }); - - // Calculate CPU usage percentage - const cpuStats = stats.cpu_stats; - const precpuStats = stats.precpu_stats; - let cpuPercent = 0; - - if (cpuStats && precpuStats && cpuStats.cpu_usage && precpuStats.cpu_usage) { - const cpuDelta = (cpuStats.cpu_usage.total_usage || 0) - (precpuStats.cpu_usage.total_usage || 0); - const systemCpuDelta = (cpuStats.system_cpu_usage || 0) - (precpuStats.system_cpu_usage || 0); - const onlineCpus = cpuStats.online_cpus || cpuStats.cpu_usage.percpu_usage?.length || 1; - - if (systemCpuDelta > 0 && cpuDelta > 0) { - cpuPercent = (cpuDelta / systemCpuDelta) * onlineCpus * 100; - } - } - - // Calculate Memory usage - let memoryUsage = 0; - let limit = 0; - if (stats.memory_stats) { - memoryUsage = stats.memory_stats.usage || 0; - const cache = stats.memory_stats.stats?.cache || stats.memory_stats.stats?.inactive_file || 0; - if (memoryUsage > cache) { - memoryUsage -= cache; - } - limit = stats.memory_stats.limit || 0; - } - - return { cpuPercent, memoryUsage, limit }; - } catch (err) { - return { cpuPercent: 0, memoryUsage: 0, limit: 0 }; - } - }); - - const results = await Promise.all(statsPromises); + for (const res of results) { totalCpu += res.cpuPercent; totalMemory += res.memoryUsage; @@ -138,10 +165,8 @@ export const getDockerSystemStats = async (): Promise } if (maxLimit === 0) { - try { - const info = await docker.info(); - maxLimit = info.MemTotal || 0; - } catch {} + const info = await docker.info().catch(() => ({ MemTotal: 0 })); + maxLimit = info.MemTotal || 0; } return { @@ -154,3 +179,4 @@ export const getDockerSystemStats = async (): Promise } }; + diff --git a/src/kubernetes/pods.ts b/src/kubernetes/pods.ts index 531c7be..05837d2 100644 --- a/src/kubernetes/pods.ts +++ b/src/kubernetes/pods.ts @@ -62,11 +62,19 @@ export const getRunningPods = async (options?: { forceAlert?: boolean }): Promis }; export interface K8sClusterStats { + /** The aggregated CPU usage/requests representation. */ cpu: string; + /** The aggregated memory usage/requests representation. */ memory: string; + /** The source of the statistics. */ source: 'metrics-server' | 'requests' | 'N/A'; } +/** + * Parses a Kubernetes CPU quantity (e.g., "450m", "2", "125000000n") into millicores. + * @param q The CPU quantity representation as a string or number. + * @returns The parsed CPU value in millicores. + */ export function parseK8sCpuQuantity(q: string | number): number { if (typeof q === 'number') return q * 1000; if (!q) return 0; @@ -88,6 +96,11 @@ export function parseK8sCpuQuantity(q: string | number): number { } } +/** + * Parses a Kubernetes memory quantity (e.g., "1Gi", "512Mi", "2k") into bytes. + * @param q The memory quantity representation as a string or number. + * @returns The parsed memory value in bytes. + */ export function parseK8sMemoryQuantity(q: string | number): number { if (typeof q === 'number') return q; if (!q) return 0; @@ -121,6 +134,11 @@ export function parseK8sMemoryQuantity(q: string | number): number { return val; } +/** + * Formats a byte number to binary-scaled string (e.g. "2GiB"). + * @param bytes The raw number of bytes. + * @returns Binary-formatted bytes string. + */ export const formatK8sBytes = (bytes: number): string => { if (bytes <= 0) return '0B'; const k = 1024; @@ -130,8 +148,103 @@ export const formatK8sBytes = (bytes: number): string => { return `${parseFloat(num.toFixed(1))}${sizes[i]}`; }; -export const getK8sClusterStats = async (): Promise => { - // 1. Try to fetch metrics-server node stats +/** + * Sums up CPU and Memory usage from metrics-server node items. + * @param items Node metrics items from custom API. + * @returns Aggregated CPU in millicores and memory in bytes. + */ +const sumNodeMetrics = (items: any[]) => { + let cpu = 0; + let memory = 0; + for (const item of items) { + cpu += parseK8sCpuQuantity(item.usage?.cpu || '0'); + memory += parseK8sMemoryQuantity(item.usage?.memory || '0'); + } + return { cpu, memory }; +}; + +/** + * Sums up CPU and Memory usage from metrics-server pod items. + * @param items Pod metrics items from custom API. + * @returns Aggregated CPU in millicores and memory in bytes. + */ +const sumPodMetrics = (items: any[]) => { + let cpu = 0; + let memory = 0; + for (const item of items) { + const containers = item.containers || []; + for (const container of containers) { + cpu += parseK8sCpuQuantity(container.usage?.cpu || '0'); + memory += parseK8sMemoryQuantity(container.usage?.memory || '0'); + } + } + return { cpu, memory }; +}; + +/** + * Checks if a pod's status phase should be considered for native requests fallback. + * @param phase The pod phase status string. + * @returns True if the pod is active, false otherwise. + */ +const isActivePodPhase = (phase?: string): boolean => { + return phase === 'Running' || phase === 'Pending'; +}; + +/** + * Sums container requests for a single container. + * @param container The Kubernetes container spec. + * @returns Object with parsed CPU and Memory requests. + */ +const getContainerRequests = (container: any) => { + const reqs = container.resources?.requests; + return { + cpu: reqs?.cpu ? parseK8sCpuQuantity(reqs.cpu) : 0, + memory: reqs?.memory ? parseK8sMemoryQuantity(reqs.memory) : 0, + }; +}; + +/** + * Sums up requests for a single pod's containers. + * @param pod The Kubernetes pod object. + * @returns Object with parsed CPU and Memory requests. + */ +const sumPodRequests = (pod: any) => { + let cpu = 0; + let memory = 0; + const containers = pod.spec?.containers || []; + const initContainers = pod.spec?.initContainers || []; + + for (const container of [...containers, ...initContainers]) { + const req = getContainerRequests(container); + cpu += req.cpu; + memory += req.memory; + } + return { cpu, memory }; +}; + +/** + * Sums up resource requests from a list of pods. + * @param pods List of pods to aggregate requests from. + * @returns Aggregated CPU requests in millicores and memory requests in bytes. + */ +const sumAllPodsRequests = (pods: any[]) => { + let cpu = 0; + let memory = 0; + for (const pod of pods) { + if (isActivePodPhase(pod.status?.phase)) { + const podReq = sumPodRequests(pod); + cpu += podReq.cpu; + memory += podReq.memory; + } + } + return { cpu, memory }; +}; + +/** + * Fetches metrics-server node stats. + * @returns Node metrics, or null if it fails or returns no nodes. + */ +const fetchNodeMetrics = async () => { try { const customApi = getCustomObjectsApi(); const res = await customApi.listClusterCustomObject({ @@ -139,30 +252,18 @@ export const getK8sClusterStats = async (): Promise => { version: 'v1beta1', plural: 'nodes', }); - const items = (res as any)?.items || []; - if (items.length > 0) { - let totalCpuMillicores = 0; - let totalMemoryBytes = 0; - - for (const item of items) { - const cpuQty = item.usage?.cpu || '0'; - const memQty = item.usage?.memory || '0'; - totalCpuMillicores += parseK8sCpuQuantity(cpuQty); - totalMemoryBytes += parseK8sMemoryQuantity(memQty); - } - - return { - cpu: `${Math.round(totalCpuMillicores)}m`, - memory: formatK8sBytes(totalMemoryBytes), - source: 'metrics-server', - }; - } - } catch (error) { - // metrics-server might not be available or permissions missing + return items.length > 0 ? sumNodeMetrics(items) : null; + } catch { + return null; } +}; - // 2. Try to fallback to Pod Metrics +/** + * Fetches metrics-server pod stats. + * @returns Pod metrics, or null if it fails or returns no pods. + */ +const fetchPodMetrics = async () => { try { const customApi = getCustomObjectsApi(); const res = await customApi.listClusterCustomObject({ @@ -170,72 +271,61 @@ export const getK8sClusterStats = async (): Promise => { version: 'v1beta1', plural: 'pods', }); - const items = (res as any)?.items || []; - if (items.length > 0) { - let totalCpuMillicores = 0; - let totalMemoryBytes = 0; - - for (const item of items) { - const containers = item.containers || []; - for (const container of containers) { - const cpuQty = container.usage?.cpu || '0'; - const memQty = container.usage?.memory || '0'; - totalCpuMillicores += parseK8sCpuQuantity(cpuQty); - totalMemoryBytes += parseK8sMemoryQuantity(memQty); - } - } - - return { - cpu: `${Math.round(totalCpuMillicores)}m`, - memory: formatK8sBytes(totalMemoryBytes), - source: 'metrics-server', - }; - } - } catch (error) { - // Pod metrics also not available + return items.length > 0 ? sumPodMetrics(items) : null; + } catch { + return null; } +}; - // 3. Fallback to native cluster stats (sum of requests of all pods) +/** + * Fetches native pod resource requests sum. + * @returns Native pod resource requests, or null if it fails or has no metrics. + */ +const fetchNativeRequests = async () => { try { const api = getK8sApi(); const res = await api.listPodForAllNamespaces(); const pods = res.items || []; - - let totalCpuMillicores = 0; - let totalMemoryBytes = 0; - - for (const pod of pods) { - const phase = pod.status?.phase; - if (phase !== 'Running' && phase !== 'Pending') { - continue; - } - - const containers = pod.spec?.containers || []; - const initContainers = pod.spec?.initContainers || []; - - for (const container of [...containers, ...initContainers]) { - const requests = container.resources?.requests; - if (requests) { - if (requests.cpu) { - totalCpuMillicores += parseK8sCpuQuantity(requests.cpu); - } - if (requests.memory) { - totalMemoryBytes += parseK8sMemoryQuantity(requests.memory); - } - } - } - } - - if (totalCpuMillicores > 0 || totalMemoryBytes > 0) { - return { - cpu: `${Math.round(totalCpuMillicores)}m`, - memory: formatK8sBytes(totalMemoryBytes), - source: 'requests', - }; - } - } catch (error) { - // Native API query failed + const stats = sumAllPodsRequests(pods); + return (stats.cpu > 0 || stats.memory > 0) ? stats : null; + } catch { + return null; + } +}; + +/** + * Fetches the aggregated CPU and Memory statistics for the Kubernetes cluster, + * checking metrics-server node metrics, metrics-server pod metrics, or falling + * back to native pod requests sum. + * @returns Kubernetes cluster stats. + */ +export const getK8sClusterStats = async (): Promise => { + const nodeStats = await fetchNodeMetrics(); + if (nodeStats) { + return { + cpu: `${Math.round(nodeStats.cpu)}m`, + memory: formatK8sBytes(nodeStats.memory), + source: 'metrics-server', + }; + } + + const podStats = await fetchPodMetrics(); + if (podStats) { + return { + cpu: `${Math.round(podStats.cpu)}m`, + memory: formatK8sBytes(podStats.memory), + source: 'metrics-server', + }; + } + + const nativeRequests = await fetchNativeRequests(); + if (nativeRequests) { + return { + cpu: `${Math.round(nativeRequests.cpu)}m`, + memory: formatK8sBytes(nativeRequests.memory), + source: 'requests', + }; } return { @@ -245,3 +335,4 @@ export const getK8sClusterStats = async (): Promise => { }; }; + From 3319b292347b9a44ae3f35a86746be43f5ad6583 Mon Sep 17 00:00:00 2001 From: utkarsh patrikar Date: Fri, 12 Jun 2026 01:38:48 +0530 Subject: [PATCH 03/10] test(watch): add watch-dashboard.test.tsx and expand stats.test.ts to hit >95% patch coverage --- src/__tests__/stats.test.ts | 160 ++++++++++++++++++++++++- src/__tests__/watch-dashboard.test.tsx | 140 ++++++++++++++++++++++ 2 files changed, 298 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/watch-dashboard.test.tsx diff --git a/src/__tests__/stats.test.ts b/src/__tests__/stats.test.ts index 48ad479..c83a907 100644 --- a/src/__tests__/stats.test.ts +++ b/src/__tests__/stats.test.ts @@ -3,11 +3,13 @@ import { parseK8sCpuQuantity, parseK8sMemoryQuantity, formatK8sBytes, - getK8sClusterStats + getK8sClusterStats, + getRunningPods } from '../kubernetes/pods'; import { getDockerSystemStats, - formatDockerBytes + formatDockerBytes, + getRunningContainers } from '../docker/containers'; const mockContainersList = vi.fn(); @@ -40,6 +42,11 @@ vi.mock('../kubernetes/client', () => { }; }); +const mockTriggerAlert = vi.fn(); +vi.mock('../monitor/alerts', () => ({ + triggerAlert: (...args: any[]) => mockTriggerAlert(...args), +})); + describe('Kubernetes resource quantity parsing', () => { describe('parseK8sCpuQuantity', () => { it('parses millicores', () => { @@ -68,6 +75,10 @@ describe('Kubernetes resource quantity parsing', () => { expect(parseK8sCpuQuantity('')).toBe(0); expect(parseK8sCpuQuantity('abc')).toBe(0); }); + + it('hits the default case for other suffixes', () => { + expect(parseK8sCpuQuantity('2x')).toBe(2000); + }); }); describe('parseK8sMemoryQuantity', () => { @@ -90,6 +101,7 @@ describe('Kubernetes resource quantity parsing', () => { it('handles empty or malformed inputs', () => { expect(parseK8sMemoryQuantity('')).toBe(0); expect(parseK8sMemoryQuantity('abc')).toBe(0); + expect(parseK8sMemoryQuantity('512')).toBe(512); }); }); }); @@ -115,6 +127,7 @@ describe('Byte formatting', () => { describe('getDockerSystemStats', () => { beforeEach(() => { vi.clearAllMocks(); + mockDockerInfo.mockResolvedValue({ MemTotal: 8000000000 }); }); it('calculates aggregate stats for running containers', async () => { @@ -172,6 +185,55 @@ describe('getDockerSystemStats', () => { expect(stats?.memoryLimit).toBe(8000000); }); + it('falls back to docker.info() memory limit if limit from stats is 0', async () => { + mockContainersList.mockResolvedValueOnce([ + { Id: 'cont1', Names: ['/c1'] }, + ]); + mockContainerStats.mockResolvedValueOnce({ + cpu_stats: {}, + precpu_stats: {}, + memory_stats: { + usage: 1000, + limit: 0, + }, + }); + mockDockerInfo.mockResolvedValueOnce({ + MemTotal: 16000000000, + }); + + const stats = await getDockerSystemStats(); + expect(stats?.memoryLimit).toBe(16000000000); + }); + + it('handles docker.info() failure gracefully', async () => { + mockContainersList.mockResolvedValueOnce([ + { Id: 'cont1', Names: ['/c1'] }, + ]); + mockContainerStats.mockResolvedValueOnce({ + cpu_stats: {}, + precpu_stats: {}, + memory_stats: { + usage: 1000, + limit: 0, + }, + }); + mockDockerInfo.mockRejectedValueOnce(new Error('Info failed')); + + const stats = await getDockerSystemStats(); + expect(stats?.memoryLimit).toBe(0); + }); + + it('handles container.stats failure gracefully for individual containers', async () => { + mockContainersList.mockResolvedValueOnce([ + { Id: 'cont1', Names: ['/c1'] }, + ]); + mockContainerStats.mockRejectedValueOnce(new Error('Stats failed')); + + const stats = await getDockerSystemStats(); + expect(stats?.cpu).toBe(0); + expect(stats?.memoryUsage).toBe(0); + }); + it('gracefully degrades to null when listing containers fails', async () => { mockContainersList.mockRejectedValueOnce(new Error('Docker socket not available')); const stats = await getDockerSystemStats(); @@ -274,3 +336,97 @@ describe('getK8sClusterStats', () => { expect(stats.source).toBe('N/A'); }); }); + +describe('getRunningContainers actual implementation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('correctly maps containers and triggers alerts for restart/failure states', async () => { + mockContainersList.mockResolvedValueOnce([ + { Id: 'c123456789012', Names: ['/my-container'], Image: 'nginx', State: 'running', Status: 'Up' }, + { Id: 'c234567890123', Names: ['/my-restarting'], Image: 'nginx', State: 'restarting', Status: 'Restarting (1)' }, + { Id: 'c345678901234', Names: ['/my-failed'], Image: 'nginx', State: 'exited', Status: 'Exited (137)' }, + { Id: 'c456789012345', Names: ['/my-clean-exit'], Image: 'nginx', State: 'exited', Status: 'Exited (0)' }, + ]); + + const containers = await getRunningContainers(); + expect(containers).toHaveLength(4); + expect(containers[0].id).toBe('c12345678901'); + expect(containers[0].name).toBe('my-container'); + + expect(mockTriggerAlert).toHaveBeenCalledWith(expect.objectContaining({ + id: 'container:my-restarting:restarting', + severity: 'warning' + }), expect.any(Object)); + + expect(mockTriggerAlert).toHaveBeenCalledWith(expect.objectContaining({ + id: 'container:my-failed:failure', + severity: 'critical' + }), expect.any(Object)); + }); + + it('propagates errors when listContainers rejects', async () => { + mockContainersList.mockRejectedValueOnce(new Error('List failed')); + await expect(getRunningContainers()).rejects.toThrow('List failed'); + }); +}); + +describe('getRunningPods actual implementation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('correctly maps pods and triggers alerts for failures/waiting conditions', async () => { + mockListPodForAllNamespaces.mockResolvedValueOnce({ + items: [ + { + metadata: { name: 'pod-running', namespace: 'default' }, + status: { + phase: 'Running', + containerStatuses: [ + { restartCount: 2, state: { running: {} } }, + ], + }, + spec: { nodeName: 'node-1' }, + }, + { + metadata: { name: 'pod-failed-phase', namespace: 'default' }, + status: { + phase: 'Failed', + containerStatuses: [], + }, + }, + { + metadata: { name: 'pod-crashloop', namespace: 'default' }, + status: { + phase: 'Pending', + containerStatuses: [ + { name: 'c1', restartCount: 5, state: { waiting: { reason: 'CrashLoopBackOff' } } }, + ], + }, + }, + ], + }); + + const pods = await getRunningPods(); + expect(pods).toHaveLength(3); + expect(pods[0].name).toBe('pod-running'); + expect(pods[0].restarts).toBe(2); + + expect(mockTriggerAlert).toHaveBeenCalledWith(expect.objectContaining({ + id: 'pod:pod-failed-phase:failure', + message: expect.stringContaining('FAILED') + }), expect.any(Object)); + + expect(mockTriggerAlert).toHaveBeenCalledWith(expect.objectContaining({ + id: 'pod:pod-crashloop:failure', + message: expect.stringContaining('CrashLoopBackOff') + }), expect.any(Object)); + }); + + it('propagates errors when listPodForAllNamespaces rejects', async () => { + mockListPodForAllNamespaces.mockRejectedValueOnce(new Error('K8s down')); + await expect(getRunningPods()).rejects.toThrow('K8s down'); + }); +}); diff --git a/src/__tests__/watch-dashboard.test.tsx b/src/__tests__/watch-dashboard.test.tsx new file mode 100644 index 0000000..8aeb7e0 --- /dev/null +++ b/src/__tests__/watch-dashboard.test.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render } from 'ink'; +import { Writable } from 'node:stream'; +import { Console } from 'node:console'; +import { WatchDashboard } from '../ui/WatchDashboard'; +import * as podsMod from '../kubernetes/pods'; +import * as containersMod from '../docker/containers'; + +if (!console.Console) { + console.Console = Console; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +class MockWritable extends Writable { + frames: string[] = []; + write(chunk: any, encoding: any, callback: any) { + this.frames.push(chunk.toString()); + if (typeof callback === 'function') { + callback(); + } + return true; + } +} + +const getRunningPodsSpy = vi.spyOn(podsMod, 'getRunningPods'); +const getK8sClusterStatsSpy = vi.spyOn(podsMod, 'getK8sClusterStats'); +const getRunningContainersSpy = vi.spyOn(containersMod, 'getRunningContainers'); +const getDockerSystemStatsSpy = vi.spyOn(containersMod, 'getDockerSystemStats'); + +describe('WatchDashboard', () => { + let mockStdout: MockWritable; + + beforeEach(() => { + mockStdout = new MockWritable(); + vi.clearAllMocks(); + }); + + it('renders loading states and then displays pods and containers', async () => { + getRunningPodsSpy.mockResolvedValue([ + { name: 'pod-1', namespace: 'default', status: 'Running', restarts: 0, node: 'node-1' }, + ]); + getK8sClusterStatsSpy.mockResolvedValue({ + cpu: '250m', + memory: '512MiB', + source: 'metrics-server', + }); + getRunningContainersSpy.mockResolvedValue([ + { id: 'c1', name: 'container-1', image: 'nginx', state: 'running', status: 'Up 2 hours' }, + ]); + getDockerSystemStatsSpy.mockResolvedValue({ + cpu: 15.5, + memoryUsage: 2000000000, + memoryLimit: 8000000000, + }); + + const { unmount } = render(, { stdout: mockStdout }); + + // Wait for async useEffect fetch calls to complete + await sleep(200); + + const output = mockStdout.frames.join('\n'); + expect(output).toContain('KDM Live Dashboard'); + expect(output).toContain('pod-1'); + expect(output).toContain('container-1'); + expect(output).toContain('k8s Stats: CPU: 250m | Mem: 512MiB'); + expect(output).toContain('Docker Stats: CPU: 15.5%'); + expect(output).toContain('Mem: 2GB'); + expect(output).toContain('8GB'); + + unmount(); + }); + + it('handles K8s API errors gracefully', async () => { + getRunningPodsSpy.mockRejectedValue(new Error('K8s error')); + getK8sClusterStatsSpy.mockResolvedValue({ cpu: 'N/A', memory: 'N/A', source: 'N/A' }); + getRunningContainersSpy.mockResolvedValue([]); + getDockerSystemStatsSpy.mockResolvedValue(null); + + const { unmount } = render(, { stdout: mockStdout }); + + await sleep(200); + + const output = mockStdout.frames.join('\n'); + expect(output).toContain('ERROR: K8S - K8s error'); + expect(output).toContain('k8s Stats: CPU: N/A | Mem: N/A'); + + unmount(); + }); + + it('handles Docker API errors gracefully', async () => { + getRunningPodsSpy.mockResolvedValue([]); + getK8sClusterStatsSpy.mockResolvedValue({ cpu: 'N/A', memory: 'N/A', source: 'N/A' }); + getRunningContainersSpy.mockRejectedValue(new Error('Docker error')); + getDockerSystemStatsSpy.mockResolvedValue(null); + + const { unmount } = render(, { stdout: mockStdout }); + + await sleep(200); + + const output = mockStdout.frames.join('\n'); + expect(output).toContain('ERROR: DOCKER - Docker error'); + expect(output).toContain('Docker Stats: CPU: N/A | Mem: N/A'); + + unmount(); + }); + + it('handles terminal resize events dynamically', async () => { + getRunningPodsSpy.mockResolvedValue([]); + getK8sClusterStatsSpy.mockResolvedValue({ cpu: 'N/A', memory: 'N/A', source: 'N/A' }); + getRunningContainersSpy.mockResolvedValue([]); + getDockerSystemStatsSpy.mockResolvedValue(null); + + const originalColumns = process.stdout.columns; + + Object.defineProperty(process.stdout, 'columns', { + value: 40, + writable: true, + configurable: true, + }); + + const { unmount } = render(, { stdout: mockStdout }); + + process.stdout.emit('resize'); + + await sleep(200); + + const output = mockStdout.frames.join('\n'); + expect(output).toBeDefined(); + + Object.defineProperty(process.stdout, 'columns', { + value: originalColumns, + writable: true, + configurable: true, + }); + + unmount(); + }); +}); From 5cef2cb42c66ff503e0f8e52e3063743625f792e Mon Sep 17 00:00:00 2001 From: utkarsh patrikar Date: Fri, 12 Jun 2026 01:47:57 +0530 Subject: [PATCH 04/10] test(watch): stabilize watch-dashboard tests in CI and resolve type issues --- src/__tests__/stats.test.ts | 91 ++++++++++++++++++++++++++ src/__tests__/watch-dashboard.test.tsx | 40 ++++++----- src/server/server.ts | 6 +- 3 files changed, 118 insertions(+), 19 deletions(-) diff --git a/src/__tests__/stats.test.ts b/src/__tests__/stats.test.ts index c83a907..15d8ece 100644 --- a/src/__tests__/stats.test.ts +++ b/src/__tests__/stats.test.ts @@ -234,6 +234,54 @@ describe('getDockerSystemStats', () => { expect(stats?.memoryUsage).toBe(0); }); + it('returns zero stats and gets memory limit from docker.info when no containers are running', async () => { + mockContainersList.mockResolvedValueOnce([]); + mockDockerInfo.mockResolvedValueOnce({ MemTotal: 12000000000 }); + + const stats = await getDockerSystemStats(); + expect(stats).toEqual({ + cpu: 0, + memoryUsage: 0, + memoryLimit: 12000000000, + }); + }); + + it('handles docker.info failure when no containers are running', async () => { + mockContainersList.mockResolvedValueOnce([]); + mockDockerInfo.mockRejectedValueOnce(new Error('info fail')); + + const stats = await getDockerSystemStats(); + expect(stats).toEqual({ + cpu: 0, + memoryUsage: 0, + memoryLimit: 0, + }); + }); + + it('returns 0 CPU percent when cpuDelta or systemCpuDelta is zero or negative', async () => { + mockContainersList.mockResolvedValueOnce([ + { Id: 'cont1', Names: ['/c1'] }, + ]); + mockContainerStats.mockResolvedValueOnce({ + cpu_stats: { + cpu_usage: { total_usage: 100 }, + system_cpu_usage: 1000, + online_cpus: 2, + }, + precpu_stats: { + cpu_usage: { total_usage: 100 }, + system_cpu_usage: 1000, + }, + memory_stats: { + usage: 1000000, + limit: 8000000, + }, + }); + + const stats = await getDockerSystemStats(); + expect(stats?.cpu).toBe(0); + }); + it('gracefully degrades to null when listing containers fails', async () => { mockContainersList.mockRejectedValueOnce(new Error('Docker socket not available')); const stats = await getDockerSystemStats(); @@ -335,6 +383,49 @@ describe('getK8sClusterStats', () => { expect(stats.memory).toBe('N/A'); expect(stats.source).toBe('N/A'); }); + + it('handles empty results from metrics-server and empty pods/requests list', async () => { + mockListClusterCustomObject + .mockResolvedValueOnce({ items: [] }) + .mockResolvedValueOnce({ items: [] }); + + mockListPodForAllNamespaces.mockResolvedValueOnce({ + items: [ + { + status: { phase: 'Running' }, + spec: { + containers: [ + { resources: {} }, + { resources: { requests: {} } }, + ], + }, + }, + { + status: { phase: 'Running' }, + spec: { + // containers undefined + }, + }, + { + status: { phase: 'Running' }, + // spec undefined + }, + { + status: { phase: 'Running' }, + spec: { + containers: [ + { resources: { requests: { cpu: '100m', memory: '256Mi' } } } + ] + } + } + ], + }); + + const stats = await getK8sClusterStats(); + expect(stats.cpu).toBe('100m'); + expect(stats.memory).toBe('256MiB'); + expect(stats.source).toBe('requests'); + }); }); describe('getRunningContainers actual implementation', () => { diff --git a/src/__tests__/watch-dashboard.test.tsx b/src/__tests__/watch-dashboard.test.tsx index 8aeb7e0..0ffc2fb 100644 --- a/src/__tests__/watch-dashboard.test.tsx +++ b/src/__tests__/watch-dashboard.test.tsx @@ -13,14 +13,23 @@ if (!console.Console) { const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const waitForFrameToContain = async (mockStdout: MockWritable, substring: string, timeout = 2000) => { + const start = Date.now(); + while (Date.now() - start < timeout) { + const output = mockStdout.frames.join('\n'); + if (output.includes(substring)) { + return; + } + await sleep(20); + } + throw new Error(`Timed out waiting for "${substring}" to appear in stdout. Output was:\n${mockStdout.frames.join('\n')}`); +}; + class MockWritable extends Writable { frames: string[] = []; - write(chunk: any, encoding: any, callback: any) { + _write(chunk: any, encoding: string, callback: (error?: Error | null) => void) { this.frames.push(chunk.toString()); - if (typeof callback === 'function') { - callback(); - } - return true; + callback(); } } @@ -55,10 +64,9 @@ describe('WatchDashboard', () => { memoryLimit: 8000000000, }); - const { unmount } = render(, { stdout: mockStdout }); + const { unmount } = render(, { stdout: mockStdout as any }); - // Wait for async useEffect fetch calls to complete - await sleep(200); + await waitForFrameToContain(mockStdout, 'pod-1'); const output = mockStdout.frames.join('\n'); expect(output).toContain('KDM Live Dashboard'); @@ -74,16 +82,15 @@ describe('WatchDashboard', () => { it('handles K8s API errors gracefully', async () => { getRunningPodsSpy.mockRejectedValue(new Error('K8s error')); - getK8sClusterStatsSpy.mockResolvedValue({ cpu: 'N/A', memory: 'N/A', source: 'N/A' }); + getK8sClusterStatsSpy.mockRejectedValue(new Error('K8s stats error')); getRunningContainersSpy.mockResolvedValue([]); getDockerSystemStatsSpy.mockResolvedValue(null); - const { unmount } = render(, { stdout: mockStdout }); + const { unmount } = render(, { stdout: mockStdout as any }); - await sleep(200); + await waitForFrameToContain(mockStdout, 'ERROR: K8S - K8s error'); const output = mockStdout.frames.join('\n'); - expect(output).toContain('ERROR: K8S - K8s error'); expect(output).toContain('k8s Stats: CPU: N/A | Mem: N/A'); unmount(); @@ -93,14 +100,13 @@ describe('WatchDashboard', () => { getRunningPodsSpy.mockResolvedValue([]); getK8sClusterStatsSpy.mockResolvedValue({ cpu: 'N/A', memory: 'N/A', source: 'N/A' }); getRunningContainersSpy.mockRejectedValue(new Error('Docker error')); - getDockerSystemStatsSpy.mockResolvedValue(null); + getDockerSystemStatsSpy.mockRejectedValue(new Error('Docker stats error')); - const { unmount } = render(, { stdout: mockStdout }); + const { unmount } = render(, { stdout: mockStdout as any }); - await sleep(200); + await waitForFrameToContain(mockStdout, 'ERROR: DOCKER - Docker error'); const output = mockStdout.frames.join('\n'); - expect(output).toContain('ERROR: DOCKER - Docker error'); expect(output).toContain('Docker Stats: CPU: N/A | Mem: N/A'); unmount(); @@ -120,7 +126,7 @@ describe('WatchDashboard', () => { configurable: true, }); - const { unmount } = render(, { stdout: mockStdout }); + const { unmount } = render(, { stdout: mockStdout as any }); process.stdout.emit('resize'); diff --git a/src/server/server.ts b/src/server/server.ts index 80f96c0..8c48161 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -127,11 +127,13 @@ export const routeRequest = (req: any, res: any, options: ServerOptions): void = }; if (method === 'GET' && url in getHandlers) { - return getHandlers[url](res); + getHandlers[url](res); + return; } if (method === 'POST' && url === '/analyze') { - return handleAnalyze(req, res, options); + handleAnalyze(req, res, options); + return; } sendJson(res, 404, { error: 'Not found' }); From 814a5ccf1bb0a7fdb3ef597e105a51c1a0225682 Mon Sep 17 00:00:00 2001 From: utkarsh patrikar Date: Fri, 12 Jun 2026 01:50:44 +0530 Subject: [PATCH 05/10] refactor(watch): refine memory limit logic, parse quantity unrecognized suffix fallbacks, and exclude initContainers --- src/__tests__/stats.test.ts | 8 ++++++-- src/docker/containers.ts | 28 +++++++++++++++------------- src/kubernetes/pods.ts | 15 +++++++++------ 3 files changed, 30 insertions(+), 21 deletions(-) diff --git a/src/__tests__/stats.test.ts b/src/__tests__/stats.test.ts index 15d8ece..477a138 100644 --- a/src/__tests__/stats.test.ts +++ b/src/__tests__/stats.test.ts @@ -77,7 +77,7 @@ describe('Kubernetes resource quantity parsing', () => { }); it('hits the default case for other suffixes', () => { - expect(parseK8sCpuQuantity('2x')).toBe(2000); + expect(parseK8sCpuQuantity('2x')).toBe(0); }); }); @@ -102,6 +102,7 @@ describe('Kubernetes resource quantity parsing', () => { expect(parseK8sMemoryQuantity('')).toBe(0); expect(parseK8sMemoryQuantity('abc')).toBe(0); expect(parseK8sMemoryQuantity('512')).toBe(512); + expect(parseK8sMemoryQuantity('2x')).toBe(0); }); }); }); @@ -182,7 +183,7 @@ describe('getDockerSystemStats', () => { // Cont 2 Memory: 2000000 - 200000 = 1800000 // Total Memory: 2700000 expect(stats?.memoryUsage).toBe(2700000); - expect(stats?.memoryLimit).toBe(8000000); + expect(stats?.memoryLimit).toBe(8000000000); }); it('falls back to docker.info() memory limit if limit from stats is 0', async () => { @@ -353,6 +354,9 @@ describe('getK8sClusterStats', () => { containers: [ { resources: { requests: { cpu: '100m', memory: '256Mi' } } }, ], + initContainers: [ + { resources: { requests: { cpu: '500m', memory: '1Gi' } } }, + ], }, }, { diff --git a/src/docker/containers.ts b/src/docker/containers.ts index 93891fd..a483d1d 100644 --- a/src/docker/containers.ts +++ b/src/docker/containers.ts @@ -131,7 +131,14 @@ const fetchContainerStats = async (docker: any, containerId: string) => { memoryUsage: calculateMemoryUsage(stats.memory_stats), limit: stats.memory_stats?.limit || 0, }; - } catch { + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + const msg = `Failed to fetch stats for container ${containerId}: ${errMsg}`; + if (typeof (logger as any).debug === 'function') { + (logger as any).debug(msg); + } else { + console.debug(msg); + } return { cpuPercent: 0, memoryUsage: 0, limit: 0 }; } }; @@ -144,9 +151,13 @@ export const getDockerSystemStats = async (): Promise const docker = getDockerClient(); try { const containers = await docker.listContainers({ filters: { status: ['running'] } }); + + // Always call docker.info() to obtain MemTotal and use that as memoryLimit + const info = await docker.info().catch(() => ({ MemTotal: 0 })); + const memoryLimit = info.MemTotal || 0; + if (containers.length === 0) { - const info = await docker.info().catch(() => ({ MemTotal: 0 })); - return { cpu: 0, memoryUsage: 0, memoryLimit: info.MemTotal || 0 }; + return { cpu: 0, memoryUsage: 0, memoryLimit }; } const statsPromises = containers.map(c => fetchContainerStats(docker, c.Id)); @@ -154,25 +165,16 @@ export const getDockerSystemStats = async (): Promise let totalCpu = 0; let totalMemory = 0; - let maxLimit = 0; for (const res of results) { totalCpu += res.cpuPercent; totalMemory += res.memoryUsage; - if (res.limit > maxLimit) { - maxLimit = res.limit; - } - } - - if (maxLimit === 0) { - const info = await docker.info().catch(() => ({ MemTotal: 0 })); - maxLimit = info.MemTotal || 0; } return { cpu: totalCpu, memoryUsage: totalMemory, - memoryLimit: maxLimit, + memoryLimit, }; } catch (error) { return null; diff --git a/src/kubernetes/pods.ts b/src/kubernetes/pods.ts index 05837d2..dcbb1da 100644 --- a/src/kubernetes/pods.ts +++ b/src/kubernetes/pods.ts @@ -92,7 +92,7 @@ export function parseK8sCpuQuantity(q: string | number): number { case '': return val * 1000; // cores to millicores default: - return val * 1000; + return 0; } } @@ -108,6 +108,10 @@ export function parseK8sMemoryQuantity(q: string | number): number { if (!match) return 0; const val = parseFloat(match[1]); const suffix = match[2]; + + if (suffix === '') { + return val; + } const binaryPower: Record = { Ki: 1024, @@ -125,13 +129,13 @@ export function parseK8sMemoryQuantity(q: string | number): number { P: 1000 * 1000 * 1000 * 1000 * 1000, }; - if (binaryPower[suffix]) { + if (suffix in binaryPower) { return val * binaryPower[suffix]; } - if (decimalPower[suffix]) { + if (suffix in decimalPower) { return val * decimalPower[suffix]; } - return val; + return 0; } /** @@ -212,9 +216,8 @@ const sumPodRequests = (pod: any) => { let cpu = 0; let memory = 0; const containers = pod.spec?.containers || []; - const initContainers = pod.spec?.initContainers || []; - for (const container of [...containers, ...initContainers]) { + for (const container of containers) { const req = getContainerRequests(container); cpu += req.cpu; memory += req.memory; From c819963a7ed759daf25c5cf66b93e7609909768d Mon Sep 17 00:00:00 2001 From: utkarsh patrikar Date: Fri, 12 Jun 2026 01:52:46 +0530 Subject: [PATCH 06/10] test(watch): refactor duplicated test functions using parameterized it.each to satisfy CodeScene quality gates --- src/__tests__/stats.test.ts | 134 ++++++++++++------------- src/__tests__/watch-dashboard.test.tsx | 50 ++++----- 2 files changed, 92 insertions(+), 92 deletions(-) diff --git a/src/__tests__/stats.test.ts b/src/__tests__/stats.test.ts index 477a138..62f913d 100644 --- a/src/__tests__/stats.test.ts +++ b/src/__tests__/stats.test.ts @@ -186,77 +186,73 @@ describe('getDockerSystemStats', () => { expect(stats?.memoryLimit).toBe(8000000000); }); - it('falls back to docker.info() memory limit if limit from stats is 0', async () => { - mockContainersList.mockResolvedValueOnce([ - { Id: 'cont1', Names: ['/c1'] }, - ]); - mockContainerStats.mockResolvedValueOnce({ - cpu_stats: {}, - precpu_stats: {}, - memory_stats: { - usage: 1000, - limit: 0, - }, - }); - mockDockerInfo.mockResolvedValueOnce({ - MemTotal: 16000000000, - }); - - const stats = await getDockerSystemStats(); - expect(stats?.memoryLimit).toBe(16000000000); - }); - - it('handles docker.info() failure gracefully', async () => { - mockContainersList.mockResolvedValueOnce([ - { Id: 'cont1', Names: ['/c1'] }, - ]); - mockContainerStats.mockResolvedValueOnce({ - cpu_stats: {}, - precpu_stats: {}, - memory_stats: { - usage: 1000, - limit: 0, - }, - }); - mockDockerInfo.mockRejectedValueOnce(new Error('Info failed')); + it.each([ + { + description: 'falls back to docker.info() memory limit if limit from stats is 0', + containers: [{ Id: 'cont1', Names: ['/c1'] }], + statsMock: { resolve: { cpu_stats: {}, precpu_stats: {}, memory_stats: { usage: 1000, limit: 0 } } }, + infoMock: { resolve: { MemTotal: 16000000000 } }, + expectedCpu: 0, + expectedMemoryUsage: 1000, + expectedLimit: 16000000000, + }, + { + description: 'handles docker.info() failure gracefully', + containers: [{ Id: 'cont1', Names: ['/c1'] }], + statsMock: { resolve: { cpu_stats: {}, precpu_stats: {}, memory_stats: { usage: 1000, limit: 0 } } }, + infoMock: { reject: new Error('Info failed') }, + expectedCpu: 0, + expectedMemoryUsage: 1000, + expectedLimit: 0, + }, + { + description: 'handles container.stats failure gracefully for individual containers', + containers: [{ Id: 'cont1', Names: ['/c1'] }], + statsMock: { reject: new Error('Stats failed') }, + infoMock: { resolve: { MemTotal: 8000000000 } }, + expectedCpu: 0, + expectedMemoryUsage: 0, + expectedLimit: 8000000000, + }, + { + description: 'returns zero stats and gets memory limit from docker.info when no containers are running', + containers: [], + statsMock: null, + infoMock: { resolve: { MemTotal: 12000000000 } }, + expectedCpu: 0, + expectedMemoryUsage: 0, + expectedLimit: 12000000000, + }, + { + description: 'handles docker.info failure when no containers are running', + containers: [], + statsMock: null, + infoMock: { reject: new Error('info fail') }, + expectedCpu: 0, + expectedMemoryUsage: 0, + expectedLimit: 0, + }, + ])('$description', async ({ containers, statsMock, infoMock, expectedCpu, expectedMemoryUsage, expectedLimit }) => { + mockContainersList.mockResolvedValueOnce(containers); + + if (statsMock) { + if ('resolve' in statsMock) { + mockContainerStats.mockResolvedValueOnce(statsMock.resolve); + } else { + mockContainerStats.mockRejectedValueOnce(statsMock.reject); + } + } + + if ('resolve' in infoMock) { + mockDockerInfo.mockResolvedValueOnce(infoMock.resolve); + } else { + mockDockerInfo.mockRejectedValueOnce(infoMock.reject); + } const stats = await getDockerSystemStats(); - expect(stats?.memoryLimit).toBe(0); - }); - - it('handles container.stats failure gracefully for individual containers', async () => { - mockContainersList.mockResolvedValueOnce([ - { Id: 'cont1', Names: ['/c1'] }, - ]); - mockContainerStats.mockRejectedValueOnce(new Error('Stats failed')); - - const stats = await getDockerSystemStats(); - expect(stats?.cpu).toBe(0); - expect(stats?.memoryUsage).toBe(0); - }); - - it('returns zero stats and gets memory limit from docker.info when no containers are running', async () => { - mockContainersList.mockResolvedValueOnce([]); - mockDockerInfo.mockResolvedValueOnce({ MemTotal: 12000000000 }); - - const stats = await getDockerSystemStats(); - expect(stats).toEqual({ - cpu: 0, - memoryUsage: 0, - memoryLimit: 12000000000, - }); - }); - - it('handles docker.info failure when no containers are running', async () => { - mockContainersList.mockResolvedValueOnce([]); - mockDockerInfo.mockRejectedValueOnce(new Error('info fail')); - - const stats = await getDockerSystemStats(); - expect(stats).toEqual({ - cpu: 0, - memoryUsage: 0, - memoryLimit: 0, - }); + expect(stats?.cpu).toBe(expectedCpu); + expect(stats?.memoryUsage).toBe(expectedMemoryUsage); + expect(stats?.memoryLimit).toBe(expectedLimit); }); it('returns 0 CPU percent when cpuDelta or systemCpuDelta is zero or negative', async () => { diff --git a/src/__tests__/watch-dashboard.test.tsx b/src/__tests__/watch-dashboard.test.tsx index 0ffc2fb..43f362a 100644 --- a/src/__tests__/watch-dashboard.test.tsx +++ b/src/__tests__/watch-dashboard.test.tsx @@ -80,34 +80,38 @@ describe('WatchDashboard', () => { unmount(); }); - it('handles K8s API errors gracefully', async () => { - getRunningPodsSpy.mockRejectedValue(new Error('K8s error')); - getK8sClusterStatsSpy.mockRejectedValue(new Error('K8s stats error')); - getRunningContainersSpy.mockResolvedValue([]); - getDockerSystemStatsSpy.mockResolvedValue(null); - - const { unmount } = render(, { stdout: mockStdout as any }); - - await waitForFrameToContain(mockStdout, 'ERROR: K8S - K8s error'); - - const output = mockStdout.frames.join('\n'); - expect(output).toContain('k8s Stats: CPU: N/A | Mem: N/A'); - - unmount(); - }); - - it('handles Docker API errors gracefully', async () => { - getRunningPodsSpy.mockResolvedValue([]); - getK8sClusterStatsSpy.mockResolvedValue({ cpu: 'N/A', memory: 'N/A', source: 'N/A' }); - getRunningContainersSpy.mockRejectedValue(new Error('Docker error')); - getDockerSystemStatsSpy.mockRejectedValue(new Error('Docker stats error')); + it.each([ + { + description: 'handles K8s API errors gracefully', + mockSetup: () => { + getRunningPodsSpy.mockRejectedValue(new Error('K8s error')); + getK8sClusterStatsSpy.mockRejectedValue(new Error('K8s stats error')); + getRunningContainersSpy.mockResolvedValue([]); + getDockerSystemStatsSpy.mockResolvedValue(null); + }, + errorMsg: 'ERROR: K8S - K8s error', + outputMsg: 'k8s Stats: CPU: N/A | Mem: N/A', + }, + { + description: 'handles Docker API errors gracefully', + mockSetup: () => { + getRunningPodsSpy.mockResolvedValue([]); + getK8sClusterStatsSpy.mockResolvedValue({ cpu: 'N/A', memory: 'N/A', source: 'N/A' }); + getRunningContainersSpy.mockRejectedValue(new Error('Docker error')); + getDockerSystemStatsSpy.mockRejectedValue(new Error('Docker stats error')); + }, + errorMsg: 'ERROR: DOCKER - Docker error', + outputMsg: 'Docker Stats: CPU: N/A | Mem: N/A', + }, + ])('$description', async ({ mockSetup, errorMsg, outputMsg }) => { + mockSetup(); const { unmount } = render(, { stdout: mockStdout as any }); - await waitForFrameToContain(mockStdout, 'ERROR: DOCKER - Docker error'); + await waitForFrameToContain(mockStdout, errorMsg); const output = mockStdout.frames.join('\n'); - expect(output).toContain('Docker Stats: CPU: N/A | Mem: N/A'); + expect(output).toContain(outputMsg); unmount(); }); From 927d7fb881a96ea4575124a82da7612adda48529 Mon Sep 17 00:00:00 2001 From: utkarsh patrikar Date: Fri, 12 Jun 2026 01:53:41 +0530 Subject: [PATCH 07/10] test(watch): add mock isTTY properties to MockWritable to stabilize rendering in CI --- src/__tests__/watch-dashboard.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/__tests__/watch-dashboard.test.tsx b/src/__tests__/watch-dashboard.test.tsx index 43f362a..aaa2c1e 100644 --- a/src/__tests__/watch-dashboard.test.tsx +++ b/src/__tests__/watch-dashboard.test.tsx @@ -27,6 +27,9 @@ const waitForFrameToContain = async (mockStdout: MockWritable, substring: string class MockWritable extends Writable { frames: string[] = []; + isTTY = true; + columns = 80; + rows = 24; _write(chunk: any, encoding: string, callback: (error?: Error | null) => void) { this.frames.push(chunk.toString()); callback(); From 6e9cccdb1d06f2ca27f6ee1f1d7a4b0a1d1c8f91 Mon Sep 17 00:00:00 2001 From: utkarsh patrikar Date: Fri, 12 Jun 2026 01:56:10 +0530 Subject: [PATCH 08/10] test(watch): temporarily clear process.env.CI and increase timeout to prevent silent render suppression in CI --- src/__tests__/watch-dashboard.test.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/__tests__/watch-dashboard.test.tsx b/src/__tests__/watch-dashboard.test.tsx index aaa2c1e..f16de4a 100644 --- a/src/__tests__/watch-dashboard.test.tsx +++ b/src/__tests__/watch-dashboard.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render } from 'ink'; import { Writable } from 'node:stream'; import { Console } from 'node:console'; @@ -13,7 +13,7 @@ if (!console.Console) { const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -const waitForFrameToContain = async (mockStdout: MockWritable, substring: string, timeout = 2000) => { +const waitForFrameToContain = async (mockStdout: MockWritable, substring: string, timeout = 5000) => { const start = Date.now(); while (Date.now() - start < timeout) { const output = mockStdout.frames.join('\n'); @@ -43,10 +43,21 @@ const getDockerSystemStatsSpy = vi.spyOn(containersMod, 'getDockerSystemStats'); describe('WatchDashboard', () => { let mockStdout: MockWritable; + let originalCI: string | undefined; beforeEach(() => { mockStdout = new MockWritable(); vi.clearAllMocks(); + originalCI = process.env.CI; + delete process.env.CI; + }); + + afterEach(() => { + if (originalCI !== undefined) { + process.env.CI = originalCI; + } else { + delete process.env.CI; + } }); it('renders loading states and then displays pods and containers', async () => { From 97cb69677f68547f175962ba6071647f614abae1 Mon Sep 17 00:00:00 2001 From: utkarsh patrikar Date: Fri, 12 Jun 2026 02:01:22 +0530 Subject: [PATCH 09/10] test(watch): pass interactive option to render and remove process.env.CI hooks --- src/__tests__/watch-dashboard.test.tsx | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/src/__tests__/watch-dashboard.test.tsx b/src/__tests__/watch-dashboard.test.tsx index f16de4a..e209294 100644 --- a/src/__tests__/watch-dashboard.test.tsx +++ b/src/__tests__/watch-dashboard.test.tsx @@ -43,21 +43,10 @@ const getDockerSystemStatsSpy = vi.spyOn(containersMod, 'getDockerSystemStats'); describe('WatchDashboard', () => { let mockStdout: MockWritable; - let originalCI: string | undefined; beforeEach(() => { mockStdout = new MockWritable(); vi.clearAllMocks(); - originalCI = process.env.CI; - delete process.env.CI; - }); - - afterEach(() => { - if (originalCI !== undefined) { - process.env.CI = originalCI; - } else { - delete process.env.CI; - } }); it('renders loading states and then displays pods and containers', async () => { @@ -78,7 +67,7 @@ describe('WatchDashboard', () => { memoryLimit: 8000000000, }); - const { unmount } = render(, { stdout: mockStdout as any }); + const { unmount } = render(, { stdout: mockStdout as any, interactive: true }); await waitForFrameToContain(mockStdout, 'pod-1'); @@ -120,7 +109,7 @@ describe('WatchDashboard', () => { ])('$description', async ({ mockSetup, errorMsg, outputMsg }) => { mockSetup(); - const { unmount } = render(, { stdout: mockStdout as any }); + const { unmount } = render(, { stdout: mockStdout as any, interactive: true }); await waitForFrameToContain(mockStdout, errorMsg); @@ -144,7 +133,7 @@ describe('WatchDashboard', () => { configurable: true, }); - const { unmount } = render(, { stdout: mockStdout as any }); + const { unmount } = render(, { stdout: mockStdout as any, interactive: true }); process.stdout.emit('resize'); From aa2a9ebeca4bad487b61b8202c8f9848a8168921 Mon Sep 17 00:00:00 2001 From: utkarsh patrikar Date: Fri, 12 Jun 2026 02:03:38 +0530 Subject: [PATCH 10/10] test(watch): remove unused afterEach import from vitest --- src/__tests__/watch-dashboard.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/watch-dashboard.test.tsx b/src/__tests__/watch-dashboard.test.tsx index e209294..d3b4d45 100644 --- a/src/__tests__/watch-dashboard.test.tsx +++ b/src/__tests__/watch-dashboard.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render } from 'ink'; import { Writable } from 'node:stream'; import { Console } from 'node:console';