/* * Copyright (C) 2026 TomTom Navigation B.V. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { PLACES_AND_ROUTES } from "./appListings"; import { ENDPOINT_MCP } from "./constants"; import { createHttpServer, type HttpServerResult } from "./indexHttp"; vi.mock("./tools/helpers/appHtmlCache", () => ({ readAppHtml: async () => "map", })); vi.mock("./services/search/searchService", async (importOriginal) => ({ ...(await importOriginal()), fetchPOICategories: async () => ({ poiCategories: [{ id: 9379, name: "Bar" }] }), })); const PORT = 3997; const WARM_UP_REQUESTS = 100; const SOAK_REQUESTS = 1_000; const HEAP_GROWTH_BUDGET_BYTES = 20 * 1024 * 1024; const REQUESTS = [ { query: "", method: "tools/list", params: {} }, { query: "?apps=false", method: "tools/list", params: {} }, { query: "", method: "resources/read", params: { uri: PLACES_AND_ROUTES } }, { query: "", method: "tools/call", params: { name: "tomtom-poi-categories", arguments: { filters: ["bar"] } }, }, ]; async function send(index: number): Promise { const { query, method, params } = REQUESTS[index % REQUESTS.length]; const started = performance.now(); const response = await fetch(`http://localhost:${PORT}/${ENDPOINT_MCP}${query}`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json,text/event-stream", Connection: "close", "tomtom-api-key": "soak-api-key", }, body: JSON.stringify({ jsonrpc: "2.0", id: index, method, params }), }); const body = await response.text(); expect(response.status, body).toBe(200); const json = body.startsWith("{") ? body : body.slice(body.indexOf("data: ") + "data: ".length); const message = JSON.parse(json); expect(message, method).toHaveProperty("result"); expect(message.result.isError, method).not.toBe(true); return performance.now() - started; } async function sendAll(count: number): Promise { const latencies: number[] = []; for (let index = 0; index < count; index++) latencies.push(await send(index)); return latencies; } function heapAfterGc(): number { globalThis.gc?.(); return process.memoryUsage().heapUsed; } const openSockets = () => process.getActiveResourcesInfo().filter((resource) => resource.startsWith("TCP")).length; function percentile(sorted: number[], fraction: number): number { return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))]; } describe("HTTP server under sustained load", () => { let server: HttpServerResult; beforeAll(async () => { server = await createHttpServer({ port: PORT }); }); afterAll(async () => { await server.shutdown(); }); it("answers 1,000 sequential requests without growing its heap or leaving sockets open", async () => { expect(globalThis.gc, "run with --expose-gc: pnpm test:soak").toBeTypeOf("function"); await sendAll(WARM_UP_REQUESTS); const socketsBefore = openSockets(); const heapBefore = heapAfterGc(); const latencies = (await sendAll(SOAK_REQUESTS)).sort((a, b) => a - b); const heapGrowth = heapAfterGc() - heapBefore; process.stderr.write( `soak: ${SOAK_REQUESTS} requests, p50 ${percentile(latencies, 0.5).toFixed(1)} ms, p95 ${percentile(latencies, 0.95).toFixed(1)} ms, heap growth ${(heapGrowth / 1024 / 1024).toFixed(1)} MB\n` ); expect(heapGrowth).toBeLessThanOrEqual(HEAP_GROWTH_BUDGET_BYTES); expect(openSockets()).toBeLessThanOrEqual(socketsBefore); }); });