diff --git a/server/test/unit/services/diagnosticService.test.ts b/server/test/unit/services/diagnosticService.test.ts new file mode 100644 index 000000000..a76f28893 --- /dev/null +++ b/server/test/unit/services/diagnosticService.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals"; +import { DiagnosticService } from "../../../src/service/business/diagnosticService.ts"; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("DiagnosticService", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe("serviceName", () => { + it("returns diagnosticService from static property", () => { + expect(DiagnosticService.SERVICE_NAME).toBe("diagnosticService"); + }); + + it("returns diagnosticService from instance getter", () => { + const service = new DiagnosticService(); + expect(service.serviceName).toBe("diagnosticService"); + }); + }); + + // ── constructor ───────────────────────────────────────────────────────── + + describe("constructor", () => { + it("sets up PerformanceObserver that clears marks on callback", () => { + let capturedCallback: ((list: PerformanceObserverEntryList) => void) | null = null; + const originalObserver = globalThis.PerformanceObserver; + const mockObserve = jest.fn(); + + globalThis.PerformanceObserver = jest.fn().mockImplementation((cb: any) => { + capturedCallback = cb; + return { observe: mockObserve }; + }) as any; + + const clearMarksSpy = jest.spyOn(performance, "clearMarks"); + const service = new DiagnosticService(); + + expect(mockObserve).toHaveBeenCalledWith({ entryTypes: ["measure"] }); + expect(capturedCallback).not.toBeNull(); + + // Invoke the callback manually + const mockEntryList = { getEntries: jest.fn().mockReturnValue([]) } as unknown as PerformanceObserverEntryList; + capturedCallback!(mockEntryList); + + expect(mockEntryList.getEntries).toHaveBeenCalled(); + expect(clearMarksSpy).toHaveBeenCalled(); + + clearMarksSpy.mockRestore(); + globalThis.PerformanceObserver = originalObserver; + expect(service).toBeDefined(); + }); + }); + + // ── getCPUUsage ───────────────────────────────────────────────────────── + + describe("getCPUUsage", () => { + it("returns cpu usage metrics after timing period", async () => { + const service = new DiagnosticService(); + const promise = service.getCPUUsage(); + jest.advanceTimersByTime(1000); + const result = await promise; + + expect(result).toHaveProperty("userUsageMs"); + expect(result).toHaveProperty("systemUsageMs"); + expect(result).toHaveProperty("usagePercentage"); + expect(typeof result.userUsageMs).toBe("number"); + expect(typeof result.systemUsageMs).toBe("number"); + expect(typeof result.usagePercentage).toBe("number"); + }); + }); + + // ── getSystemStats ────────────────────────────────────────────────────── + + describe("getSystemStats", () => { + it("returns all diagnostic sections", async () => { + const service = new DiagnosticService(); + const promise = service.getSystemStats(); + // getCPUUsage has 1s timeout, then event loop delay has 0ms timeout + jest.advanceTimersByTime(1000); + // Need to flush the microtask queue and advance again for the nested setTimeout(0) + await jest.advanceTimersByTimeAsync(10); + const result = await promise; + + expect(result).toHaveProperty("osStats"); + expect(result.osStats).toHaveProperty("freeMemoryBytes"); + expect(result.osStats).toHaveProperty("totalMemoryBytes"); + + expect(result).toHaveProperty("memoryUsage"); + expect(result.memoryUsage).toHaveProperty("rss"); + expect(result.memoryUsage).toHaveProperty("heapTotal"); + expect(result.memoryUsage).toHaveProperty("heapUsed"); + expect(result.memoryUsage).toHaveProperty("external"); + expect(result.memoryUsage).toHaveProperty("arrayBuffers"); + + expect(result).toHaveProperty("cpuUsage"); + expect(result.cpuUsage).toHaveProperty("userUsageMs"); + + expect(result).toHaveProperty("v8HeapStats"); + expect(result.v8HeapStats).toHaveProperty("totalHeapSizeBytes"); + expect(result.v8HeapStats).toHaveProperty("usedHeapSizeBytes"); + expect(result.v8HeapStats).toHaveProperty("heapSizeLimitBytes"); + + expect(typeof result.eventLoopDelayMs).toBe("number"); + expect(result.eventLoopDelayMs).toBeGreaterThanOrEqual(0); + expect(typeof result.uptimeMs).toBe("number"); + expect(result.uptimeMs).toBeGreaterThan(0); + }); + + it("reads eventLoopDelay from real performance entries", async () => { + jest.useRealTimers(); + const service = new DiagnosticService(); + + // Call getSystemStats with real timers — slow but guarantees real perf entries + const result = await service.getSystemStats(); + + expect(typeof result.eventLoopDelayMs).toBe("number"); + expect(result.eventLoopDelayMs).toBeGreaterThanOrEqual(0); + jest.useFakeTimers(); + }); + + it.each([ + { label: "empty entries array", entries: [] }, + { label: "undefined entry in array", entries: [undefined] }, + ])("defaults eventLoopDelayMs to 0 when $label", async ({ entries }) => { + const service = new DiagnosticService(); + const originalGetEntries = performance.getEntriesByName; + const originalMeasure = performance.measure; + const originalMark = performance.mark; + + performance.mark = jest.fn() as any; + performance.measure = jest.fn() as any; + performance.getEntriesByName = jest.fn().mockReturnValue(entries) as any; + + const promise = service.getSystemStats(); + jest.advanceTimersByTime(1000); + await jest.advanceTimersByTimeAsync(10); + const result = await promise; + + expect(result.eventLoopDelayMs).toBe(0); + expect(performance.getEntriesByName).toHaveBeenCalledWith("eventLoopDelay"); + + performance.getEntriesByName = originalGetEntries; + performance.measure = originalMeasure; + performance.mark = originalMark; + }); + }); +});