import { describe, expect, test, beforeEach } from "bun:test"; import { metrics } from "./metrics"; beforeEach(() => { metrics.reset(); }); describe("MetricsCollector", () => { describe("connection tracking", () => { test("records connections and disconnections", () => { metrics.recordConnection(); metrics.recordConnection(); metrics.recordDisconnection(); const snap = metrics.getSnapshot(); expect(snap.connections.totalConnections).toBe(2); expect(snap.connections.totalDisconnections).toBe(1); expect(snap.connections.activeConnections).toBe(1); }); test("active connections can go to zero", () => { const before = metrics.getSnapshot(); const baseConns = before.connections.totalConnections; const baseDisconns = before.connections.totalDisconnections; metrics.recordConnection(); metrics.recordDisconnection(); const snap = metrics.getSnapshot(); expect(snap.connections.totalConnections).toBe(baseConns + 1); expect(snap.connections.totalDisconnections).toBe(baseDisconns + 1); expect(snap.connections.activeConnections).toBe(before.connections.activeConnections); }); }); describe("command tracking", () => { test("records command count and type breakdown", () => { metrics.recordCommand("console"); metrics.recordCommand("console"); metrics.recordCommand("desktop_start"); const snap = metrics.getSnapshot(); expect(snap.commands.total).toBe(3); expect(snap.commands.byType["console"]).toBe(2); expect(snap.commands.byType["desktop_start"]).toBe(1); }); test("commands within last minute are counted", () => { metrics.recordCommand("test"); const snap = metrics.getSnapshot(); expect(snap.commands.lastMinute).toBeGreaterThanOrEqual(1); expect(snap.commands.lastHour).toBeGreaterThanOrEqual(1); }); }); describe("bandwidth tracking", () => { test("records bytes sent and received", () => { metrics.recordBytesSent(1024); metrics.recordBytesSent(2048); metrics.recordBytesReceived(512); const snap = metrics.getSnapshot(); expect(snap.bandwidth.sent).toBe(3072); expect(snap.bandwidth.received).toBe(512); }); }); describe("ping tracking", () => { test("returns null stats when no pings recorded", () => { const snap = metrics.getSnapshot(); expect(snap.ping.min).toBeNull(); expect(snap.ping.max).toBeNull(); expect(snap.ping.avg).toBeNull(); expect(snap.ping.count).toBe(0); }); test("computes min/max/avg correctly", () => { metrics.recordPing(10); metrics.recordPing(20); metrics.recordPing(30); const snap = metrics.getSnapshot(); expect(snap.ping.min).toBe(10); expect(snap.ping.max).toBe(30); expect(snap.ping.avg).toBe(20); expect(snap.ping.count).toBe(3); }); test("single ping has same min/max/avg", () => { metrics.recordPing(42); const snap = metrics.getSnapshot(); expect(snap.ping.min).toBe(42); expect(snap.ping.max).toBe(42); expect(snap.ping.avg).toBe(42); expect(snap.ping.count).toBe(1); }); }); describe("HTTP request tracking", () => { test("records total HTTP requests", () => { metrics.recordHttpRequest(10, 200); metrics.recordHttpRequest(20, 200); metrics.recordHttpRequest(5, 404); const snap = metrics.getSnapshot(); expect(snap.http.total).toBe(3); }); test("tracks error responses in last minute", () => { metrics.recordHttpRequest(10, 200); metrics.recordHttpRequest(20, 500); metrics.recordHttpRequest(5, 404); const snap = metrics.getSnapshot(); expect(snap.http.lastMinuteErrors).toBeGreaterThanOrEqual(2); }); test("computes latency avg and p95", () => { for (let i = 1; i <= 100; i++) { metrics.recordHttpRequest(i, 200); } const snap = metrics.getSnapshot(); expect(snap.http.latencyAvg).toBeCloseTo(50.5, 0); expect(snap.http.latencyP95).toBeGreaterThanOrEqual(90); expect(snap.http.latencyP99).toBeGreaterThanOrEqual(99); }); test("tracks slow HTTP routes", () => { metrics.recordHttpRequest(15, 200, "GET /api/metrics"); metrics.recordHttpRequest(120, 200, "GET /api/clients/:id/rd/ws"); metrics.recordHttpRequest(200, 500, "GET /api/clients/:id/rd/ws"); const snap = metrics.getSnapshot(); expect(snap.http.routes[0].route).toBe("GET /api/clients/:id/rd/ws"); expect(snap.http.routes[0].countLastMinute).toBe(2); expect(snap.http.routes[0].errorsLastMinute).toBe(1); expect(snap.http.routes[0].latencyP95).toBeGreaterThanOrEqual(120); }); test("does not count the metrics dashboard poll as HTTP pressure", () => { metrics.recordHttpRequest(100, 200, "GET /api/metrics"); metrics.recordHttpRequest(20, 200, "GET /api/clients"); const snap = metrics.getSnapshot(); expect(snap.http.total).toBe(1); expect(snap.http.lastMinute).toBe(1); expect(snap.http.routes.map((route) => route.route)).toEqual([ "GET /api/clients", ]); }); }); describe("snapshot structure", () => { test("returns expected top-level keys", () => { const snap = metrics.getSnapshot(); expect(snap).toHaveProperty("timestamp"); expect(snap).toHaveProperty("clients"); expect(snap).toHaveProperty("connections"); expect(snap).toHaveProperty("commands"); expect(snap).toHaveProperty("sessions"); expect(snap).toHaveProperty("bandwidth"); expect(snap).toHaveProperty("server"); expect(snap).toHaveProperty("ping"); expect(snap).toHaveProperty("http"); expect(snap).toHaveProperty("eventLoop"); }); test("server uptime is positive", () => { const snap = metrics.getSnapshot(); expect(snap.server.uptime).toBeGreaterThanOrEqual(0); expect(snap.server.startTime).toBeGreaterThan(0); }); test("system memory values are reasonable", () => { const snap = metrics.getSnapshot(); expect(snap.server.systemMemory.total).toBeGreaterThan(0); expect(snap.server.systemMemory.free).toBeGreaterThanOrEqual(0); expect(snap.server.systemMemory.usedPercent).toBeGreaterThanOrEqual(0); expect(snap.server.systemMemory.usedPercent).toBeLessThanOrEqual(100); }); test("cpu cores is positive", () => { const snap = metrics.getSnapshot(); expect(snap.server.cpu.cores).toBeGreaterThan(0); }); }); describe("history", () => { test("starts empty", () => { expect(metrics.getHistory()).toEqual([]); }); test("recordHistoryEntry adds to history", () => { const snap = metrics.getSnapshot(); metrics.recordHistoryEntry(snap); const history = metrics.getHistory(); expect(history).toHaveLength(1); expect(history[0].timestamp).toBe(snap.timestamp); }); test("history captures pressure metrics for charting", () => { metrics.recordHttpRequest(25, 200); metrics.recordHttpRequest(80, 500); metrics.recordBytesSent(1024); metrics.recordBytesReceived(2048); const snap = metrics.getSnapshot(); metrics.recordHistoryEntry(snap); const [entry] = metrics.getHistory(); expect(entry.httpRequestsPerMinute).toBeGreaterThanOrEqual(2); expect(entry.httpErrorsPerMinute).toBeGreaterThanOrEqual(1); expect(entry.httpLatencyAvg).toBeGreaterThan(0); expect(entry.httpLatencyP95).toBeGreaterThan(0); expect(entry.heapUsed).toBeGreaterThan(0); expect(entry.rss).toBeGreaterThan(0); expect(entry.systemMemoryUsedPercent).toBeGreaterThanOrEqual(0); expect(entry.activeSessions).toBe(0); }); test("history is capped at maxHistoryPoints", () => { const weekOfFiveSecondSamples = 7 * 24 * 60 * 12; for (let i = 0; i < weekOfFiveSecondSamples + 180; i++) { const snap = metrics.getSnapshot(); metrics.recordHistoryEntry(snap); } const history = metrics.getHistory(); expect(history.length).toBeLessThanOrEqual(weekOfFiveSecondSamples); }); test("getHistory returns a copy", () => { const snap = metrics.getSnapshot(); metrics.recordHistoryEntry(snap); const h1 = metrics.getHistory(); const h2 = metrics.getHistory(); expect(h1).not.toBe(h2); expect(h1).toEqual(h2); }); }); describe("reset", () => { test("clears resettable counters", () => { metrics.recordCommand("test"); metrics.recordBytesSent(100); metrics.recordBytesReceived(200); metrics.recordPing(50); metrics.recordHttpRequest(10, 200); metrics.reset(); const snap = metrics.getSnapshot(); // Note: connection counters are cumulative and not cleared by reset() expect(snap.commands.total).toBe(0); expect(snap.bandwidth.sent).toBe(0); expect(snap.bandwidth.received).toBe(0); expect(snap.ping.count).toBe(0); expect(snap.http.total).toBe(0); }); }); });