/** * Performance Benchmark Tests — Phase 75 (PERF-01, PERF-03, PERF-04) * * Measures p50/p95/p99 response times for: * - All admin API GET endpoints (PERF-01: p95 < 500ms) * - Guardian checkOutboundThresholds (PERF-03: p95 < 10ms) * * Uses the same mock patterns as monitor.test.ts to benchmark route handler * performance in isolation (no network I/O, no DB I/O). * * Added 2026-03-29 (Phase 75). DO NOT REMOVE — performance regression guard. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { EventEmitter } from "node:events"; import type { IncomingMessage, ServerResponse } from "node:http"; import { performance } from "node:perf_hooks"; // ── Module mocks (must be before imports of the module under test) ───────── vi.mock("./send.js", () => ({ getWahaContacts: vi.fn().mockResolvedValue([]), getWahaGroupParticipants: vi.fn().mockResolvedValue([]), getAllWahaPresence: vi.fn().mockResolvedValue({}), toArr: vi.fn().mockReturnValue([]), findWahaPhoneByLid: vi.fn().mockResolvedValue(null), getWahaContact: vi.fn().mockResolvedValue(null), })); vi.mock("./directory.js", () => ({ getDirectoryDb: vi.fn().mockReturnValue({ getContacts: vi.fn().mockReturnValue([]), getContactCount: vi.fn().mockReturnValue(0), getDmCount: vi.fn().mockReturnValue(0), getGroupCount: vi.fn().mockReturnValue(0), getNewsletterCount: vi.fn().mockReturnValue(0), getContact: vi.fn().mockReturnValue(null), getContactTtl: vi.fn().mockReturnValue(null), getDmSettings: vi.fn().mockReturnValue(null), setContactDmSettings: vi.fn(), setParticipantAllowInGroup: vi.fn(), setParticipantAllowDm: vi.fn(), setGroupFilterOverride: vi.fn(), getGroupFilterOverride: vi.fn().mockReturnValue(null), getGroupParticipants: vi.fn().mockReturnValue([]), bulkUpsertGroupParticipants: vi.fn(), upsertContact: vi.fn(), upsertLidMapping: vi.fn(), resolveLidToCus: vi.fn().mockReturnValue(null), resolveJids: vi.fn().mockReturnValue(new Map()), getModuleAssignments: vi.fn().mockReturnValue([]), isGroupMuted: vi.fn().mockReturnValue(false), setParticipantRole: vi.fn().mockReturnValue(true), getGroupAllowAllStatus: vi.fn().mockReturnValue(false), revokePairingGrant: vi.fn(), setGroupAllowAllStatus: vi.fn(), updateParticipantDisplayName: vi.fn(), hasModuleConfig: vi.fn().mockReturnValue(false), saveModuleConfig: vi.fn(), getModuleConfig: vi.fn().mockReturnValue({}), assignModule: vi.fn(), unassignModule: vi.fn(), }), })); vi.mock("./accounts.js", () => ({ resolveWahaAccount: vi.fn().mockReturnValue({ accountId: "default", enabled: true, baseUrl: "http://localhost:3004", apiKey: "test-api-key", session: "test-session", role: "bot", subRole: "full-access", config: { baseUrl: "http://localhost:3004", session: "test-session", webhookPort: 8050, dmFilter: {}, groupFilter: {}, allowFrom: [], groupAllowFrom: [], allowedGroups: [], }, }), listEnabledWahaAccounts: vi.fn().mockReturnValue([ { accountId: "default", session: "test-session", name: "Test Session", role: "bot", subRole: "full-access", apiKey: "test-api-key", config: { baseUrl: "http://localhost:3004", session: "test-session", }, }, ]), resolveDefaultWahaAccountId: vi.fn().mockReturnValue("default"), })); vi.mock("./inbound.js", () => ({ getDmFilterForAdmin: vi.fn().mockReturnValue({ stats: { dropped: 0, allowed: 0, tokensEstimatedSaved: 0 }, recentEvents: [] }), getGroupFilterForAdmin: vi.fn().mockReturnValue({ stats: { dropped: 0, allowed: 0, tokensEstimatedSaved: 0 }, recentEvents: [] }), handleWahaInbound: vi.fn().mockResolvedValue(undefined), })); vi.mock("./health.js", () => ({ startHealthCheck: vi.fn().mockReturnValue({ status: "unknown", consecutiveFailures: 0, lastSuccessAt: null, lastCheckAt: null }), getHealthState: vi.fn().mockReturnValue({ status: "healthy", consecutiveFailures: 0, lastSuccessAt: Date.now(), lastCheckAt: Date.now() }), getRecoveryState: vi.fn().mockReturnValue(null), getRecoveryHistory: vi.fn().mockReturnValue([]), setHealthStateChangeCallback: vi.fn(), })); vi.mock("./analytics.js", () => ({ getAnalyticsDb: vi.fn().mockReturnValue({ query: vi.fn().mockReturnValue([]), getSummary: vi.fn().mockReturnValue({ total: 0, dm: 0, group: 0 }), getTopChats: vi.fn().mockReturnValue([]), recordEvent: vi.fn(), }), recordAnalyticsEvent: vi.fn(), })); vi.mock("./inbound-queue.js", () => { class InboundQueue { enqueue = vi.fn(); getStats = vi.fn().mockReturnValue({ dmDepth: 0, groupDepth: 0, dmCapacity: 50, groupCapacity: 100 }); constructor() {} } return { InboundQueue, setQueueChangeCallback: vi.fn(), }; }); vi.mock("./pairing.js", () => ({ getPairingEngine: vi.fn().mockReturnValue({ start: vi.fn(), stop: vi.fn(), }), })); vi.mock("./module-registry.js", () => ({ getModuleRegistry: vi.fn().mockReturnValue({ listModules: vi.fn().mockReturnValue([]), enableModule: vi.fn(), disableModule: vi.fn(), getModuleAssignments: vi.fn().mockReturnValue([]), }), })); vi.mock("./sync.js", () => ({ getSyncState: vi.fn().mockReturnValue(null), triggerImmediateSync: vi.fn(), })); vi.mock("./signature.js", () => ({ verifyWahaWebhookHmac: vi.fn().mockReturnValue(true), })); vi.mock("./secret-input.js", () => ({ normalizeResolvedSecretInputString: vi.fn().mockReturnValue(null), })); vi.mock("./dedup.js", () => ({ isDuplicate: vi.fn().mockReturnValue(false), })); vi.mock("./config-schema.js", () => ({ validateWahaConfig: vi.fn().mockReturnValue({ valid: true, errors: [] }), })); vi.mock("./http-client.js", () => ({ callWahaApi: vi.fn().mockResolvedValue({ ok: true }), })); vi.mock("./proxy-send-handler.js", () => ({ handleProxySend: vi.fn().mockResolvedValue({ status: 200, body: { ok: true } }), })); vi.mock("./config-io.js", () => ({ getConfigPath: vi.fn().mockReturnValue("/mock/openclaw.json"), readConfig: vi.fn().mockResolvedValue({ channels: { waha: { session: "test-session", baseUrl: "http://localhost:3004", dmFilter: { enabled: false }, groupFilter: { enabled: false }, }, }, }), writeConfig: vi.fn().mockResolvedValue(undefined), modifyConfig: vi.fn().mockResolvedValue(undefined), withConfigMutex: vi.fn().mockImplementation(async (fn: () => Promise) => fn()), })); const { mockReadRequestBodyWithLimit } = vi.hoisted(() => ({ mockReadRequestBodyWithLimit: vi.fn().mockResolvedValue(""), })); vi.mock("./request-utils.js", () => ({ isRequestBodyLimitError: vi.fn().mockReturnValue(false), readRequestBodyWithLimit: mockReadRequestBodyWithLimit, requestBodyErrorToText: vi.fn().mockReturnValue("error"), RequestBodyLimitError: class RequestBodyLimitError extends Error { constructor(type: "size" | "timeout") { super(type); this.name = "RequestBodyLimitError"; } }, })); vi.mock("./platform-types.js", async () => { const actual = await vi.importActual("./platform-types.js"); return { ...actual }; }); vi.mock("./account-utils.js", async () => { const actual = await vi.importActual("./account-utils.js"); return { ...actual }; }); vi.mock("node:fs", async () => { const actual = await vi.importActual("node:fs"); return { ...actual, readFileSync: vi.fn().mockReturnValue(JSON.stringify({ channels: { waha: { session: "test-session", baseUrl: "http://localhost:3004", dmFilter: { enabled: false }, groupFilter: { enabled: false }, }, }, })), writeFileSync: vi.fn(), existsSync: vi.fn().mockReturnValue(false), copyFileSync: vi.fn(), renameSync: vi.fn(), }; }); // ── Import module under test AFTER all mocks ─────────────────────────────── import { createWahaWebhookServer } from "./monitor.js"; // Guardian imports — direct import (no mocks needed, pure computation) import { SlidingWindowCounter, checkOutboundThresholds, checkInboundRate, type GuardianConfig, } from "./modules/guardian/guardian.js"; // ── Shared constants ────────────────────────────────────────────────────── const WINDOW_MS = 60_000; const DEFAULT_GUARDIAN_CONFIG: GuardianConfig = { enabled: true, outboundMsgThreshold: 200, outboundMsgWindowMs: WINDOW_MS, recipientThreshold: 50, recipientWindowMs: WINDOW_MS, inboundDropThreshold: 500, inboundDropWindowMs: WINDOW_MS, pollIntervalMs: 5_000, }; // ── Test helpers ──────────────────────────────────────────────────────────── function makeMinimalConfig() { return { channels: { waha: { session: "test-session", baseUrl: "http://localhost:3004", dmFilter: { enabled: false }, groupFilter: { enabled: false }, allowFrom: [], groupAllowFrom: [], allowedGroups: [], dmPolicy: "allowlist", groupPolicy: "allowlist", }, }, }; } function makeRuntime() { return { log: vi.fn() }; } interface MockRes { statusCode: number; headers: Record; body: string; writeHead: ReturnType; end: ReturnType; setHeader: ReturnType; write: ReturnType; on: ReturnType; headersSent: boolean; } function makeReq(overrides: { url?: string; method?: string; headers?: Record; }): IncomingMessage { const emitter = new EventEmitter() as IncomingMessage; emitter.url = overrides.url ?? "/"; emitter.method = overrides.method ?? "GET"; emitter.headers = overrides.headers ?? {}; (emitter as any).socket = { remoteAddress: "127.0.0.1" }; (emitter as any).destroy = vi.fn(); return emitter; } function makeRes(): MockRes { const res: MockRes = { statusCode: 200, headers: {}, body: "", headersSent: false, writeHead: vi.fn().mockImplementation(function (this: MockRes, code: number, hdrs?: Record) { res.statusCode = code; if (hdrs) Object.assign(res.headers, hdrs); res.headersSent = true; }), end: vi.fn().mockImplementation(function (this: MockRes, data?: string | Buffer) { if (data) res.body += typeof data === "string" ? data : data.toString(); }), setHeader: vi.fn().mockImplementation(function (this: MockRes, name: string, value: string) { res.headers[name] = value; }), write: vi.fn(), on: vi.fn(), }; return res; } function makeServer(bodyFn?: (req: IncomingMessage) => Promise) { const cfg = makeMinimalConfig(); const runtime = makeRuntime(); const readBody = bodyFn ?? (async () => "{}"); return createWahaWebhookServer({ accountId: "default", config: cfg as never, runtime: runtime as never, readBody, }); } async function callRoute( server: ReturnType["server"], req: IncomingMessage, res: MockRes, timeoutMs = 3000, ): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`callRoute timed out after ${timeoutMs}ms`)), timeoutMs); const origEnd = res.end; res.end = vi.fn().mockImplementation((...args: Parameters) => { origEnd(...args as Parameters); clearTimeout(timer); resolve(); }); try { server.emit("request", req, res as unknown as ServerResponse); } catch (err) { clearTimeout(timer); reject(err); } }); } // ── Percentile calculation ───────────────────────────────────────────────── function percentile(sortedTimes: number[], p: number): number { const idx = Math.floor(sortedTimes.length * p); return sortedTimes[Math.min(idx, sortedTimes.length - 1)]; } function calcPercentiles(times: number[]): { p50: number; p95: number; p99: number; min: number; max: number; mean: number } { const sorted = [...times].sort((a, b) => a - b); const sum = sorted.reduce((acc, t) => acc + t, 0); return { p50: percentile(sorted, 0.50), p95: percentile(sorted, 0.95), p99: percentile(sorted, 0.99), min: sorted[0], max: sorted[sorted.length - 1], mean: sum / sorted.length, }; } // ── Benchmark runner ─────────────────────────────────────────────────────── async function benchmarkEndpoint( serverCtx: ReturnType, url: string, iterations: number = 100, ): Promise<{ p50: number; p95: number; p99: number; min: number; max: number; mean: number }> { const times: number[] = []; for (let i = 0; i < iterations; i++) { const req = makeReq({ url, method: "GET" }); const res = makeRes(); const start = performance.now(); await callRoute(serverCtx.server, req, res); const elapsed = performance.now() - start; if (res.statusCode >= 500) { throw new Error(`${url} returned server error ${res.statusCode} on iteration ${i}`); } times.push(elapsed); } return calcPercentiles(times); } // ═══════════════════════════════════════════════════════════════════════════ // PERF-01: Admin API Endpoint Benchmarks // ═══════════════════════════════════════════════════════════════════════════ describe("PERF-01: Admin API Response Time Benchmarks", () => { let serverCtx: ReturnType; // All GET endpoints to benchmark const endpoints = [ "/healthz", "/api/admin/health", "/api/admin/queue", "/api/admin/recovery", "/api/admin/config", "/api/admin/sessions", "/api/admin/directory", "/api/admin/stats", "/api/admin/modules", "/api/admin/analytics", "/api/admin/logs", ]; // Store results for summary output const results: Record = {}; beforeEach(() => { vi.clearAllMocks(); serverCtx = makeServer(); }); afterEach(() => { serverCtx.server.removeAllListeners(); }); for (const endpoint of endpoints) { it(`${endpoint} — p95 < 500ms (100 iterations)`, async () => { const stats = await benchmarkEndpoint(serverCtx, endpoint, 100); results[endpoint] = stats; // Log results for report generation console.log(` ${endpoint}: p50=${stats.p50.toFixed(3)}ms p95=${stats.p95.toFixed(3)}ms p99=${stats.p99.toFixed(3)}ms mean=${stats.mean.toFixed(3)}ms`); // PERF-01: p95 must be under 500ms expect(stats.p95).toBeLessThan(500); }); } it("summary: all endpoints within budget", () => { // Verify we benchmarked all endpoints const measured = Object.keys(results).length; // This test runs last — fail if benchmarks didn't run (prevents false positives) expect(measured).toBeGreaterThan(0); const worstP95 = Math.max(...Object.values(results).map(r => r.p95)); console.log(`\n PERF-01 SUMMARY: ${measured} endpoints benchmarked, worst p95 = ${worstP95.toFixed(3)}ms`); expect(worstP95).toBeLessThan(500); }); }); // ═══════════════════════════════════════════════════════════════════════════ // PERF-03: Guardian Threshold Evaluation Benchmarks // ═══════════════════════════════════════════════════════════════════════════ describe("PERF-03: Guardian checkOutboundThresholds Benchmark", () => { const ITERATIONS = 1000; const SESSIONS = 50; it(`checkOutboundThresholds — p95 < 10ms (${ITERATIONS} iterations, ${SESSIONS} sessions)`, () => { // Set up counters with realistic data const outboundCounter = new SlidingWindowCounter(WINDOW_MS); const recipientCounter = new SlidingWindowCounter(WINDOW_MS); // Populate counters with realistic data: 100 messages per session, 20 unique recipients for (let s = 0; s < SESSIONS; s++) { const session = `session_${s}`; for (let m = 0; m < 100; m++) { outboundCounter.record(session, `recipient_${m % 20}`); } for (let r = 0; r < 20; r++) { recipientCounter.record(session, `recipient_${r}`); } } const times: number[] = []; for (let i = 0; i < ITERATIONS; i++) { const session = `session_${i % SESSIONS}`; const start = performance.now(); checkOutboundThresholds(session, outboundCounter, recipientCounter, DEFAULT_GUARDIAN_CONFIG); const elapsed = performance.now() - start; times.push(elapsed); } const stats = calcPercentiles(times); console.log(` checkOutboundThresholds: p50=${stats.p50.toFixed(4)}ms p95=${stats.p95.toFixed(4)}ms p99=${stats.p99.toFixed(4)}ms mean=${stats.mean.toFixed(4)}ms`); // PERF-03: p95 must be under 10ms (should be microseconds) expect(stats.p95).toBeLessThan(10); }); it(`checkInboundRate — p95 < 10ms (${ITERATIONS} iterations)`, () => { const inboundCounter = new SlidingWindowCounter(WINDOW_MS); // Populate with realistic data for (let s = 0; s < SESSIONS; s++) { const session = `session_${s}`; for (let m = 0; m < 200; m++) { inboundCounter.record(session); } } const times: number[] = []; for (let i = 0; i < ITERATIONS; i++) { const session = `session_${i % SESSIONS}`; const start = performance.now(); checkInboundRate(session, inboundCounter, DEFAULT_GUARDIAN_CONFIG); const elapsed = performance.now() - start; times.push(elapsed); } const stats = calcPercentiles(times); console.log(` checkInboundRate: p50=${stats.p50.toFixed(4)}ms p95=${stats.p95.toFixed(4)}ms p99=${stats.p99.toFixed(4)}ms mean=${stats.mean.toFixed(4)}ms`); expect(stats.p95).toBeLessThan(10); }); it(`SlidingWindowCounter.record — p95 < 1ms (${ITERATIONS} iterations)`, () => { const counter = new SlidingWindowCounter(WINDOW_MS); const times: number[] = []; for (let i = 0; i < ITERATIONS; i++) { const session = `session_${i % 100}`; const start = performance.now(); counter.record(session, `recipient_${i % 50}`); const elapsed = performance.now() - start; times.push(elapsed); } const stats = calcPercentiles(times); console.log(` SlidingWindowCounter.record: p50=${stats.p50.toFixed(4)}ms p95=${stats.p95.toFixed(4)}ms p99=${stats.p99.toFixed(4)}ms mean=${stats.mean.toFixed(4)}ms`); expect(stats.p95).toBeLessThan(1); }); });