From 2eb496d90e95741696a3290a4c88e2d2c9ff1dfa Mon Sep 17 00:00:00 2001 From: Tamas Date: Mon, 12 Jan 2026 11:51:59 +0100 Subject: [PATCH 1/4] feat(load-tests): add local load test runner Adds a new load testing package with two scenarios: - connection-storm: Rapid connection establishment testing - steady-state: Connection stability over time Usage: yarn start --target=ws://localhost:8000/connection/websocket --scenario=steady-state --connections=100 --duration=60 Features: - Centrifuge WebSocket client wrapper with timing - Progress bar with real-time connection status - JSON output with latency percentiles - Configurable ramp-up pacing Note: Infrastructure commands (infra, results aggregate) are placeholders for the follow-up distributed testing PR. --- .gitignore | 1 + apps/load-tests/.env.example | 20 ++ apps/load-tests/.gitignore | 11 + apps/load-tests/package.json | 28 +++ apps/load-tests/results/.gitkeep | 0 apps/load-tests/src/cli/infra.ts | 55 +++++ apps/load-tests/src/cli/results.ts | 20 ++ apps/load-tests/src/cli/run.ts | 167 +++++++++++++++ .../src/client/centrifuge-client.ts | 112 ++++++++++ apps/load-tests/src/output/formatter.ts | 66 ++++++ apps/load-tests/src/output/types.ts | 50 +++++ apps/load-tests/src/output/writer.ts | 16 ++ .../src/scenarios/connection-storm.ts | 117 ++++++++++ apps/load-tests/src/scenarios/index.ts | 31 +++ apps/load-tests/src/scenarios/steady-state.ts | 200 ++++++++++++++++++ apps/load-tests/src/scenarios/types.ts | 50 +++++ apps/load-tests/src/utils/progress.ts | 54 +++++ apps/load-tests/src/utils/stats.ts | 25 +++ apps/load-tests/src/utils/timing.ts | 7 + apps/load-tests/tsconfig.json | 7 + 20 files changed, 1037 insertions(+) create mode 100644 apps/load-tests/.env.example create mode 100644 apps/load-tests/.gitignore create mode 100644 apps/load-tests/package.json create mode 100644 apps/load-tests/results/.gitkeep create mode 100644 apps/load-tests/src/cli/infra.ts create mode 100644 apps/load-tests/src/cli/results.ts create mode 100644 apps/load-tests/src/cli/run.ts create mode 100644 apps/load-tests/src/client/centrifuge-client.ts create mode 100644 apps/load-tests/src/output/formatter.ts create mode 100644 apps/load-tests/src/output/types.ts create mode 100644 apps/load-tests/src/output/writer.ts create mode 100644 apps/load-tests/src/scenarios/connection-storm.ts create mode 100644 apps/load-tests/src/scenarios/index.ts create mode 100644 apps/load-tests/src/scenarios/steady-state.ts create mode 100644 apps/load-tests/src/scenarios/types.ts create mode 100644 apps/load-tests/src/utils/progress.ts create mode 100644 apps/load-tests/src/utils/stats.ts create mode 100644 apps/load-tests/src/utils/timing.ts create mode 100644 apps/load-tests/tsconfig.json diff --git a/.gitignore b/.gitignore index 61e8d47..6a65f17 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ packages/*/docs # typescript packages/*/*.tsbuildinfo +apps/*/*.tsbuildinfo # LLM .llm.txt diff --git a/apps/load-tests/.env.example b/apps/load-tests/.env.example new file mode 100644 index 0000000..8b54dd7 --- /dev/null +++ b/apps/load-tests/.env.example @@ -0,0 +1,20 @@ +# Load Test Runner Configuration +# ================================ + +# Target relay server URL (required for running tests) +# RELAY_URL=ws://localhost:8000/connection/websocket +# RELAY_URL=wss://mm-sdk-relay.api.cx.metamask.io/connection/websocket + +# DigitalOcean Infrastructure Configuration +# ========================================== + +# DigitalOcean API token (required for infra commands) +# Get this from: https://cloud.digitalocean.com/account/api/tokens +DIGITALOCEAN_TOKEN= + +# SSH key fingerprint registered with DigitalOcean (required for infra commands) +# Find this in: https://cloud.digitalocean.com/account/security +SSH_KEY_FINGERPRINT= + +# Path to SSH private key (optional, defaults to ~/.ssh/id_rsa) +# SSH_PRIVATE_KEY_PATH=~/.ssh/id_rsa diff --git a/apps/load-tests/.gitignore b/apps/load-tests/.gitignore new file mode 100644 index 0000000..aaae9ef --- /dev/null +++ b/apps/load-tests/.gitignore @@ -0,0 +1,11 @@ +# Environment files (except example) +.env +.env.local + +# Results (except .gitkeep) +results/* +!results/.gitkeep + +# Infrastructure state +results/.infra-state.json + diff --git a/apps/load-tests/package.json b/apps/load-tests/package.json new file mode 100644 index 0000000..17b03c6 --- /dev/null +++ b/apps/load-tests/package.json @@ -0,0 +1,28 @@ +{ + "name": "@metamask/mobile-wallet-protocol-load-tests", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "start": "tsx src/cli/run.ts", + "infra": "tsx src/cli/infra.ts", + "results": "tsx src/cli/results.ts" + }, + "dependencies": { + "centrifuge": "^5.3.5", + "chalk": "^5.6.2", + "cli-progress": "^3.12.0", + "commander": "^13.1.0", + "dotenv": "^16.5.0", + "ssh2": "^1.16.0", + "tsx": "^4.20.3", + "ws": "^8.18.3" + }, + "devDependencies": { + "@types/cli-progress": "^3.11.6", + "@types/node": "^24.0.3", + "@types/ssh2": "^1.15.4", + "@types/ws": "^8.18.1", + "typescript": "^5.8.3" + } +} diff --git a/apps/load-tests/results/.gitkeep b/apps/load-tests/results/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/load-tests/src/cli/infra.ts b/apps/load-tests/src/cli/infra.ts new file mode 100644 index 0000000..c6c89fc --- /dev/null +++ b/apps/load-tests/src/cli/infra.ts @@ -0,0 +1,55 @@ +#!/usr/bin/env node +import chalk from "chalk"; +import { Command } from "commander"; + +const program = new Command(); + +program + .name("infra") + .description("Manage DigitalOcean infrastructure for distributed load testing") + .version("0.0.1"); + +program + .command("create") + .description("Create DigitalOcean droplets for load testing") + .action(() => { + console.log(chalk.yellow("[infra create] Not implemented yet - coming in next PR")); + }); + +program + .command("list") + .description("List current load test droplets") + .action(() => { + console.log(chalk.yellow("[infra list] Not implemented yet - coming in next PR")); + }); + +program + .command("destroy") + .description("Destroy all load test droplets") + .action(() => { + console.log(chalk.yellow("[infra destroy] Not implemented yet - coming in next PR")); + }); + +program + .command("exec") + .description("Execute a command on all droplets") + .action(() => { + console.log(chalk.yellow("[infra exec] Not implemented yet - coming in next PR")); + }); + +program + .command("update") + .description("Update code on all droplets") + .action(() => { + console.log(chalk.yellow("[infra update] Not implemented yet - coming in next PR")); + }); + +program + .command("collect") + .description("Collect results from all droplets") + .action(() => { + console.log(chalk.yellow("[infra collect] Not implemented yet - coming in next PR")); + }); + +program.parse(); + diff --git a/apps/load-tests/src/cli/results.ts b/apps/load-tests/src/cli/results.ts new file mode 100644 index 0000000..80a9295 --- /dev/null +++ b/apps/load-tests/src/cli/results.ts @@ -0,0 +1,20 @@ +#!/usr/bin/env node +import chalk from "chalk"; +import { Command } from "commander"; + +const program = new Command(); + +program + .name("results") + .description("Process and aggregate load test results") + .version("0.0.1"); + +program + .command("aggregate") + .description("Aggregate results from multiple load test runs") + .action(() => { + console.log(chalk.yellow("[results aggregate] Not implemented yet - coming in next PR")); + }); + +program.parse(); + diff --git a/apps/load-tests/src/cli/run.ts b/apps/load-tests/src/cli/run.ts new file mode 100644 index 0000000..be1cf63 --- /dev/null +++ b/apps/load-tests/src/cli/run.ts @@ -0,0 +1,167 @@ +#!/usr/bin/env node +import chalk from "chalk"; +import { Command } from "commander"; +import { printResults } from "../output/formatter.js"; +import type { TestResults } from "../output/types.js"; +import { writeResults } from "../output/writer.js"; +import { + isValidScenarioName, + runScenario, + type ScenarioOptions, + type ScenarioResult, +} from "../scenarios/index.js"; +import { calculateLatencyStats } from "../utils/stats.js"; + +/** + * CLI options as parsed by commander (strings). + */ +interface CliOptions { + target: string; + scenario: string; + connections: string; + duration: string; + rampUp: string; + output?: string; +} + +/** + * Parse CLI options into ScenarioOptions (with proper types). + */ +function parseOptions(cli: CliOptions): ScenarioOptions { + return { + target: cli.target, + connections: Number.parseInt(cli.connections, 10), + durationSec: Number.parseInt(cli.duration, 10), + rampUpSec: Number.parseInt(cli.rampUp, 10), + }; +} + +/** + * Transform ScenarioResult into TestResults for output. + */ +function buildTestResults( + scenarioName: string, + options: ScenarioOptions, + result: ScenarioResult, +): TestResults { + const { connections } = result; + + return { + scenario: scenarioName, + timestamp: new Date().toISOString(), + target: options.target, + config: { + connections: options.connections, + durationSec: options.durationSec, + rampUpSec: options.rampUpSec, + }, + results: { + connections: { + attempted: connections.attempted, + successful: connections.successful, + failed: connections.failed, + successRate: + connections.attempted > 0 + ? (connections.successful / connections.attempted) * 100 + : 0, + immediate: connections.immediate, + recovered: connections.recovered, + }, + timing: { + totalTimeMs: result.timing.totalTimeMs, + connectionsPerSec: + result.timing.totalTimeMs > 0 + ? (connections.attempted / result.timing.totalTimeMs) * 1000 + : 0, + }, + latency: calculateLatencyStats(result.timing.connectionLatencies), + retries: { + totalRetries: result.retries.totalRetries, + avgRetriesPerConnection: + connections.attempted > 0 + ? result.retries.totalRetries / connections.attempted + : 0, + }, + steadyState: result.steadyState + ? { + holdDurationMs: result.steadyState.holdDurationMs, + currentDisconnects: result.steadyState.currentDisconnects, + peakDisconnects: result.steadyState.peakDisconnects, + reconnectsDuringHold: result.steadyState.reconnectsDuringHold, + connectionStability: result.steadyState.connectionStability, + } + : undefined, + }, + }; +} + +const program = new Command(); + +program + .name("start") + .description("Run load tests against a Centrifugo relay server") + .version("0.0.1") + .requiredOption("--target ", "WebSocket URL of the relay server") + .option( + "--scenario ", + "Scenario to run: connection-storm, steady-state", + "connection-storm", + ) + .option("--connections ", "Number of connections to create", "100") + .option( + "--duration ", + "Test duration in seconds (for steady-state)", + "60", + ) + .option( + "--ramp-up ", + "Seconds to ramp up to full connection count", + "10", + ) + .option("--output ", "Path to write JSON results") + .action(async (cli: CliOptions) => { + // Validate scenario name + if (!isValidScenarioName(cli.scenario)) { + console.error(chalk.red(`[load-test] Unknown scenario: ${cli.scenario}`)); + console.error(chalk.yellow("[load-test] Available scenarios: connection-storm, steady-state")); + process.exit(1); + } + + // Parse options + const options = parseOptions(cli); + + // Print configuration + console.log(chalk.bold.blue("╔══════════════════════════════════════╗")); + console.log(chalk.bold.blue("║ LOAD TEST RUNNER ║")); + console.log(chalk.bold.blue("╚══════════════════════════════════════╝")); + console.log(""); + console.log(chalk.bold("Configuration:")); + console.log(` Target: ${chalk.dim(options.target)}`); + console.log(` Scenario: ${chalk.cyan(cli.scenario)}`); + console.log(` Connections: ${chalk.bold(options.connections)}`); + console.log(` Duration: ${options.durationSec}s`); + console.log(` Ramp-up: ${options.rampUpSec}s`); + if (cli.output) { + console.log(` Output: ${chalk.dim(cli.output)}`); + } + console.log(""); + + // Run scenario + const result = await runScenario(cli.scenario, options); + + // Build and display results + const testResults = buildTestResults(cli.scenario, options, result); + + console.log(""); + printResults(testResults); + + if (cli.output) { + console.log(""); + writeResults(cli.output, testResults); + } + + console.log(""); + console.log(chalk.green("✓ Done")); + }); + +program.parse(); diff --git a/apps/load-tests/src/client/centrifuge-client.ts b/apps/load-tests/src/client/centrifuge-client.ts new file mode 100644 index 0000000..24263ea --- /dev/null +++ b/apps/load-tests/src/client/centrifuge-client.ts @@ -0,0 +1,112 @@ +import { Centrifuge } from "centrifuge"; +import WebSocket from "ws"; + +/** + * Connection outcome types: + * - immediate: Connected on first try + * - recovered: Failed initially but reconnected successfully + * - failed: Could not connect after all retries + */ +export type ConnectionOutcome = "immediate" | "recovered" | "failed"; + +export interface ConnectionResult { + success: boolean; + outcome: ConnectionOutcome; + connectionTimeMs: number; + retryCount: number; + error?: string; +} + +export interface CentrifugeClientOptions { + url: string; + timeoutMs?: number; + minReconnectDelay?: number; + maxReconnectDelay?: number; +} + +/** + * Wrapper around the Centrifuge client for load testing. + * Connects to a Centrifugo server and measures connection time. + * Supports automatic reconnection with tracking of outcomes. + */ +export class CentrifugeClient { + private client: Centrifuge | null = null; + private readonly url: string; + private readonly timeoutMs: number; + private readonly minReconnectDelay: number; + private readonly maxReconnectDelay: number; + + constructor(options: CentrifugeClientOptions) { + this.url = options.url; + this.timeoutMs = options.timeoutMs ?? 30000; + this.minReconnectDelay = options.minReconnectDelay ?? 500; + this.maxReconnectDelay = options.maxReconnectDelay ?? 5000; + } + + /** + * Connect to the Centrifugo server. + * Returns connection timing, outcome, and retry info. + * Will wait for reconnection if initial connection fails. + */ + async connect(): Promise { + const startTime = performance.now(); + let retryCount = 0; + let hadError = false; + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + this.disconnect(); + resolve({ + success: false, + outcome: "failed", + connectionTimeMs: performance.now() - startTime, + retryCount, + error: `Connection timeout after ${this.timeoutMs}ms`, + }); + }, this.timeoutMs); + + this.client = new Centrifuge(this.url, { + websocket: WebSocket, + minReconnectDelay: this.minReconnectDelay, + maxReconnectDelay: this.maxReconnectDelay, + timeout: 10000, + }); + + this.client.on("connected", () => { + clearTimeout(timeout); + resolve({ + success: true, + outcome: hadError ? "recovered" : "immediate", + connectionTimeMs: performance.now() - startTime, + retryCount, + }); + }); + + // Track errors but don't resolve - let it retry + this.client.on("error", () => { + hadError = true; + retryCount++; + }); + + this.client.connect(); + }); + } + + /** + * Disconnect from the server. + */ + disconnect(): void { + if (this.client) { + this.client.disconnect(); + this.client = null; + } + } + + /** + * Check if currently connected. + */ + isConnected(): boolean { + return this.client?.state === "connected"; + } +} + diff --git a/apps/load-tests/src/output/formatter.ts b/apps/load-tests/src/output/formatter.ts new file mode 100644 index 0000000..0da42ca --- /dev/null +++ b/apps/load-tests/src/output/formatter.ts @@ -0,0 +1,66 @@ +import chalk from "chalk"; +import type { TestResults } from "./types.js"; + +/** + * Print test results summary to console. + */ +export function printResults(results: TestResults): void { + const { connections, timing, latency, retries, steadyState } = results.results; + + console.log(chalk.gray("─────────────────────────────────────")); + console.log(chalk.bold(" RESULTS SUMMARY")); + console.log(chalk.gray("─────────────────────────────────────")); + + // Connection summary with color-coded success rate + const successRate = connections.successRate; + const rateColor = successRate >= 99 ? chalk.green : successRate >= 95 ? chalk.yellow : chalk.red; + console.log( + `Connections: ${connections.attempted} attempted, ${connections.successful} successful (${rateColor(successRate.toFixed(1) + "%")})`, + ); + + // Breakdown with icons + console.log( + ` ${chalk.green("✓")} Immediate: ${connections.immediate} | ${chalk.yellow("↻")} Recovered: ${connections.recovered} | ${chalk.red("✗")} Failed: ${connections.failed}`, + ); + + // Timing + console.log(`Total time: ${Math.round(timing.totalTimeMs)}ms`); + console.log(`Rate: ${timing.connectionsPerSec.toFixed(1)} conn/sec`); + + // Latency with color-coded p95 + if (latency) { + const p95Color = latency.p95 <= 100 ? chalk.green : latency.p95 <= 400 ? chalk.yellow : chalk.red; + console.log( + `Latency: min=${latency.min}ms, max=${latency.max}ms, avg=${latency.avg}ms, p95=${p95Color(latency.p95 + "ms")}`, + ); + } + + // Retries (only if any) + if (retries.totalRetries > 0) { + console.log( + chalk.yellow(`Retries: ${retries.totalRetries} total (avg ${retries.avgRetriesPerConnection.toFixed(1)} per conn)`), + ); + } + + // Steady-state specific metrics + if (steadyState) { + console.log(`Hold: ${Math.round(steadyState.holdDurationMs / 1000)}s`); + + const disconnectColor = steadyState.currentDisconnects === 0 ? chalk.green : chalk.red; + console.log( + `Disconnects: ${disconnectColor(steadyState.currentDisconnects.toString())} current, ${steadyState.peakDisconnects} peak`, + ); + + if (steadyState.reconnectsDuringHold > 0) { + console.log(chalk.yellow(`Reconnects: ${steadyState.reconnectsDuringHold} during hold`)); + } + + const stabilityColor = + steadyState.connectionStability >= 99.9 + ? chalk.green + : steadyState.connectionStability >= 99 + ? chalk.yellow + : chalk.red; + console.log(`Stability: ${stabilityColor(steadyState.connectionStability.toFixed(1) + "%")}`); + } +} diff --git a/apps/load-tests/src/output/types.ts b/apps/load-tests/src/output/types.ts new file mode 100644 index 0000000..c037138 --- /dev/null +++ b/apps/load-tests/src/output/types.ts @@ -0,0 +1,50 @@ +/** + * Complete test results structure for JSON output. + * This is the final output format - built from ScenarioResult in the CLI. + */ +export interface TestResults { + scenario: string; + timestamp: string; + target: string; + config: { + connections: number; + durationSec: number; + rampUpSec: number; + }; + results: { + connections: { + attempted: number; + successful: number; + failed: number; + successRate: number; + /** Connected on first try */ + immediate: number; + /** Failed initially but recovered via reconnect */ + recovered: number; + }; + timing: { + totalTimeMs: number; + connectionsPerSec: number; + }; + latency: { + min: number; + max: number; + avg: number; + p95: number; + } | null; + retries: { + totalRetries: number; + avgRetriesPerConnection: number; + }; + steadyState?: { + holdDurationMs: number; + /** Current number of disconnected clients at end of hold */ + currentDisconnects: number; + /** Peak number of disconnects seen at any point during hold */ + peakDisconnects: number; + /** Number of times clients reconnected during hold */ + reconnectsDuringHold: number; + connectionStability: number; + }; + }; +} diff --git a/apps/load-tests/src/output/writer.ts b/apps/load-tests/src/output/writer.ts new file mode 100644 index 0000000..5057a3e --- /dev/null +++ b/apps/load-tests/src/output/writer.ts @@ -0,0 +1,16 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { TestResults } from "./types.js"; + +/** + * Write test results to a JSON file. + */ +export function writeResults(outputPath: string, results: TestResults): void { + const dir = path.dirname(outputPath); + if (dir && !fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(outputPath, JSON.stringify(results, null, 2)); + console.log(`[load-test] Results written to ${outputPath}`); +} + diff --git a/apps/load-tests/src/scenarios/connection-storm.ts b/apps/load-tests/src/scenarios/connection-storm.ts new file mode 100644 index 0000000..f448f73 --- /dev/null +++ b/apps/load-tests/src/scenarios/connection-storm.ts @@ -0,0 +1,117 @@ +import chalk from "chalk"; +import { + CentrifugeClient, + type ConnectionResult, +} from "../client/centrifuge-client.js"; +import { + createConnectionProgressBar, + startProgressBar, + stopProgressBar, + updateProgressBar, +} from "../utils/progress.js"; +import { sleep } from "../utils/timing.js"; +import type { ScenarioOptions, ScenarioResult } from "./types.js"; + +/** + * Connection storm scenario: + * Rapidly connect many clients with optional pacing, then disconnect. + * Tests raw connection handling capacity. + */ +export async function runConnectionStorm( + options: ScenarioOptions, +): Promise { + const { target, connections, rampUpSec } = options; + + // Calculate pacing: spread connection starts over ramp-up period + const connectionDelay = rampUpSec > 0 ? (rampUpSec * 1000) / connections : 0; + + console.log(`${chalk.cyan("[connection-storm]")} Connecting ${chalk.bold(connections)} client(s) to ${chalk.dim(target)}`); + if (connectionDelay > 0) { + console.log( + `${chalk.cyan("[connection-storm]")} Pacing: ${chalk.bold((1000 / connectionDelay).toFixed(1))} conn/sec over ${rampUpSec}s`, + ); + } + console.log(""); + + const startTime = performance.now(); + const clients: CentrifugeClient[] = []; + const connectionResults: ConnectionResult[] = []; + + // Create progress bar + const progressBar = createConnectionProgressBar("[connection-storm]"); + startProgressBar(progressBar, connections); + + // Create and connect all clients with pacing + const connectPromises: Promise[] = []; + + for (let i = 0; i < connections; i++) { + const client = new CentrifugeClient({ url: target }); + clients.push(client); + + connectPromises.push( + client.connect().then((result) => { + connectionResults.push(result); + const immediate = connectionResults.filter((r) => r.outcome === "immediate").length; + const recovered = connectionResults.filter((r) => r.outcome === "recovered").length; + const failed = connectionResults.filter((r) => r.outcome === "failed").length; + updateProgressBar(progressBar, connectionResults.length, { immediate, recovered, failed }); + }), + ); + + // Pace connection starts (but don't wait for connection to complete) + if (i < connections - 1 && connectionDelay > 0) { + await sleep(connectionDelay); + } + } + + await Promise.all(connectPromises); + stopProgressBar(progressBar); + + const totalTime = performance.now() - startTime; + + console.log(""); + + const immediate = connectionResults.filter((r) => r.outcome === "immediate"); + const recovered = connectionResults.filter((r) => r.outcome === "recovered"); + const failed = connectionResults.filter((r) => r.outcome === "failed"); + const successful = connectionResults.filter((r) => r.success); + const latencies = successful.map((r) => r.connectionTimeMs); + const totalRetries = connectionResults.reduce((sum, r) => sum + r.retryCount, 0); + + // Print errors if any + if (failed.length > 0) { + const errorCounts = new Map(); + for (const f of failed) { + const err = f.error ?? "Unknown error"; + errorCounts.set(err, (errorCounts.get(err) ?? 0) + 1); + } + console.log(chalk.red("Errors:")); + for (const [err, count] of errorCounts) { + console.log(chalk.red(` ${count}x: ${err}`)); + } + console.log(""); + } + + // Disconnect all clients + console.log(`${chalk.cyan("[connection-storm]")} Disconnecting clients...`); + for (const client of clients) { + client.disconnect(); + } + + return { + connections: { + attempted: connections, + successful: successful.length, + failed: failed.length, + immediate: immediate.length, + recovered: recovered.length, + }, + timing: { + totalTimeMs: totalTime, + connectionLatencies: latencies, + }, + retries: { + totalRetries, + }, + }; +} diff --git a/apps/load-tests/src/scenarios/index.ts b/apps/load-tests/src/scenarios/index.ts new file mode 100644 index 0000000..73c2f63 --- /dev/null +++ b/apps/load-tests/src/scenarios/index.ts @@ -0,0 +1,31 @@ +import { runConnectionStorm } from "./connection-storm.js"; +import { runSteadyState } from "./steady-state.js"; +import type { ScenarioName, ScenarioOptions, ScenarioResult } from "./types.js"; + +export type { ScenarioName, ScenarioOptions, ScenarioResult }; + +/** + * Run a scenario by name. + * This is the main entry point for executing load test scenarios. + */ +export async function runScenario( + name: ScenarioName, + options: ScenarioOptions, +): Promise { + switch (name) { + case "connection-storm": + return runConnectionStorm(options); + case "steady-state": + return runSteadyState(options); + default: + throw new Error(`Unknown scenario: ${name as string}`); + } +} + +/** + * Check if a string is a valid scenario name. + */ +export function isValidScenarioName(name: string): name is ScenarioName { + return name === "connection-storm" || name === "steady-state"; +} + diff --git a/apps/load-tests/src/scenarios/steady-state.ts b/apps/load-tests/src/scenarios/steady-state.ts new file mode 100644 index 0000000..aed7bae --- /dev/null +++ b/apps/load-tests/src/scenarios/steady-state.ts @@ -0,0 +1,200 @@ +import chalk from "chalk"; +import { + CentrifugeClient, + type ConnectionResult, +} from "../client/centrifuge-client.js"; +import { + createConnectionProgressBar, + startProgressBar, + stopProgressBar, + updateProgressBar, +} from "../utils/progress.js"; +import { sleep } from "../utils/timing.js"; +import type { ScenarioOptions, ScenarioResult } from "./types.js"; + +/** + * Steady state scenario: + * 1. Ramp up connections over rampUpSec (in parallel with proper pacing) + * 2. Hold connections for durationSec + * 3. Track disconnects during hold + * 4. Disconnect all at end + */ +export async function runSteadyState( + options: ScenarioOptions, +): Promise { + const { target, connections, durationSec, rampUpSec } = options; + + console.log( + `${chalk.cyan("[steady-state]")} Ramping up to ${chalk.bold(connections)} connections over ${rampUpSec}s...`, + ); + console.log(""); + + const clients: CentrifugeClient[] = []; + const connectionResults: ConnectionResult[] = []; + let peakDisconnects = 0; + let reconnectsDuringHold = 0; + let previousDisconnectCount = 0; + + const rampUpStart = performance.now(); + const connectionDelay = (rampUpSec * 1000) / connections; + + // Create progress bar + const progressBar = createConnectionProgressBar("[steady-state]"); + startProgressBar(progressBar, connections); + + // Ramp up phase - fire connections in parallel with pacing + const connectPromises: Promise[] = []; + + for (let i = 0; i < connections; i++) { + const client = new CentrifugeClient({ url: target }); + clients.push(client); + + // Fire connection (don't await - let it run in parallel) + const connectPromise = client.connect().then((result) => { + connectionResults.push(result); + const immediate = connectionResults.filter((r) => r.outcome === "immediate").length; + const recovered = connectionResults.filter((r) => r.outcome === "recovered").length; + const failed = connectionResults.filter((r) => r.outcome === "failed").length; + updateProgressBar(progressBar, connectionResults.length, { immediate, recovered, failed }); + }); + connectPromises.push(connectPromise); + + // Pace the connection starts (but don't wait for connection to complete) + if (i < connections - 1 && connectionDelay > 0) { + await sleep(connectionDelay); + } + } + + // Wait for all connections to complete + await Promise.all(connectPromises); + stopProgressBar(progressBar); + + const rampUpTime = performance.now() - rampUpStart; + const successfulConnections = connectionResults.filter((r) => r.success).length; + + console.log(""); + console.log( + `${chalk.cyan("[steady-state]")} Ramp complete: ${chalk.green(successfulConnections)}/${connections} connected in ${Math.round(rampUpTime)}ms`, + ); + + if (successfulConnections === 0) { + console.log(chalk.red("[steady-state] No successful connections, skipping hold phase")); + return buildResult(connectionResults, connections, rampUpTime, 0, 0, 0, 0); + } + + // Hold phase - keep connections open and monitor + console.log(`${chalk.cyan("[steady-state]")} Holding for ${chalk.bold(durationSec)}s...`); + + const holdStart = performance.now(); + const holdEndTime = holdStart + durationSec * 1000; + let lastLogTime = holdStart; + const logInterval = 5000; // Log every 5 seconds + + while (performance.now() < holdEndTime) { + // Check for disconnects + const currentActive = clients.filter((c) => c.isConnected()).length; + const currentDisconnectCount = successfulConnections - currentActive; + + // Track peak disconnects (high water mark) + if (currentDisconnectCount > peakDisconnects) { + peakDisconnects = currentDisconnectCount; + } + + // Track reconnections: if disconnect count decreased, clients reconnected + if (currentDisconnectCount < previousDisconnectCount) { + reconnectsDuringHold += previousDisconnectCount - currentDisconnectCount; + } + previousDisconnectCount = currentDisconnectCount; + + // Log status periodically + if (performance.now() - lastLogTime >= logInterval) { + const elapsed = Math.round((performance.now() - holdStart) / 1000); + const activeColor = currentActive === successfulConnections ? chalk.green : chalk.yellow; + const disconnectColor = currentDisconnectCount === 0 ? chalk.green : chalk.red; + console.log( + `${chalk.cyan("[steady-state]")} ${chalk.dim(`[${elapsed}s]`)} Active: ${activeColor(currentActive)}/${successfulConnections} | Disconnected: ${disconnectColor(currentDisconnectCount)} (peak: ${peakDisconnects}) | Reconnects: ${reconnectsDuringHold}`, + ); + lastLogTime = performance.now(); + } + + await sleep(100); // Check every 100ms + } + + const holdDuration = performance.now() - holdStart; + + // Final check + const finalActive = clients.filter((c) => c.isConnected()).length; + const finalDisconnects = successfulConnections - finalActive; + + const activeColor = finalActive === successfulConnections ? chalk.green : chalk.yellow; + const disconnectColor = finalDisconnects === 0 ? chalk.green : chalk.red; + console.log( + `${chalk.cyan("[steady-state]")} Hold complete: ${activeColor(finalActive)}/${successfulConnections} active | Final disconnects: ${disconnectColor(finalDisconnects)} | Peak: ${peakDisconnects} | Reconnects: ${reconnectsDuringHold}`, + ); + + // Disconnect all clients + console.log(`${chalk.cyan("[steady-state]")} Disconnecting clients...`); + for (const client of clients) { + client.disconnect(); + } + + // Connection stability = percentage that stayed connected the whole time + const connectionStability = + successfulConnections > 0 + ? ((successfulConnections - finalDisconnects) / successfulConnections) * 100 + : 0; + + return buildResult( + connectionResults, + connections, + rampUpTime, + holdDuration, + finalDisconnects, + peakDisconnects, + reconnectsDuringHold, + connectionStability, + ); +} + +function buildResult( + connectionResults: ConnectionResult[], + totalConnections: number, + rampUpTimeMs: number, + holdDurationMs: number, + currentDisconnects: number, + peakDisconnects: number, + reconnectsDuringHold: number, + connectionStability = 0, +): ScenarioResult { + const immediate = connectionResults.filter((r) => r.outcome === "immediate"); + const recovered = connectionResults.filter((r) => r.outcome === "recovered"); + const failed = connectionResults.filter((r) => r.outcome === "failed"); + const successful = connectionResults.filter((r) => r.success); + const latencies = successful.map((r) => r.connectionTimeMs); + const totalRetries = connectionResults.reduce((sum, r) => sum + r.retryCount, 0); + + return { + connections: { + attempted: totalConnections, + successful: successful.length, + failed: failed.length, + immediate: immediate.length, + recovered: recovered.length, + }, + timing: { + totalTimeMs: rampUpTimeMs + holdDurationMs, + connectionLatencies: latencies, + }, + retries: { + totalRetries, + }, + steadyState: { + rampUpTimeMs, + holdDurationMs, + currentDisconnects, + peakDisconnects, + reconnectsDuringHold, + connectionStability, + }, + }; +} diff --git a/apps/load-tests/src/scenarios/types.ts b/apps/load-tests/src/scenarios/types.ts new file mode 100644 index 0000000..93e9e11 --- /dev/null +++ b/apps/load-tests/src/scenarios/types.ts @@ -0,0 +1,50 @@ +/** + * Parsed options for running a scenario. + * These are already parsed (numbers, not strings) - CLI parsing happens in cli/run.ts + */ +export interface ScenarioOptions { + target: string; + connections: number; + durationSec: number; + rampUpSec: number; +} + +/** + * Common result type returned by all scenarios. + * This is the "raw" result - the CLI wraps this in TestResults for output. + */ +export interface ScenarioResult { + /** Connection metrics */ + connections: { + attempted: number; + successful: number; + failed: number; + immediate: number; + recovered: number; + }; + + /** Timing metrics */ + timing: { + totalTimeMs: number; + /** Raw latencies for percentile calculation */ + connectionLatencies: number[]; + }; + + /** Retry metrics */ + retries: { + totalRetries: number; + }; + + /** Steady-state specific metrics (only present for steady-state scenario) */ + steadyState?: { + rampUpTimeMs: number; + holdDurationMs: number; + currentDisconnects: number; + peakDisconnects: number; + reconnectsDuringHold: number; + connectionStability: number; + }; +} + +export type ScenarioName = "connection-storm" | "steady-state"; + diff --git a/apps/load-tests/src/utils/progress.ts b/apps/load-tests/src/utils/progress.ts new file mode 100644 index 0000000..1d4ca49 --- /dev/null +++ b/apps/load-tests/src/utils/progress.ts @@ -0,0 +1,54 @@ +import chalk from "chalk"; +import cliProgress from "cli-progress"; + +export interface ConnectionProgress { + immediate: number; + recovered: number; + failed: number; +} + +/** + * Create a progress bar for connection tracking. + */ +export function createConnectionProgressBar(label: string): cliProgress.SingleBar { + return new cliProgress.SingleBar( + { + format: `${chalk.cyan(label)} ${chalk.gray("|")} {bar} ${chalk.gray("|")} {value}/{total} (${chalk.green("✓")} {immediate} ${chalk.yellow("↻")} {recovered} ${chalk.red("✗")} {failed})`, + barCompleteChar: "█", + barIncompleteChar: "░", + hideCursor: true, + clearOnComplete: false, + stopOnComplete: true, + }, + cliProgress.Presets.shades_classic, + ); +} + +/** + * Start a connection progress bar. + */ +export function startProgressBar( + bar: cliProgress.SingleBar, + total: number, +): void { + bar.start(total, 0, { immediate: 0, recovered: 0, failed: 0 }); +} + +/** + * Update a connection progress bar. + */ +export function updateProgressBar( + bar: cliProgress.SingleBar, + current: number, + progress: ConnectionProgress, +): void { + bar.update(current, progress); +} + +/** + * Stop a progress bar. + */ +export function stopProgressBar(bar: cliProgress.SingleBar): void { + bar.stop(); +} + diff --git a/apps/load-tests/src/utils/stats.ts b/apps/load-tests/src/utils/stats.ts new file mode 100644 index 0000000..0dca8dd --- /dev/null +++ b/apps/load-tests/src/utils/stats.ts @@ -0,0 +1,25 @@ +/** + * Latency statistics for a set of measurements. + */ +export interface LatencyStats { + min: number; + max: number; + avg: number; + p95: number; +} + +/** + * Calculate latency statistics from an array of latency measurements. + * Returns null if the array is empty. + */ +export function calculateLatencyStats(latencies: number[]): LatencyStats | null { + if (latencies.length === 0) return null; + const sorted = [...latencies].sort((a, b) => a - b); + return { + min: Math.round(Math.min(...sorted)), + max: Math.round(Math.max(...sorted)), + avg: Math.round(sorted.reduce((a, b) => a + b, 0) / sorted.length), + p95: Math.round(sorted[Math.floor((sorted.length - 1) * 0.95)] ?? 0), + }; +} + diff --git a/apps/load-tests/src/utils/timing.ts b/apps/load-tests/src/utils/timing.ts new file mode 100644 index 0000000..ed06d63 --- /dev/null +++ b/apps/load-tests/src/utils/timing.ts @@ -0,0 +1,7 @@ +/** + * Sleep for the specified number of milliseconds. + */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + diff --git a/apps/load-tests/tsconfig.json b/apps/load-tests/tsconfig.json new file mode 100644 index 0000000..877518b --- /dev/null +++ b/apps/load-tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts"], + "compilerOptions": { + "noEmit": true + } +} From 8ff3d93e108d61cf790ca767bceb3054002a910f Mon Sep 17 00:00:00 2001 From: Tamas Date: Mon, 12 Jan 2026 11:58:52 +0100 Subject: [PATCH 2/4] fix(load-tests): address code review findings - Rename 'latency' to 'connectTime' for clarity (measures connection establishment time, NOT message RTT) - Add p50 and p99 percentiles to connectTime stats - Fix connectionStability comment to accurately describe what it measures (percentage connected at end, not 'stayed connected the whole time') - Add temp/ to gitignore for working documents - Update lavamoat config for tsx>esbuild --- .gitignore | 5 +- apps/load-tests/src/cli/run.ts | 4 +- apps/load-tests/src/output/formatter.ts | 10 +- apps/load-tests/src/output/types.ts | 5 +- apps/load-tests/src/scenarios/steady-state.ts | 4 +- apps/load-tests/src/utils/stats.ts | 18 +- package.json | 3 +- yarn.lock | 439 +++++++++++++++++- 8 files changed, 468 insertions(+), 20 deletions(-) diff --git a/.gitignore b/.gitignore index 6a65f17..3244490 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,7 @@ apps/*/*.tsbuildinfo .llm.txt .llm-packages.txt .llm-apps.txt -.todo.md \ No newline at end of file +.todo.md + +# Temp working documents +temp/ \ No newline at end of file diff --git a/apps/load-tests/src/cli/run.ts b/apps/load-tests/src/cli/run.ts index be1cf63..d4464f2 100644 --- a/apps/load-tests/src/cli/run.ts +++ b/apps/load-tests/src/cli/run.ts @@ -10,7 +10,7 @@ import { type ScenarioOptions, type ScenarioResult, } from "../scenarios/index.js"; -import { calculateLatencyStats } from "../utils/stats.js"; +import { calculateConnectTimeStats } from "../utils/stats.js"; /** * CLI options as parsed by commander (strings). @@ -74,7 +74,7 @@ function buildTestResults( ? (connections.attempted / result.timing.totalTimeMs) * 1000 : 0, }, - latency: calculateLatencyStats(result.timing.connectionLatencies), + connectTime: calculateConnectTimeStats(result.timing.connectionLatencies), retries: { totalRetries: result.retries.totalRetries, avgRetriesPerConnection: diff --git a/apps/load-tests/src/output/formatter.ts b/apps/load-tests/src/output/formatter.ts index 0da42ca..2bdf520 100644 --- a/apps/load-tests/src/output/formatter.ts +++ b/apps/load-tests/src/output/formatter.ts @@ -5,7 +5,7 @@ import type { TestResults } from "./types.js"; * Print test results summary to console. */ export function printResults(results: TestResults): void { - const { connections, timing, latency, retries, steadyState } = results.results; + const { connections, timing, connectTime, retries, steadyState } = results.results; console.log(chalk.gray("─────────────────────────────────────")); console.log(chalk.bold(" RESULTS SUMMARY")); @@ -27,11 +27,11 @@ export function printResults(results: TestResults): void { console.log(`Total time: ${Math.round(timing.totalTimeMs)}ms`); console.log(`Rate: ${timing.connectionsPerSec.toFixed(1)} conn/sec`); - // Latency with color-coded p95 - if (latency) { - const p95Color = latency.p95 <= 100 ? chalk.green : latency.p95 <= 400 ? chalk.yellow : chalk.red; + // Connection time with color-coded p95 + if (connectTime) { + const p95Color = connectTime.p95 <= 100 ? chalk.green : connectTime.p95 <= 400 ? chalk.yellow : chalk.red; console.log( - `Latency: min=${latency.min}ms, max=${latency.max}ms, avg=${latency.avg}ms, p95=${p95Color(latency.p95 + "ms")}`, + `Connect: min=${connectTime.min}ms, avg=${connectTime.avg}ms, p50=${connectTime.p50}ms, p95=${p95Color(connectTime.p95 + "ms")}, p99=${connectTime.p99}ms, max=${connectTime.max}ms`, ); } diff --git a/apps/load-tests/src/output/types.ts b/apps/load-tests/src/output/types.ts index c037138..dbd0f2f 100644 --- a/apps/load-tests/src/output/types.ts +++ b/apps/load-tests/src/output/types.ts @@ -26,11 +26,14 @@ export interface TestResults { totalTimeMs: number; connectionsPerSec: number; }; - latency: { + /** Connection establishment time (NOT message RTT) */ + connectTime: { min: number; max: number; avg: number; + p50: number; p95: number; + p99: number; } | null; retries: { totalRetries: number; diff --git a/apps/load-tests/src/scenarios/steady-state.ts b/apps/load-tests/src/scenarios/steady-state.ts index aed7bae..1ab1bb3 100644 --- a/apps/load-tests/src/scenarios/steady-state.ts +++ b/apps/load-tests/src/scenarios/steady-state.ts @@ -138,7 +138,9 @@ export async function runSteadyState( client.disconnect(); } - // Connection stability = percentage that stayed connected the whole time + // Connection stability = percentage still connected at end of hold period + // Note: This does NOT mean "stayed connected the whole time" - clients may have + // disconnected and reconnected during the hold. Use peakDisconnects for worst-case. const connectionStability = successfulConnections > 0 ? ((successfulConnections - finalDisconnects) / successfulConnections) * 100 diff --git a/apps/load-tests/src/utils/stats.ts b/apps/load-tests/src/utils/stats.ts index 0dca8dd..4a72873 100644 --- a/apps/load-tests/src/utils/stats.ts +++ b/apps/load-tests/src/utils/stats.ts @@ -1,25 +1,31 @@ /** - * Latency statistics for a set of measurements. + * Connection time statistics for a set of measurements. + * Note: This measures the time to establish a WebSocket connection, + * NOT message round-trip latency. */ -export interface LatencyStats { +export interface ConnectTimeStats { min: number; max: number; avg: number; + p50: number; p95: number; + p99: number; } /** - * Calculate latency statistics from an array of latency measurements. + * Calculate connection time statistics from an array of timing measurements. * Returns null if the array is empty. */ -export function calculateLatencyStats(latencies: number[]): LatencyStats | null { - if (latencies.length === 0) return null; - const sorted = [...latencies].sort((a, b) => a - b); +export function calculateConnectTimeStats(times: number[]): ConnectTimeStats | null { + if (times.length === 0) return null; + const sorted = [...times].sort((a, b) => a - b); return { min: Math.round(Math.min(...sorted)), max: Math.round(Math.max(...sorted)), avg: Math.round(sorted.reduce((a, b) => a + b, 0) / sorted.length), + p50: Math.round(sorted[Math.floor((sorted.length - 1) * 0.5)] ?? 0), p95: Math.round(sorted[Math.floor((sorted.length - 1) * 0.95)] ?? 0), + p99: Math.round(sorted[Math.floor((sorted.length - 1) * 0.99)] ?? 0), }; } diff --git a/package.json b/package.json index e9bed1d..56567d5 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,8 @@ "allowScripts": { "@lavamoat/preinstall-always-fail": false, "simple-git-hooks": false, - "tsup>esbuild": false + "tsup>esbuild": false, + "tsup>postcss-load-config>tsx>esbuild": false } }, "dependencies": { diff --git a/yarn.lock b/yarn.lock index 914fd93..f7a36d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1301,6 +1301,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/aix-ppc64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/aix-ppc64@npm:0.27.2" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/android-arm64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/android-arm64@npm:0.25.8" @@ -1308,6 +1315,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/android-arm64@npm:0.27.2" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/android-arm@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/android-arm@npm:0.25.8" @@ -1315,6 +1329,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/android-arm@npm:0.27.2" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@esbuild/android-x64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/android-x64@npm:0.25.8" @@ -1322,6 +1343,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/android-x64@npm:0.27.2" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + "@esbuild/darwin-arm64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/darwin-arm64@npm:0.25.8" @@ -1329,6 +1357,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/darwin-arm64@npm:0.27.2" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/darwin-x64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/darwin-x64@npm:0.25.8" @@ -1336,6 +1371,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/darwin-x64@npm:0.27.2" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@esbuild/freebsd-arm64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/freebsd-arm64@npm:0.25.8" @@ -1343,6 +1385,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/freebsd-arm64@npm:0.27.2" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/freebsd-x64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/freebsd-x64@npm:0.25.8" @@ -1350,6 +1399,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/freebsd-x64@npm:0.27.2" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/linux-arm64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/linux-arm64@npm:0.25.8" @@ -1357,6 +1413,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-arm64@npm:0.27.2" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/linux-arm@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/linux-arm@npm:0.25.8" @@ -1364,6 +1427,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-arm@npm:0.27.2" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "@esbuild/linux-ia32@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/linux-ia32@npm:0.25.8" @@ -1371,6 +1441,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ia32@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-ia32@npm:0.27.2" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/linux-loong64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/linux-loong64@npm:0.25.8" @@ -1378,6 +1455,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-loong64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-loong64@npm:0.27.2" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + "@esbuild/linux-mips64el@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/linux-mips64el@npm:0.25.8" @@ -1385,6 +1469,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-mips64el@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-mips64el@npm:0.27.2" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + "@esbuild/linux-ppc64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/linux-ppc64@npm:0.25.8" @@ -1392,6 +1483,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ppc64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-ppc64@npm:0.27.2" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/linux-riscv64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/linux-riscv64@npm:0.25.8" @@ -1399,6 +1497,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-riscv64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-riscv64@npm:0.27.2" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + "@esbuild/linux-s390x@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/linux-s390x@npm:0.25.8" @@ -1406,6 +1511,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-s390x@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-s390x@npm:0.27.2" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + "@esbuild/linux-x64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/linux-x64@npm:0.25.8" @@ -1413,6 +1525,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-x64@npm:0.27.2" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + "@esbuild/netbsd-arm64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/netbsd-arm64@npm:0.25.8" @@ -1420,6 +1539,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/netbsd-arm64@npm:0.27.2" + conditions: os=netbsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/netbsd-x64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/netbsd-x64@npm:0.25.8" @@ -1427,6 +1553,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/netbsd-x64@npm:0.27.2" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openbsd-arm64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/openbsd-arm64@npm:0.25.8" @@ -1434,6 +1567,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/openbsd-arm64@npm:0.27.2" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/openbsd-x64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/openbsd-x64@npm:0.25.8" @@ -1441,6 +1581,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/openbsd-x64@npm:0.27.2" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openharmony-arm64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/openharmony-arm64@npm:0.25.8" @@ -1448,6 +1595,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openharmony-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/openharmony-arm64@npm:0.27.2" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/sunos-x64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/sunos-x64@npm:0.25.8" @@ -1455,6 +1609,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/sunos-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/sunos-x64@npm:0.27.2" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + "@esbuild/win32-arm64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/win32-arm64@npm:0.25.8" @@ -1462,6 +1623,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/win32-arm64@npm:0.27.2" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/win32-ia32@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/win32-ia32@npm:0.25.8" @@ -1469,6 +1637,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-ia32@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/win32-ia32@npm:0.27.2" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/win32-x64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/win32-x64@npm:0.25.8" @@ -1476,6 +1651,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/win32-x64@npm:0.27.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@eslint-community/eslint-utils@npm:^4.7.0, @eslint-community/eslint-utils@npm:^4.8.0": version: 4.9.0 resolution: "@eslint-community/eslint-utils@npm:4.9.0" @@ -2624,6 +2806,26 @@ __metadata: languageName: unknown linkType: soft +"@metamask/mobile-wallet-protocol-load-tests@workspace:apps/load-tests": + version: 0.0.0-use.local + resolution: "@metamask/mobile-wallet-protocol-load-tests@workspace:apps/load-tests" + dependencies: + "@types/cli-progress": ^3.11.6 + "@types/node": ^24.0.3 + "@types/ssh2": ^1.15.4 + "@types/ws": ^8.18.1 + centrifuge: ^5.3.5 + chalk: ^5.6.2 + cli-progress: ^3.12.0 + commander: ^13.1.0 + dotenv: ^16.5.0 + ssh2: ^1.16.0 + tsx: ^4.20.3 + typescript: ^5.8.3 + ws: ^8.18.3 + languageName: unknown + linkType: soft + "@metamask/mobile-wallet-protocol-wallet-client@workspace:^, @metamask/mobile-wallet-protocol-wallet-client@workspace:packages/wallet-client": version: 0.0.0-use.local resolution: "@metamask/mobile-wallet-protocol-wallet-client@workspace:packages/wallet-client" @@ -4190,6 +4392,15 @@ __metadata: languageName: node linkType: hard +"@types/cli-progress@npm:^3.11.6": + version: 3.11.6 + resolution: "@types/cli-progress@npm:3.11.6" + dependencies: + "@types/node": "*" + checksum: 2df9d4788089564c8eb01e6d05b084bd030b7ce3f1a3698c57a998f2b329c5c7a3ea2d20e3756579a385945c70875df3c798b7740f6bf679eb1b1937e91f5eca + languageName: node + linkType: hard + "@types/debug@npm:^4.1.7": version: 4.1.12 resolution: "@types/debug@npm:4.1.12" @@ -4284,6 +4495,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^18.11.18": + version: 18.19.130 + resolution: "@types/node@npm:18.19.130" + dependencies: + undici-types: ~5.26.4 + checksum: b7032363581c416e721a88cffdc2b47662337cacd20f8294f5619a1abf79615c7fef1521964c2aa9d36ed6aae733e1a03e8c704661bd5a0c2f34b390f41ea395 + languageName: node + linkType: hard + "@types/node@npm:^20": version: 20.19.23 resolution: "@types/node@npm:20.19.23" @@ -4350,6 +4570,15 @@ __metadata: languageName: node linkType: hard +"@types/ssh2@npm:^1.15.4": + version: 1.15.5 + resolution: "@types/ssh2@npm:1.15.5" + dependencies: + "@types/node": ^18.11.18 + checksum: 158ce6644f6784b1f53d93f39d7b97291f97a45e756af6fd4e2d8b0f72800248137826e03b3218caadda5d769b882a06f2ab0981d57a55632658c54898fafc4a + languageName: node + linkType: hard + "@types/stack-utils@npm:^2.0.0": version: 2.0.3 resolution: "@types/stack-utils@npm:2.0.3" @@ -5133,6 +5362,15 @@ __metadata: languageName: node linkType: hard +"asn1@npm:^0.2.6": + version: 0.2.6 + resolution: "asn1@npm:0.2.6" + dependencies: + safer-buffer: ~2.1.0 + checksum: 39f2ae343b03c15ad4f238ba561e626602a3de8d94ae536c46a4a93e69578826305366dc09fbb9b56aec39b4982a463682f259c38e59f6fa380cd72cd61e493d + languageName: node + linkType: hard + "assertion-error@npm:^2.0.1": version: 2.0.1 resolution: "assertion-error@npm:2.0.1" @@ -5431,6 +5669,15 @@ __metadata: languageName: node linkType: hard +"bcrypt-pbkdf@npm:^1.0.2": + version: 1.0.2 + resolution: "bcrypt-pbkdf@npm:1.0.2" + dependencies: + tweetnacl: ^0.14.3 + checksum: 4edfc9fe7d07019609ccf797a2af28351736e9d012c8402a07120c4453a3b789a15f2ee1530dc49eee8f7eb9379331a8dd4b3766042b9e502f74a68e7f662291 + languageName: node + linkType: hard + "better-opn@npm:~3.0.2": version: 3.0.2 resolution: "better-opn@npm:3.0.2" @@ -5585,6 +5832,13 @@ __metadata: languageName: node linkType: hard +"buildcheck@npm:~0.0.6": + version: 0.0.7 + resolution: "buildcheck@npm:0.0.7" + checksum: 18bc4581525776dc7486906241723a0b2bc6d9d55bdbf8aa3ac225ed02c9dfc01be06020a5cce58b1630edd8a1ba1ce3fc51959bbbafaabcef05f9e7707210de + languageName: node + linkType: hard + "bundle-name@npm:^4.1.0": version: 4.1.0 resolution: "bundle-name@npm:4.1.0" @@ -5763,6 +6017,13 @@ __metadata: languageName: node linkType: hard +"chalk@npm:^5.6.2": + version: 5.6.2 + resolution: "chalk@npm:5.6.2" + checksum: 4ee2d47a626d79ca27cb5299ecdcce840ef5755e287412536522344db0fc51ca0f6d6433202332c29e2288c6a90a2b31f3bd626bc8c14743b6b6ee28abd3b796 + languageName: node + linkType: hard + "check-error@npm:^2.1.1": version: 2.1.1 resolution: "check-error@npm:2.1.1" @@ -5851,6 +6112,15 @@ __metadata: languageName: node linkType: hard +"cli-progress@npm:^3.12.0": + version: 3.12.0 + resolution: "cli-progress@npm:3.12.0" + dependencies: + string-width: ^4.2.3 + checksum: e8390dc3cdf3c72ecfda0a1e8997bfed63a0d837f97366bbce0ca2ff1b452da386caed007b389f0fe972625037b6c8e7ab087c69d6184cc4dfc8595c4c1d3e6e + languageName: node + linkType: hard + "cli-spinners@npm:^2.0.0": version: 2.9.2 resolution: "cli-spinners@npm:2.9.2" @@ -5969,6 +6239,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:^13.1.0": + version: 13.1.0 + resolution: "commander@npm:13.1.0" + checksum: 8ca2fcb33caf2aa06fba3722d7a9440921331d54019dabf906f3603313e7bf334b009b862257b44083ff65d5a3ab19e83ad73af282bd5319f01dc228bdf87ef0 + languageName: node + linkType: hard + "commander@npm:^2.20.0": version: 2.20.3 resolution: "commander@npm:2.20.3" @@ -6093,6 +6370,17 @@ __metadata: languageName: node linkType: hard +"cpu-features@npm:~0.0.10": + version: 0.0.10 + resolution: "cpu-features@npm:0.0.10" + dependencies: + buildcheck: ~0.0.6 + nan: ^2.19.0 + node-gyp: latest + checksum: ab17e25cea0b642bdcfd163d3d872be4cc7d821e854d41048557799e990d672ee1cc7bd1d4e7c4de0309b1683d4c001d36ba8569b5035d1e7e2ff2d681f681d7 + languageName: node + linkType: hard + "crc-32@npm:^1.2.0": version: 1.2.2 resolution: "crc-32@npm:1.2.2" @@ -6398,7 +6686,7 @@ __metadata: languageName: node linkType: hard -"dotenv@npm:^16.4.5": +"dotenv@npm:^16.4.5, dotenv@npm:^16.5.0": version: 16.6.1 resolution: "dotenv@npm:16.6.1" checksum: e8bd63c9a37f57934f7938a9cf35de698097fadf980cb6edb61d33b3e424ceccfe4d10f37130b904a973b9038627c2646a3365a904b4406514ea94d7f1816b69 @@ -6770,6 +7058,95 @@ __metadata: languageName: node linkType: hard +"esbuild@npm:~0.27.0": + version: 0.27.2 + resolution: "esbuild@npm:0.27.2" + dependencies: + "@esbuild/aix-ppc64": 0.27.2 + "@esbuild/android-arm": 0.27.2 + "@esbuild/android-arm64": 0.27.2 + "@esbuild/android-x64": 0.27.2 + "@esbuild/darwin-arm64": 0.27.2 + "@esbuild/darwin-x64": 0.27.2 + "@esbuild/freebsd-arm64": 0.27.2 + "@esbuild/freebsd-x64": 0.27.2 + "@esbuild/linux-arm": 0.27.2 + "@esbuild/linux-arm64": 0.27.2 + "@esbuild/linux-ia32": 0.27.2 + "@esbuild/linux-loong64": 0.27.2 + "@esbuild/linux-mips64el": 0.27.2 + "@esbuild/linux-ppc64": 0.27.2 + "@esbuild/linux-riscv64": 0.27.2 + "@esbuild/linux-s390x": 0.27.2 + "@esbuild/linux-x64": 0.27.2 + "@esbuild/netbsd-arm64": 0.27.2 + "@esbuild/netbsd-x64": 0.27.2 + "@esbuild/openbsd-arm64": 0.27.2 + "@esbuild/openbsd-x64": 0.27.2 + "@esbuild/openharmony-arm64": 0.27.2 + "@esbuild/sunos-x64": 0.27.2 + "@esbuild/win32-arm64": 0.27.2 + "@esbuild/win32-ia32": 0.27.2 + "@esbuild/win32-x64": 0.27.2 + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-arm64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/openharmony-arm64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 62ec92f8f40ad19922ae7d8dbf0427e41744120a77cc95abdf099dfb484d65fbe3c70cc55b8eccb7f6cb0d14e871ff1f2f76376d476915c2a6d2b800269261b2 + languageName: node + linkType: hard + "escalade@npm:^3.1.1, escalade@npm:^3.2.0": version: 3.2.0 resolution: "escalade@npm:3.2.0" @@ -8065,7 +8442,7 @@ __metadata: languageName: node linkType: hard -"get-tsconfig@npm:^4.10.0": +"get-tsconfig@npm:^4.10.0, get-tsconfig@npm:^4.7.5": version: 4.13.0 resolution: "get-tsconfig@npm:4.13.0" dependencies: @@ -10388,6 +10765,15 @@ __metadata: languageName: node linkType: hard +"nan@npm:^2.19.0, nan@npm:^2.23.0": + version: 2.24.0 + resolution: "nan@npm:2.24.0" + dependencies: + node-gyp: latest + checksum: ab4080188a2fe2bef0a1f3ce5c65a6c3d71fa23be08f4e0696dc256c5030c809d11569d5bcf28810148a7b0029c195c592b98b7b22c5e9e7e9aa0e71905a63b8 + languageName: node + linkType: hard + "nanoid@npm:^3.3.11, nanoid@npm:^3.3.6, nanoid@npm:^3.3.7, nanoid@npm:^3.3.8": version: 3.3.11 resolution: "nanoid@npm:3.3.11" @@ -12244,7 +12630,7 @@ __metadata: languageName: node linkType: hard -"safer-buffer@npm:>= 2.1.2 < 3, safer-buffer@npm:>= 2.1.2 < 3.0.0": +"safer-buffer@npm:>= 2.1.2 < 3, safer-buffer@npm:>= 2.1.2 < 3.0.0, safer-buffer@npm:~2.1.0": version: 2.1.2 resolution: "safer-buffer@npm:2.1.2" checksum: cab8f25ae6f1434abee8d80023d7e72b598cf1327164ddab31003c51215526801e40b66c5e65d658a0af1e9d6478cadcb4c745f4bd6751f97d8644786c0978b0 @@ -12768,6 +13154,23 @@ __metadata: languageName: node linkType: hard +"ssh2@npm:^1.16.0": + version: 1.17.0 + resolution: "ssh2@npm:1.17.0" + dependencies: + asn1: ^0.2.6 + bcrypt-pbkdf: ^1.0.2 + cpu-features: ~0.0.10 + nan: ^2.23.0 + dependenciesMeta: + cpu-features: + optional: true + nan: + optional: true + checksum: 1661b020e367e358603187a1efbb7628cb9b2f75543f60e354ede67be1216d331f2b99a73c57fb01a04be050a1e06fc97d04760d1396ea658ca816ddf80df9a9 + languageName: node + linkType: hard + "ssri@npm:^10.0.0": version: 10.0.6 resolution: "ssri@npm:10.0.6" @@ -13444,6 +13847,29 @@ __metadata: languageName: node linkType: hard +"tsx@npm:^4.20.3": + version: 4.21.0 + resolution: "tsx@npm:4.21.0" + dependencies: + esbuild: ~0.27.0 + fsevents: ~2.3.3 + get-tsconfig: ^4.7.5 + dependenciesMeta: + fsevents: + optional: true + bin: + tsx: dist/cli.mjs + checksum: 50c98e4b6e66d1c30f72925c8e5e7be1a02377574de7cd367d7e7a6d4af43ca8ff659f91c654e7628b25a5498015e32f090529b92c679b0342811e1cf682e8cf + languageName: node + linkType: hard + +"tweetnacl@npm:^0.14.3": + version: 0.14.5 + resolution: "tweetnacl@npm:0.14.5" + checksum: 6061daba1724f59473d99a7bb82e13f211cdf6e31315510ae9656fefd4779851cb927adad90f3b488c8ed77c106adc0421ea8055f6f976ff21b27c5c4e918487 + languageName: node + linkType: hard + "type-check@npm:^0.4.0, type-check@npm:~0.4.0": version: 0.4.0 resolution: "type-check@npm:0.4.0" @@ -13612,6 +14038,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~5.26.4": + version: 5.26.5 + resolution: "undici-types@npm:5.26.5" + checksum: 3192ef6f3fd5df652f2dc1cd782b49d6ff14dc98e5dced492aa8a8c65425227da5da6aafe22523c67f035a272c599bb89cfe803c1db6311e44bed3042fc25487 + languageName: node + linkType: hard + "undici-types@npm:~6.21.0": version: 6.21.0 resolution: "undici-types@npm:6.21.0" From b3bb4e82c1985e67c054e100a8c1f6337c994c63 Mon Sep 17 00:00:00 2001 From: Tamas Date: Mon, 12 Jan 2026 13:33:57 +0100 Subject: [PATCH 3/4] fix(load-tests): avoid spread operator on sorted array to prevent stack overflow Since the array is already sorted ascending, use sorted[0] for min and sorted[sorted.length - 1] for max instead of Math.min/max(...sorted). This prevents RangeError: Maximum call stack size exceeded when testing with >65K connections. --- apps/load-tests/src/utils/stats.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/load-tests/src/utils/stats.ts b/apps/load-tests/src/utils/stats.ts index 4a72873..950a7fd 100644 --- a/apps/load-tests/src/utils/stats.ts +++ b/apps/load-tests/src/utils/stats.ts @@ -19,9 +19,11 @@ export interface ConnectTimeStats { export function calculateConnectTimeStats(times: number[]): ConnectTimeStats | null { if (times.length === 0) return null; const sorted = [...times].sort((a, b) => a - b); + // Since array is sorted ascending, min is first element, max is last + // Avoid spread operator to prevent stack overflow with large arrays (>65K elements) return { - min: Math.round(Math.min(...sorted)), - max: Math.round(Math.max(...sorted)), + min: Math.round(sorted[0]), + max: Math.round(sorted[sorted.length - 1]), avg: Math.round(sorted.reduce((a, b) => a + b, 0) / sorted.length), p50: Math.round(sorted[Math.floor((sorted.length - 1) * 0.5)] ?? 0), p95: Math.round(sorted[Math.floor((sorted.length - 1) * 0.95)] ?? 0), From 1c54b83b8abd0d0a64fd8e036a4a59833482576f Mon Sep 17 00:00:00 2001 From: Tamas Date: Tue, 13 Jan 2026 16:15:21 +0100 Subject: [PATCH 4/4] chore: address PR review feedback - Remove .gitkeep, results/ dir is created programmatically - Remove redundant .infra-state.json ignore (already covered by results/) - Simplify .tsbuildinfo pattern to **/*.tsbuildinfo --- .gitignore | 3 +-- apps/load-tests/.gitignore | 9 ++------- apps/load-tests/results/.gitkeep | 0 3 files changed, 3 insertions(+), 9 deletions(-) delete mode 100644 apps/load-tests/results/.gitkeep diff --git a/.gitignore b/.gitignore index 3244490..354405f 100644 --- a/.gitignore +++ b/.gitignore @@ -33,8 +33,7 @@ packages/*/docs .yarn # typescript -packages/*/*.tsbuildinfo -apps/*/*.tsbuildinfo +**/*.tsbuildinfo # LLM .llm.txt diff --git a/apps/load-tests/.gitignore b/apps/load-tests/.gitignore index aaae9ef..26e08b1 100644 --- a/apps/load-tests/.gitignore +++ b/apps/load-tests/.gitignore @@ -2,10 +2,5 @@ .env .env.local -# Results (except .gitkeep) -results/* -!results/.gitkeep - -# Infrastructure state -results/.infra-state.json - +# Results (created programmatically, no .gitkeep needed) +results/ diff --git a/apps/load-tests/results/.gitkeep b/apps/load-tests/results/.gitkeep deleted file mode 100644 index e69de29..0000000