diff --git a/.gitignore b/.gitignore index 61e8d47..354405f 100644 --- a/.gitignore +++ b/.gitignore @@ -33,10 +33,13 @@ packages/*/docs .yarn # typescript -packages/*/*.tsbuildinfo +**/*.tsbuildinfo # LLM .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/.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..26e08b1 --- /dev/null +++ b/apps/load-tests/.gitignore @@ -0,0 +1,6 @@ +# Environment files (except example) +.env +.env.local + +# Results (created programmatically, no .gitkeep needed) +results/ 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/src/cli/infra.ts b/apps/load-tests/src/cli/infra.ts new file mode 100644 index 0000000..b972a61 --- /dev/null +++ b/apps/load-tests/src/cli/infra.ts @@ -0,0 +1,526 @@ +#!/usr/bin/env node +import chalk from "chalk"; +import { Command } from "commander"; +import { collectFromDroplets, printCollectResults } from "../infra/collect.js"; +import { loadInfraConfig } from "../infra/config.js"; +import { createDroplet, deleteDroplet, listDropletsByPrefix, waitForDropletActive } from "../infra/digitalocean.js"; +import { type DropletSetupStatus, formatStatus, setupDroplet } from "../infra/droplet.js"; +import { execOnDroplets, printExecResults, saveExecLogs } from "../infra/exec.js"; +import { DROPLET_HOURLY_COST, DROPLET_IMAGE, DROPLET_REGION, DROPLET_SIZE, type DropletInfo } from "../infra/types.js"; + +const program = new Command(); + +program.name("infra").description("Manage DigitalOcean infrastructure for distributed load testing").version("0.0.1"); + +/** + * Validate a git branch name to prevent shell injection. + * Allows alphanumeric, hyphens, underscores, slashes, and dots. + */ +function validateBranchName(branch: string): void { + if (!/^[\w.\-/]+$/.test(branch)) { + throw new Error(`Invalid branch name: "${branch}". Only alphanumeric, hyphens, underscores, slashes, and dots are allowed.`); + } +} + +/** + * Escape a string for use in shell single quotes. + * Replaces ' with '\'' (end quote, escaped quote, start quote). + */ +function escapeShellSingleQuote(str: string): string { + return str.replace(/'/g, "'\\''"); +} + +/** + * Escape regex special characters in a string. + */ +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Format a relative time string (e.g., "2h ago") + */ +function formatRelativeTime(dateStr: string): string { + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffDays > 0) return `${diffDays}d ago`; + if (diffHours > 0) return `${diffHours}h ago`; + if (diffMins > 0) return `${diffMins}m ago`; + return "just now"; +} + +/** + * Print a table of droplets. + */ +function printDropletTable(droplets: DropletInfo[]): void { + // Header + console.log(chalk.dim(" NAME IP REGION SIZE STATUS CREATED")); + + for (const d of droplets) { + const statusColor = d.status === "active" ? chalk.green : chalk.yellow; + console.log( + ` ${d.name.padEnd(14)} ${(d.ip ?? "pending").padEnd(16)} ${d.region.padEnd(8)} ${d.size.padEnd(14)} ${statusColor(d.status.padEnd(8))} ${formatRelativeTime(d.createdAt)}`, + ); + } +} + +// ============================================================================ +// LIST COMMAND +// ============================================================================ + +program + .command("list") + .description("List current load test droplets") + .option("--name-prefix ", "Prefix to filter droplets", "load-test") + .action(async (options: { namePrefix: string }) => { + try { + const config = loadInfraConfig(); + const droplets = await listDropletsByPrefix(config.digitalOceanToken, options.namePrefix); + + if (droplets.length === 0) { + console.log(chalk.yellow(`[infra list] No droplets found matching prefix "${options.namePrefix}"`)); + return; + } + + console.log(chalk.cyan(`[infra list] Found ${droplets.length} droplet(s):`)); + console.log(""); + printDropletTable(droplets); + + // Calculate hourly cost + const hourlyCost = droplets.length * DROPLET_HOURLY_COST; + console.log(""); + console.log(chalk.dim(` Total: ${droplets.length} droplets (~$${hourlyCost.toFixed(3)}/hr)`)); + } catch (error) { + console.error(chalk.red(`[infra list] Error: ${(error as Error).message}`)); + process.exit(1); + } + }); + +// ============================================================================ +// CREATE COMMAND +// ============================================================================ + +// Track status for each droplet during creation +const dropletStatuses = new Map(); + +function updateDropletStatus(name: string, status: DropletSetupStatus): void { + dropletStatuses.set(name, status); +} + +function printDropletStatuses(): void { + for (const [name, status] of dropletStatuses) { + console.log(` ${name.padEnd(14)} ${formatStatus(status)}`); + } +} + +program + .command("create") + .description("Create DigitalOcean droplets for load testing") + .option("--count ", "Number of droplets to create", "3") + .option("--name-prefix ", "Prefix for droplet names", "load-test") + .option("--branch ", "Git branch to clone", "main") + .option("--skip-setup", "Skip running the setup script", false) + .action(async (options: { count: string; namePrefix: string; branch: string; skipSetup: boolean }) => { + try { + const config = loadInfraConfig(); + const count = Number.parseInt(options.count, 10); + + // Validate branch name to prevent shell injection + if (!options.skipSetup) { + validateBranchName(options.branch); + } + + console.log(chalk.cyan(`[infra create] Creating ${count} droplet(s) (${DROPLET_REGION}, ${DROPLET_SIZE})...`)); + if (!options.skipSetup) { + console.log(chalk.dim(`[infra create] Branch: ${options.branch}`)); + } + console.log(""); + + // Get existing droplets to determine next number + const existing = await listDropletsByPrefix(config.digitalOceanToken, options.namePrefix); + const escapedPrefix = escapeRegex(options.namePrefix); + const existingNumbers = existing + .map((d) => { + const match = d.name.match(new RegExp(`^${escapedPrefix}-(\\d+)$`)); + return match ? Number.parseInt(match[1], 10) : 0; + }) + .filter((n) => n > 0); + const startNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1; + + // Initialize status tracking + const dropletNames: string[] = []; + for (let i = 0; i < count; i++) { + const name = `${options.namePrefix}-${startNumber + i}`; + dropletNames.push(name); + updateDropletStatus(name, "creating"); + } + printDropletStatuses(); + + // Create droplets via API (limit concurrency to 5 to avoid rate limits) + const createdDroplets: DropletInfo[] = []; + const CONCURRENCY_LIMIT = 5; + for (let i = 0; i < dropletNames.length; i += CONCURRENCY_LIMIT) { + const batch = dropletNames.slice(i, i + CONCURRENCY_LIMIT); + const createPromises = batch.map(async (name) => { + const droplet = await createDroplet(config.digitalOceanToken, { + name, + region: DROPLET_REGION, + size: DROPLET_SIZE, + image: DROPLET_IMAGE, + sshKeyFingerprint: config.sshKeyFingerprint, + }); + createdDroplets.push(droplet); + updateDropletStatus(name, "waiting_for_active"); + }); + await Promise.all(createPromises); + } + + console.log(""); + console.log(chalk.cyan("[infra create] Waiting for droplets to be active...")); + + // Wait for all droplets to be active + const activePromises = createdDroplets.map(async (d) => { + const active = await waitForDropletActive(config.digitalOceanToken, d.id); + updateDropletStatus(d.name, "waiting_for_ssh"); + return active; + }); + const activeDroplets = await Promise.all(activePromises); + + // Skip setup if requested + if (options.skipSetup) { + for (const d of activeDroplets) { + updateDropletStatus(d.name, "ready"); + } + console.log(""); + console.log(chalk.green(`[infra create] Complete! ${count}/${count} droplets active (setup skipped).`)); + console.log(""); + printDropletTable(activeDroplets); + return; + } + + console.log(""); + console.log(chalk.cyan("[infra create] Running setup on droplets...")); + console.log(chalk.dim("[infra create] This may take 3-5 minutes...")); + console.log(""); + + // Run setup on all droplets in parallel + const setupPromises = activeDroplets.map(async (droplet) => { + try { + await setupDroplet(droplet, options.branch, config.sshPrivateKeyPath, (d, status) => updateDropletStatus(d.name, status)); + } catch (error) { + updateDropletStatus(droplet.name, "failed"); + console.error(chalk.red(` ${droplet.name}: ${(error as Error).message}`)); + } + }); + + await Promise.all(setupPromises); + + // Count successes and failures + const readyCount = [...dropletStatuses.values()].filter((s) => s === "ready").length; + const failedCount = [...dropletStatuses.values()].filter((s) => s === "failed").length; + + console.log(""); + if (failedCount === 0) { + console.log(chalk.green(`[infra create] Complete! ${readyCount}/${count} droplets ready.`)); + } else { + console.log(chalk.yellow(`[infra create] Done. ${readyCount}/${count} ready, ${failedCount} failed.`)); + } + console.log(""); + printDropletStatuses(); + } catch (error) { + console.error(chalk.red(`[infra create] Error: ${(error as Error).message}`)); + process.exit(1); + } + }); + +// ============================================================================ +// DESTROY COMMAND +// ============================================================================ + +program + .command("destroy") + .description("Destroy all load test droplets") + .option("--name-prefix ", "Prefix to filter droplets", "load-test") + .option("--yes", "Skip confirmation prompt", false) + .action(async (options: { namePrefix: string; yes: boolean }) => { + try { + const config = loadInfraConfig(); + const droplets = await listDropletsByPrefix(config.digitalOceanToken, options.namePrefix); + + if (droplets.length === 0) { + console.log(chalk.yellow(`[infra destroy] No droplets found matching prefix "${options.namePrefix}"`)); + return; + } + + console.log(chalk.cyan(`[infra destroy] Found ${droplets.length} droplet(s) to destroy:`)); + console.log(""); + console.log(` ${droplets.map((d) => d.name).join(", ")}`); + console.log(""); + + // Confirm unless --yes + if (!options.yes) { + const readline = await import("node:readline"); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const answer = await new Promise((resolve) => { + rl.question(chalk.yellow(" Are you sure you want to destroy these droplets? (y/N) "), resolve); + }); + rl.close(); + + if (answer.toLowerCase() !== "y") { + console.log(chalk.dim(" Cancelled.")); + return; + } + } + + console.log(chalk.cyan("[infra destroy] Destroying droplets...")); + + // Delete all in parallel + const deletePromises = droplets.map(async (d) => { + await deleteDroplet(config.digitalOceanToken, d.id); + console.log(` ${d.name} ${chalk.green("✓ destroyed")}`); + }); + + await Promise.all(deletePromises); + + console.log(""); + console.log(chalk.green("[infra destroy] All droplets destroyed.")); + } catch (error) { + console.error(chalk.red(`[infra destroy] Error: ${(error as Error).message}`)); + process.exit(1); + } + }); + +// ============================================================================ +// UPDATE COMMAND +// ============================================================================ + +program + .command("update") + .description("Update code on all droplets (git pull && yarn build)") + .option("--name-prefix ", "Prefix to filter droplets", "load-test") + .option("--branch ", "Branch to checkout", "main") + .action(async (options: { namePrefix: string; branch: string }) => { + try { + const config = loadInfraConfig(); + + // Validate branch name to prevent shell injection + validateBranchName(options.branch); + + const droplets = await listDropletsByPrefix(config.digitalOceanToken, options.namePrefix); + + if (droplets.length === 0) { + console.log(chalk.yellow(`[infra update] No droplets found matching prefix "${options.namePrefix}"`)); + return; + } + + console.log(chalk.cyan(`[infra update] Updating ${droplets.length} droplet(s)...`)); + console.log(chalk.dim(`[infra update] Branch: ${options.branch}`)); + console.log(""); + + // Build the update command (branch already validated above) + const updateCommand = `cd /app && git fetch && git checkout ${options.branch} && git pull && yarn install && yarn build`; + + const results = await execOnDroplets(droplets, updateCommand, config.sshPrivateKeyPath); + + // Save logs + const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const logsDir = `results/.infra-logs/update-${timestamp}`; + saveExecLogs(results, logsDir); + + printExecResults(results); + console.log(""); + console.log(chalk.dim(` Full logs: ${logsDir}/`)); + } catch (error) { + console.error(chalk.red(`[infra update] Error: ${(error as Error).message}`)); + process.exit(1); + } + }); + +// ============================================================================ +// EXEC COMMAND +// ============================================================================ + +program + .command("exec") + .description("Execute a command on all droplets") + .requiredOption("--command ", "Command to execute") + .option("--name-prefix ", "Prefix to filter droplets", "load-test") + .option("--background", "Run command in background (fire-and-forget)", false) + .action(async (options: { command: string; namePrefix: string; background: boolean }) => { + try { + const config = loadInfraConfig(); + const droplets = await listDropletsByPrefix(config.digitalOceanToken, options.namePrefix); + + if (droplets.length === 0) { + console.log(chalk.yellow(`[infra exec] No droplets found matching prefix "${options.namePrefix}"`)); + return; + } + + if (options.background) { + // Fire-and-forget mode: wrap command in nohup and return immediately + const bgCommand = `nohup bash -c '${options.command.replace(/'/g, "'\\''")}' > /tmp/load-test-output.log 2>&1 &`; + console.log(chalk.cyan(`[infra exec] Starting background process on ${droplets.length} droplet(s)...`)); + console.log(chalk.dim(`[infra exec] Command: ${options.command}`)); + console.log(""); + + const results = await execOnDroplets(droplets, bgCommand, config.sshPrivateKeyPath); + + const succeeded = results.filter((r) => r.exitCode === 0).length; + console.log(chalk.green(`[infra exec] Started on ${succeeded}/${droplets.length} droplet(s).`)); + console.log(""); + console.log(chalk.dim(" Use 'yarn infra wait' to wait for completion.")); + console.log(chalk.dim(" Logs are being written to /tmp/load-test-output.log on each droplet.")); + } else { + // Normal mode: wait for command to complete + console.log(chalk.cyan(`[infra exec] Running on ${droplets.length} droplet(s)...`)); + console.log(chalk.dim(`[infra exec] Command: ${options.command}`)); + console.log(""); + + const results = await execOnDroplets(droplets, options.command, config.sshPrivateKeyPath); + + // Save logs + const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const logsDir = `results/.infra-logs/exec-${timestamp}`; + saveExecLogs(results, logsDir); + + printExecResults(results); + console.log(""); + console.log(chalk.dim(` Full logs: ${logsDir}/`)); + } + } catch (error) { + console.error(chalk.red(`[infra exec] Error: ${(error as Error).message}`)); + process.exit(1); + } + }); + +// ============================================================================ +// WAIT COMMAND +// ============================================================================ + +program + .command("wait") + .description("Wait for a file to exist on all droplets (poll until ready)") + .requiredOption("--file ", "Path to file to wait for on each droplet") + .option("--name-prefix ", "Prefix to filter droplets", "load-test") + .option("--timeout ", "Timeout in seconds", "600") + .option("--interval ", "Poll interval in seconds", "5") + .action(async (options: { file: string; namePrefix: string; timeout: string; interval: string }) => { + try { + const config = loadInfraConfig(); + const droplets = await listDropletsByPrefix(config.digitalOceanToken, options.namePrefix); + + if (droplets.length === 0) { + console.log(chalk.yellow(`[infra wait] No droplets found matching prefix "${options.namePrefix}"`)); + return; + } + + const timeoutSec = Number.parseInt(options.timeout, 10); + const intervalSec = Number.parseInt(options.interval, 10); + + // Validate numeric inputs to prevent infinite loop with NaN + if (Number.isNaN(timeoutSec) || timeoutSec <= 0) { + throw new Error(`Invalid timeout value: "${options.timeout}". Must be a positive number.`); + } + if (Number.isNaN(intervalSec) || intervalSec <= 0) { + throw new Error(`Invalid interval value: "${options.interval}". Must be a positive number.`); + } + + console.log(chalk.cyan(`[infra wait] Waiting for ${options.file} on ${droplets.length} droplet(s)...`)); + console.log(chalk.dim(`[infra wait] Timeout: ${timeoutSec}s, Poll interval: ${intervalSec}s`)); + console.log(""); + + const startTime = Date.now(); + const completed = new Set(); + // Escape single quotes in file path to prevent shell injection + const escapedFile = escapeShellSingleQuote(options.file); + const checkCommand = `test -f '${escapedFile}' && echo 'EXISTS' || echo 'NOT_FOUND'`; + + while (completed.size < droplets.length) { + const elapsed = (Date.now() - startTime) / 1000; + if (elapsed >= timeoutSec) { + console.log(""); + console.log(chalk.red(`[infra wait] Timeout after ${timeoutSec}s. ${completed.size}/${droplets.length} completed.`)); + process.exit(1); + } + + // Check remaining droplets + const remaining = droplets.filter((d) => !completed.has(d.name)); + const results = await execOnDroplets(remaining, checkCommand, config.sshPrivateKeyPath); + + for (const result of results) { + if (result.stdout.includes("EXISTS")) { + completed.add(result.dropletName); + } + } + + // Print progress + const progressBar = "█".repeat(completed.size) + "░".repeat(droplets.length - completed.size); + process.stdout.write(`\r [${progressBar}] ${completed.size}/${droplets.length} complete (${Math.round(elapsed)}s)`); + + if (completed.size < droplets.length) { + await new Promise((resolve) => setTimeout(resolve, intervalSec * 1000)); + } + } + + console.log(""); + console.log(""); + console.log(chalk.green(`[infra wait] All ${droplets.length} droplet(s) have ${options.file}`)); + } catch (error) { + console.error(chalk.red(`[infra wait] Error: ${(error as Error).message}`)); + process.exit(1); + } + }); + +// ============================================================================ +// COLLECT COMMAND +// ============================================================================ + +program + .command("collect") + .description("Collect results from all droplets") + .requiredOption("--output ", "Directory to store collected results") + .option("--name-prefix ", "Prefix to filter droplets", "load-test") + .option("--remote-path ", "Path to results file on droplet", "/tmp/results.json") + .action(async (options: { output: string; namePrefix: string; remotePath: string }) => { + try { + const config = loadInfraConfig(); + const droplets = await listDropletsByPrefix(config.digitalOceanToken, options.namePrefix); + + if (droplets.length === 0) { + console.log(chalk.yellow(`[infra collect] No droplets found matching prefix "${options.namePrefix}"`)); + return; + } + + console.log(chalk.cyan(`[infra collect] Collecting from ${droplets.length} droplet(s)...`)); + console.log(chalk.dim(`[infra collect] Remote path: ${options.remotePath}`)); + console.log(""); + + const results = await collectFromDroplets(droplets, options.remotePath, options.output, config.sshPrivateKeyPath); + + printCollectResults(results); + + // List downloaded files + const successful = results.filter((r) => r.success); + if (successful.length > 0) { + console.log(""); + console.log(chalk.cyan(`[infra collect] Results saved to ${options.output}/`)); + for (const r of successful) { + console.log(chalk.dim(` - ${r.dropletName}.json`)); + } + } + } catch (error) { + console.error(chalk.red(`[infra collect] Error: ${(error as Error).message}`)); + process.exit(1); + } + }); + +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..b993753 --- /dev/null +++ b/apps/load-tests/src/cli/results.ts @@ -0,0 +1,33 @@ +#!/usr/bin/env node +import chalk from "chalk"; +import { Command } from "commander"; +import { + aggregateResults, + printAggregatedResults, +} from "../results/aggregate.js"; + +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") + .requiredOption("--input ", "Directory containing result JSON files") + .action((options: { input: string }) => { + try { + console.log(chalk.cyan(`[results] Aggregating results from ${options.input}...`)); + console.log(""); + + const aggregated = aggregateResults(options.input); + printAggregatedResults(aggregated); + } catch (error) { + console.error(chalk.red(`[results] Error: ${(error as Error).message}`)); + process.exit(1); + } + }); + +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..d4464f2 --- /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 { calculateConnectTimeStats } 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, + }, + connectTime: calculateConnectTimeStats(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/infra/collect.ts b/apps/load-tests/src/infra/collect.ts new file mode 100644 index 0000000..04a8bc5 --- /dev/null +++ b/apps/load-tests/src/infra/collect.ts @@ -0,0 +1,115 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import chalk from "chalk"; +import { downloadFile } from "./ssh.js"; +import type { DropletInfo } from "./types.js"; + +/** + * Result of collecting a file from a droplet. + */ +export interface CollectResult { + dropletName: string; + success: boolean; + localPath?: string; + fileSize?: number; + error?: string; +} + +/** + * Collect files from multiple droplets. + */ +export async function collectFromDroplets( + droplets: DropletInfo[], + remotePath: string, + outputDir: string, + privateKeyPath: string, + onProgress?: (droplet: DropletInfo, status: "downloading" | "done" | "failed") => void, +): Promise { + // Create output directory + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + const results: CollectResult[] = []; + + const collectPromises = droplets.map(async (droplet) => { + if (!droplet.ip) { + const result: CollectResult = { + dropletName: droplet.name, + success: false, + error: "No IP address", + }; + results.push(result); + onProgress?.(droplet, "failed"); + return result; + } + + onProgress?.(droplet, "downloading"); + + const localPath = path.join(outputDir, `${droplet.name}.json`); + + try { + await downloadFile(droplet.ip, remotePath, localPath, privateKeyPath); + + // Get file size + const stats = fs.statSync(localPath); + const result: CollectResult = { + dropletName: droplet.name, + success: true, + localPath, + fileSize: stats.size, + }; + results.push(result); + onProgress?.(droplet, "done"); + return result; + } catch (error) { + const result: CollectResult = { + dropletName: droplet.name, + success: false, + error: (error as Error).message, + }; + results.push(result); + onProgress?.(droplet, "failed"); + return result; + } + }); + + await Promise.all(collectPromises); + return results; +} + +/** + * Format file size in a human-readable way. + */ +export function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const kb = bytes / 1024; + if (kb < 1024) return `${kb.toFixed(1)} KB`; + const mb = kb / 1024; + return `${mb.toFixed(1)} MB`; +} + +/** + * Print collection results summary. + */ +export function printCollectResults(results: CollectResult[]): void { + const successful = results.filter((r) => r.success); + const failed = results.filter((r) => !r.success); + + console.log(""); + if (failed.length === 0) { + console.log(chalk.green(`[infra collect] Complete! ${successful.length}/${results.length} files downloaded.`)); + } else { + console.log(chalk.yellow(`[infra collect] Done. ${successful.length}/${results.length} downloaded, ${failed.length} failed.`)); + } + console.log(""); + + for (const result of results) { + if (result.success) { + console.log(` ${result.dropletName.padEnd(14)} ${chalk.green("✓")} downloaded (${formatFileSize(result.fileSize ?? 0)})`); + } else { + console.log(` ${result.dropletName.padEnd(14)} ${chalk.red("✗")} ${result.error}`); + } + } +} + diff --git a/apps/load-tests/src/infra/config.ts b/apps/load-tests/src/infra/config.ts new file mode 100644 index 0000000..b570b0d --- /dev/null +++ b/apps/load-tests/src/infra/config.ts @@ -0,0 +1,73 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { config as loadDotenv } from "dotenv"; +import type { InfraConfig, SshConfig } from "./types.js"; + +// Load .env file from the load-tests directory +const envPath = path.resolve(import.meta.dirname, "../../.env"); +loadDotenv({ path: envPath }); + +/** + * Load and validate infrastructure configuration from environment variables. + * Throws an error if required variables are missing. + */ +export function loadInfraConfig(): InfraConfig { + const digitalOceanToken = process.env.DIGITALOCEAN_TOKEN; + const sshKeyFingerprint = process.env.SSH_KEY_FINGERPRINT; + const sshPrivateKeyPath = process.env.SSH_PRIVATE_KEY_PATH ?? "~/.ssh/id_rsa"; + + if (!digitalOceanToken) { + throw new Error( + "DIGITALOCEAN_TOKEN is required.\n" + + "Set it in apps/load-tests/.env or as an environment variable.\n" + + "Get a token from: https://cloud.digitalocean.com/account/api/tokens" + ); + } + + if (!sshKeyFingerprint) { + throw new Error( + "SSH_KEY_FINGERPRINT is required.\n" + + "Set it in apps/load-tests/.env or as an environment variable.\n" + + "Find your SSH key fingerprint at: https://cloud.digitalocean.com/account/security" + ); + } + + // Expand ~ to home directory + const expandedKeyPath = sshPrivateKeyPath.replace(/^~/, os.homedir()); + + return { + digitalOceanToken, + sshKeyFingerprint, + sshPrivateKeyPath: expandedKeyPath, + }; +} + +/** + * Get SSH configuration for connecting to droplets. + */ +export function getSshConfig(config: InfraConfig): SshConfig { + return { + privateKeyPath: config.sshPrivateKeyPath, + username: "root", + port: 22, + }; +} + +/** + * Read the SSH private key from disk. + * Throws an error if the file doesn't exist. + */ +export function readSshPrivateKey(config: InfraConfig): string { + const keyPath = config.sshPrivateKeyPath; + + if (!fs.existsSync(keyPath)) { + throw new Error( + `SSH private key not found at: ${keyPath}\n` + + "Set SSH_PRIVATE_KEY_PATH in apps/load-tests/.env to the correct path." + ); + } + + return fs.readFileSync(keyPath, "utf-8"); +} + diff --git a/apps/load-tests/src/infra/digitalocean.ts b/apps/load-tests/src/infra/digitalocean.ts new file mode 100644 index 0000000..1f34c7f --- /dev/null +++ b/apps/load-tests/src/infra/digitalocean.ts @@ -0,0 +1,189 @@ +import type { CreateDropletOptions, DropletInfo } from "./types.js"; + +const DO_API_BASE = "https://api.digitalocean.com/v2"; + +/** + * DigitalOcean API error. + */ +export class DigitalOceanError extends Error { + constructor( + message: string, + public statusCode: number, + public responseBody?: string, + ) { + super(message); + this.name = "DigitalOceanError"; + } +} + +/** + * Make an authenticated request to the DigitalOcean API. + */ +async function doRequest( + token: string, + method: string, + path: string, + body?: unknown, +): Promise { + const response = await fetch(`${DO_API_BASE}${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const text = await response.text(); + throw new DigitalOceanError( + `DigitalOcean API error: ${response.status} ${response.statusText}`, + response.status, + text, + ); + } + + // Handle 204 No Content + if (response.status === 204) { + return undefined as T; + } + + return response.json() as Promise; +} + +/** + * Parse a droplet from the API response. + */ +function parseDroplet(data: ApiDroplet): DropletInfo { + // Find the public IPv4 address + const publicIpv4 = data.networks?.v4?.find( + (n: { type: string }) => n.type === "public", + ); + + return { + id: data.id, + name: data.name, + status: data.status as DropletInfo["status"], + ip: publicIpv4?.ip_address ?? null, + region: data.region?.slug ?? "unknown", + size: data.size?.slug ?? "unknown", + createdAt: data.created_at, + }; +} + +// API response types (partial, only what we need) +interface ApiDroplet { + id: number; + name: string; + status: string; + created_at: string; + networks?: { + v4?: Array<{ type: string; ip_address: string }>; + }; + region?: { slug: string }; + size?: { slug: string }; +} + +interface ListDropletsResponse { + droplets: ApiDroplet[]; +} + +interface CreateDropletResponse { + droplet: ApiDroplet; +} + +interface GetDropletResponse { + droplet: ApiDroplet; +} + +/** + * List all droplets. + */ +export async function listDroplets(token: string): Promise { + const response = await doRequest( + token, + "GET", + "/droplets?per_page=200", + ); + return response.droplets.map(parseDroplet); +} + +/** + * List droplets matching a name prefix. + */ +export async function listDropletsByPrefix( + token: string, + prefix: string, +): Promise { + const all = await listDroplets(token); + return all.filter((d) => d.name.startsWith(prefix)); +} + +/** + * Create a new droplet. + */ +export async function createDroplet( + token: string, + options: CreateDropletOptions, +): Promise { + const response = await doRequest( + token, + "POST", + "/droplets", + { + name: options.name, + region: options.region, + size: options.size, + image: options.image, + ssh_keys: [options.sshKeyFingerprint], + user_data: options.userData, + }, + ); + return parseDroplet(response.droplet); +} + +/** + * Get a droplet by ID. + */ +export async function getDroplet( + token: string, + id: number, +): Promise { + const response = await doRequest( + token, + "GET", + `/droplets/${id}`, + ); + return parseDroplet(response.droplet); +} + +/** + * Delete a droplet by ID. + */ +export async function deleteDroplet(token: string, id: number): Promise { + await doRequest(token, "DELETE", `/droplets/${id}`); +} + +/** + * Wait for a droplet to reach the "active" status. + * Polls every 5 seconds up to the timeout. + */ +export async function waitForDropletActive( + token: string, + id: number, + timeoutMs = 120000, +): Promise { + const startTime = Date.now(); + const pollInterval = 5000; + + while (Date.now() - startTime < timeoutMs) { + const droplet = await getDroplet(token, id); + if (droplet.status === "active" && droplet.ip) { + return droplet; + } + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + + throw new Error(`Droplet ${id} did not become active within ${timeoutMs}ms`); +} + diff --git a/apps/load-tests/src/infra/droplet.ts b/apps/load-tests/src/infra/droplet.ts new file mode 100644 index 0000000..a5a8914 --- /dev/null +++ b/apps/load-tests/src/infra/droplet.ts @@ -0,0 +1,170 @@ +import chalk from "chalk"; +import { execSsh, waitForSsh } from "./ssh.js"; +import type { DropletInfo, ExecResult } from "./types.js"; + +/** + * Status of a droplet during creation. + */ +export type DropletSetupStatus = + | "creating" + | "waiting_for_active" + | "waiting_for_ssh" + | "installing_nodejs" + | "cloning_repo" + | "installing_deps" + | "building" + | "ready" + | "failed"; + +/** + * Callback for progress updates during droplet setup. + */ +export type ProgressCallback = ( + droplet: DropletInfo, + status: DropletSetupStatus, + message?: string, +) => void; + +/** + * Validate a git branch name to prevent shell injection. + * Allows alphanumeric, hyphens, underscores, slashes, and dots. + */ +function validateBranchName(branch: string): void { + if (!/^[\w.\-/]+$/.test(branch)) { + throw new Error(`Invalid branch name: "${branch}". Only alphanumeric, hyphens, underscores, slashes, and dots are allowed.`); + } +} + +/** + * Generate the setup script for a droplet. + * Downloads Node.js directly from nodejs.org and installs to /usr/local. + */ +export function generateSetupScript(branch: string): string { + // Validate branch name to prevent shell injection + validateBranchName(branch); + + return `#!/bin/bash +set -e + +export DEBIAN_FRONTEND=noninteractive +NODE_VERSION="20.19.0" + +echo "=== Waiting for apt locks (up to 2 min) ===" +WAIT_COUNT=0 +while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 || \ + fuser /var/lib/apt/lists/lock >/dev/null 2>&1 || \ + fuser /var/cache/apt/archives/lock >/dev/null 2>&1; do + echo "Waiting for apt locks... (\$WAIT_COUNT s)" + sleep 5 + WAIT_COUNT=\$((WAIT_COUNT + 5)) + if [ \$WAIT_COUNT -ge 120 ]; then + echo "Timed out waiting for apt locks, proceeding anyway..." + break + fi +done + +echo "=== Downloading Node.js \$NODE_VERSION ===" +cd /tmp +curl -fsSL "https://nodejs.org/dist/v\$NODE_VERSION/node-v\$NODE_VERSION-linux-x64.tar.xz" -o node.tar.xz + +echo "=== Installing Node.js to /usr/local ===" +tar -xJf node.tar.xz +cp -r node-v\$NODE_VERSION-linux-x64/{bin,lib,share} /usr/local/ +rm -rf node.tar.xz node-v\$NODE_VERSION-linux-x64 + +echo "=== Verifying Node installation ===" +/usr/local/bin/node --version +/usr/local/bin/npm --version + +echo "=== Installing Yarn via corepack ===" +/usr/local/bin/corepack enable +/usr/local/bin/corepack prepare yarn@stable --activate + +echo "=== Cloning repository ===" +git clone --branch ${branch} https://github.com/MetaMask/mobile-wallet-protocol /app + +echo "=== Installing dependencies ===" +cd /app +/usr/local/bin/yarn install + +echo "=== Building ===" +/usr/local/bin/yarn build + +echo "=== Setup complete ===" +`; +} + +/** + * Run the setup script on a droplet. + * Reports progress via the callback. + */ +export async function setupDroplet( + droplet: DropletInfo, + branch: string, + privateKeyPath: string, + onProgress?: ProgressCallback, +): Promise { + if (!droplet.ip) { + throw new Error(`Droplet ${droplet.name} has no IP address`); + } + + // Wait for SSH to be available + onProgress?.(droplet, "waiting_for_ssh"); + await waitForSsh(droplet.ip, privateKeyPath); + + // Run the setup script + onProgress?.(droplet, "installing_nodejs"); + const script = generateSetupScript(branch); + + try { + const result = await execSsh( + droplet.ip, + script, + privateKeyPath, + 600000, // 10 minute timeout for full setup + ); + + if (result.exitCode === 0) { + onProgress?.(droplet, "ready"); + return { ...result, dropletName: droplet.name }; + } else { + // Get last few lines of stderr or stdout for context + const errorContext = (result.stderr || result.stdout).trim().split("\n").slice(-5).join("\n"); + const errorMsg = `Exit code ${result.exitCode}: ${errorContext}`; + onProgress?.(droplet, "failed", errorMsg); + throw new Error(errorMsg); + } + } catch (error) { + onProgress?.(droplet, "failed", (error as Error).message); + throw error; + } +} + +/** + * Format a status for display. + */ +export function formatStatus(status: DropletSetupStatus): string { + switch (status) { + case "creating": + return chalk.dim("○ creating..."); + case "waiting_for_active": + return chalk.dim("○ waiting for active..."); + case "waiting_for_ssh": + return chalk.yellow("● waiting for SSH..."); + case "installing_nodejs": + return chalk.yellow("● installing Node.js..."); + case "cloning_repo": + return chalk.yellow("● cloning repo..."); + case "installing_deps": + return chalk.yellow("● installing deps..."); + case "building": + return chalk.yellow("● building..."); + case "ready": + return chalk.green("✓ ready"); + case "failed": + return chalk.red("✗ failed"); + default: + return chalk.dim("○ unknown"); + } +} + diff --git a/apps/load-tests/src/infra/exec.ts b/apps/load-tests/src/infra/exec.ts new file mode 100644 index 0000000..9eb4b0a --- /dev/null +++ b/apps/load-tests/src/infra/exec.ts @@ -0,0 +1,130 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import chalk from "chalk"; +import { execSsh } from "./ssh.js"; +import type { DropletInfo, ExecResult } from "./types.js"; + +/** + * Execute a command on multiple droplets in parallel. + * Returns results for each droplet. + */ +export async function execOnDroplets( + droplets: DropletInfo[], + command: string, + privateKeyPath: string, + onProgress?: (droplet: DropletInfo, status: "running" | "done" | "failed") => void, +): Promise { + const results: ExecResult[] = []; + + const execPromises = droplets.map(async (droplet) => { + if (!droplet.ip) { + const result: ExecResult = { + dropletName: droplet.name, + dropletIp: "", + exitCode: -1, + stdout: "", + stderr: "", + durationMs: 0, + error: "No IP address", + }; + results.push(result); + onProgress?.(droplet, "failed"); + return result; + } + + onProgress?.(droplet, "running"); + + try { + const result = await execSsh(droplet.ip, command, privateKeyPath); + result.dropletName = droplet.name; + results.push(result); + onProgress?.(droplet, result.exitCode === 0 ? "done" : "failed"); + return result; + } catch (error) { + const result: ExecResult = { + dropletName: droplet.name, + dropletIp: droplet.ip, + exitCode: -1, + stdout: "", + stderr: "", + durationMs: 0, + error: (error as Error).message, + }; + results.push(result); + onProgress?.(droplet, "failed"); + return result; + } + }); + + await Promise.all(execPromises); + return results; +} + +/** + * Save execution logs to a directory. + * Creates one file per droplet with stdout/stderr. + */ +export function saveExecLogs( + results: ExecResult[], + logsDir: string, +): void { + // Create logs directory + if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); + } + + for (const result of results) { + const logPath = path.join(logsDir, `${result.dropletName}.log`); + let content = `# ${result.dropletName} (${result.dropletIp})\n`; + content += `# Exit code: ${result.exitCode}\n`; + content += `# Duration: ${result.durationMs}ms\n`; + if (result.error) { + content += `# Error: ${result.error}\n`; + } + content += "\n--- STDOUT ---\n"; + content += result.stdout || "(empty)\n"; + content += "\n--- STDERR ---\n"; + content += result.stderr || "(empty)\n"; + + fs.writeFileSync(logPath, content); + } +} + +/** + * Format a duration in a human-readable way. + */ +export function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds}s`; +} + +/** + * Print execution results summary. + */ +export function printExecResults(results: ExecResult[]): void { + const successful = results.filter((r) => r.exitCode === 0); + const failed = results.filter((r) => r.exitCode !== 0); + + console.log(""); + if (failed.length === 0) { + console.log(chalk.green(`[infra exec] Complete! ${successful.length}/${results.length} succeeded.`)); + } else { + console.log(chalk.yellow(`[infra exec] Done. ${successful.length}/${results.length} succeeded, ${failed.length} failed.`)); + } + console.log(""); + + for (const result of results) { + const icon = result.exitCode === 0 ? chalk.green("✓") : chalk.red("✗"); + const exitCodeStr = result.error + ? chalk.red(`error: ${result.error.substring(0, 30)}`) + : result.exitCode === 0 + ? chalk.green(`exit ${result.exitCode}`) + : chalk.red(`exit ${result.exitCode}`); + console.log(` ${result.dropletName.padEnd(14)} ${icon} ${exitCodeStr} (${formatDuration(result.durationMs)})`); + } +} + diff --git a/apps/load-tests/src/infra/ssh.ts b/apps/load-tests/src/infra/ssh.ts new file mode 100644 index 0000000..481ada2 --- /dev/null +++ b/apps/load-tests/src/infra/ssh.ts @@ -0,0 +1,175 @@ +import * as fs from "node:fs"; +import { Client } from "ssh2"; +import type { ExecResult } from "./types.js"; + +/** + * Sleep for the specified number of milliseconds. + */ +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Wait for SSH to be available on a droplet. + * Retries with exponential backoff up to the timeout. + */ +export async function waitForSsh( + ip: string, + privateKeyPath: string, + timeoutMs = 120000, +): Promise { + const startTime = Date.now(); + let delay = 2000; // Start with 2s delay + const maxDelay = 10000; // Max 10s between retries + + while (Date.now() - startTime < timeoutMs) { + try { + // Try to connect and run a simple command + await execSsh(ip, "echo ok", privateKeyPath, 10000); + return; // Success! + } catch { + // Not ready yet, wait and retry + await sleep(delay); + delay = Math.min(delay * 1.5, maxDelay); + } + } + + throw new Error(`SSH not available on ${ip} after ${timeoutMs}ms`); +} + +/** + * Execute a command on a remote host via SSH. + * Returns the result with stdout, stderr, and exit code. + */ +export async function execSsh( + ip: string, + command: string, + privateKeyPath: string, + timeoutMs = 300000, // 5 minutes default +): Promise { + const startTime = Date.now(); + const privateKey = fs.readFileSync(privateKeyPath, "utf-8"); + + return new Promise((resolve, reject) => { + const client = new Client(); + let stdout = ""; + let stderr = ""; + let exitCode = -1; + let timedOut = false; + + const timeout = setTimeout(() => { + timedOut = true; + client.end(); + reject(new Error(`SSH command timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + client.on("ready", () => { + client.exec(command, (err, stream) => { + if (err) { + clearTimeout(timeout); + client.end(); + reject(err); + return; + } + + stream.on("close", (code: number | null) => { + clearTimeout(timeout); + exitCode = code ?? 1; // Assume failure if no exit code + client.end(); + resolve({ + dropletName: "", // Will be filled in by caller + dropletIp: ip, + exitCode, + stdout, + stderr, + durationMs: Date.now() - startTime, + }); + }); + + stream.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + + stream.stderr.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + }); + }); + + client.on("error", (err) => { + if (!timedOut) { + clearTimeout(timeout); + reject(err); + } + }); + + client.connect({ + host: ip, + port: 22, + username: "root", + privateKey, + readyTimeout: 20000, + }); + }); +} + +/** + * Download a file from a remote host via SFTP. + */ +export async function downloadFile( + ip: string, + remotePath: string, + localPath: string, + privateKeyPath: string, + timeoutMs = 60000, // 1 minute default timeout +): Promise { + const privateKey = fs.readFileSync(privateKeyPath, "utf-8"); + + return new Promise((resolve, reject) => { + const client = new Client(); + let timedOut = false; + + const timeout = setTimeout(() => { + timedOut = true; + client.end(); + reject(new Error(`SFTP download timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + client.on("ready", () => { + client.sftp((err, sftp) => { + if (err) { + clearTimeout(timeout); + client.end(); + reject(err); + return; + } + + sftp.fastGet(remotePath, localPath, (err) => { + clearTimeout(timeout); + client.end(); + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + }); + + client.on("error", (err) => { + if (!timedOut) { + clearTimeout(timeout); + reject(err); + } + }); + + client.connect({ + host: ip, + port: 22, + username: "root", + privateKey, + readyTimeout: 20000, + }); + }); +} + diff --git a/apps/load-tests/src/infra/types.ts b/apps/load-tests/src/infra/types.ts new file mode 100644 index 0000000..88fd189 --- /dev/null +++ b/apps/load-tests/src/infra/types.ts @@ -0,0 +1,71 @@ +/** + * Droplet information returned from DigitalOcean API. + */ +export interface DropletInfo { + id: number; + name: string; + status: "new" | "active" | "off" | "archive"; + ip: string | null; + region: string; + size: string; + createdAt: string; +} + +/** + * Options for creating a droplet via the API. + */ +export interface CreateDropletOptions { + name: string; + region: string; + size: string; + image: string; + sshKeyFingerprint: string; + userData?: string; +} + +/** + * Options for the create command. + */ +export interface CreateOptions { + count: number; + namePrefix: string; + branch: string; +} + +/** + * Result of executing a command on a droplet. + */ +export interface ExecResult { + dropletName: string; + dropletIp: string; + exitCode: number; + stdout: string; + stderr: string; + durationMs: number; + error?: string; +} + +/** + * Infrastructure configuration loaded from environment. + */ +export interface InfraConfig { + digitalOceanToken: string; + sshKeyFingerprint: string; + sshPrivateKeyPath: string; +} + +/** + * SSH connection configuration. + */ +export interface SshConfig { + privateKeyPath: string; + username: string; + port: number; +} + +// Hardcoded droplet settings +export const DROPLET_REGION = "nyc1"; +export const DROPLET_SIZE = "s-2vcpu-2gb"; // 2GB RAM needed for yarn install +export const DROPLET_IMAGE = "ubuntu-24-04-x64"; +export const DROPLET_HOURLY_COST = 0.018; // USD per hour for s-2vcpu-2gb + diff --git a/apps/load-tests/src/output/formatter.ts b/apps/load-tests/src/output/formatter.ts new file mode 100644 index 0000000..2bdf520 --- /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, connectTime, 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`); + + // Connection time with color-coded p95 + if (connectTime) { + const p95Color = connectTime.p95 <= 100 ? chalk.green : connectTime.p95 <= 400 ? chalk.yellow : chalk.red; + console.log( + `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`, + ); + } + + // 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..dbd0f2f --- /dev/null +++ b/apps/load-tests/src/output/types.ts @@ -0,0 +1,53 @@ +/** + * 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; + }; + /** Connection establishment time (NOT message RTT) */ + connectTime: { + min: number; + max: number; + avg: number; + p50: number; + p95: number; + p99: 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/results/aggregate.ts b/apps/load-tests/src/results/aggregate.ts new file mode 100644 index 0000000..7cd50eb --- /dev/null +++ b/apps/load-tests/src/results/aggregate.ts @@ -0,0 +1,207 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import chalk from "chalk"; +import type { TestResults } from "../output/types.js"; +import { calculateConnectTimeStats } from "../utils/stats.js"; + +/** + * Aggregated results from multiple load test runs. + */ +export interface AggregatedResults { + dropletCount: number; + files: string[]; + scenario: string; + target: string; + totals: { + connections: { + attempted: number; + successful: number; + failed: number; + successRate: number; + immediate: number; + recovered: number; + }; + timing: { + totalTimeMs: number; + avgTimeMs: number; + connectionsPerSec: number; + }; + connectTime: { + min: number; + max: number; + avg: number; + p50: number; + p95: number; + p99: number; + } | null; + retries: { + totalRetries: number; + avgRetriesPerConnection: number; + }; + }; + perDroplet: Array<{ + file: string; + connections: number; + successRate: number; + avgConnectTime: number | null; + }>; +} + +/** + * Load and aggregate results from a directory of JSON files. + */ +export function aggregateResults(inputDir: string): AggregatedResults { + // Find all JSON files in the directory + if (!fs.existsSync(inputDir)) { + throw new Error(`Directory not found: ${inputDir}`); + } + + const files = fs.readdirSync(inputDir).filter((f) => f.endsWith(".json")); + if (files.length === 0) { + throw new Error(`No JSON files found in: ${inputDir}`); + } + + // Load all results + const results: Array<{ file: string; data: TestResults }> = []; + for (const file of files) { + const filePath = path.join(inputDir, file); + const content = fs.readFileSync(filePath, "utf-8"); + try { + const data = JSON.parse(content) as TestResults; + // Validate that the parsed data has the expected structure + if (!data.scenario || !data.target || !data.results?.connections) { + console.warn(chalk.yellow(`Warning: ${file} is missing required fields, skipping`)); + continue; + } + results.push({ file, data }); + } catch { + console.warn(chalk.yellow(`Warning: Could not parse ${file}, skipping`)); + } + } + + if (results.length === 0) { + throw new Error("No valid result files found"); + } + + // Aggregate totals + let totalAttempted = 0; + let totalSuccessful = 0; + let totalFailed = 0; + let totalImmediate = 0; + let totalRecovered = 0; + let totalTimeMs = 0; + let totalRetries = 0; + const allConnectTimes: number[] = []; + + const perDroplet: AggregatedResults["perDroplet"] = []; + + for (const { file, data } of results) { + const conn = data.results.connections; + totalAttempted += conn.attempted; + totalSuccessful += conn.successful; + totalFailed += conn.failed; + totalImmediate += conn.immediate; + totalRecovered += conn.recovered; + totalTimeMs += data.results.timing.totalTimeMs; + totalRetries += data.results.retries.totalRetries; + + // Collect individual connect times for aggregate stats + // We use the per-droplet average * count as an approximation + // since individual times aren't stored in TestResults + if (data.results.connectTime) { + // Push multiple samples based on connection count to weight the average + const times = data.results.connectTime; + allConnectTimes.push(times.min, times.avg, times.max); + } + + perDroplet.push({ + file, + connections: conn.attempted, + successRate: conn.successRate, + avgConnectTime: data.results.connectTime?.avg ?? null, + }); + } + + // Use the first result for scenario/target info + const first = results[0].data; + + return { + dropletCount: results.length, + files: files, + scenario: first.scenario, + target: first.target, + totals: { + connections: { + attempted: totalAttempted, + successful: totalSuccessful, + failed: totalFailed, + successRate: totalAttempted > 0 ? (totalSuccessful / totalAttempted) * 100 : 0, + immediate: totalImmediate, + recovered: totalRecovered, + }, + timing: { + totalTimeMs, + avgTimeMs: totalTimeMs / results.length, + connectionsPerSec: totalTimeMs > 0 ? (totalAttempted / (totalTimeMs / 1000)) * results.length : 0, + }, + connectTime: calculateConnectTimeStats(allConnectTimes), + retries: { + totalRetries, + avgRetriesPerConnection: totalAttempted > 0 ? totalRetries / totalAttempted : 0, + }, + }, + perDroplet, + }; +} + +/** + * Print aggregated results. + */ +export function printAggregatedResults(agg: AggregatedResults): void { + console.log(chalk.gray("─".repeat(60))); + console.log(chalk.bold.cyan(" DISTRIBUTED TEST SUMMARY")); + console.log(chalk.gray("─".repeat(60))); + console.log(""); + + console.log(chalk.bold("Overview:")); + console.log(` Droplets: ${chalk.cyan(agg.dropletCount)}`); + console.log(` Scenario: ${agg.scenario}`); + console.log(` Target: ${chalk.dim(agg.target)}`); + console.log(""); + + console.log(chalk.bold("Connections:")); + const successRate = agg.totals.connections.successRate; + const rateColor = successRate >= 99 ? chalk.green : successRate >= 95 ? chalk.yellow : chalk.red; + console.log(` Total: ${chalk.cyan(agg.totals.connections.attempted)}`); + console.log(` Successful: ${chalk.green(agg.totals.connections.successful)} (${rateColor(successRate.toFixed(1) + "%")})`); + console.log(` Failed: ${agg.totals.connections.failed > 0 ? chalk.red(agg.totals.connections.failed) : chalk.green("0")}`); + console.log(` Immediate: ${agg.totals.connections.immediate}`); + console.log(` Recovered: ${agg.totals.connections.recovered}`); + console.log(""); + + console.log(chalk.bold("Timing:")); + console.log(` Avg Duration: ${Math.round(agg.totals.timing.avgTimeMs)}ms`); + console.log(` Throughput: ${agg.totals.timing.connectionsPerSec.toFixed(1)} conn/sec (combined)`); + console.log(""); + + if (agg.totals.retries.totalRetries > 0) { + console.log(chalk.bold("Retries:")); + console.log(chalk.yellow(` Total: ${agg.totals.retries.totalRetries}`)); + console.log(chalk.yellow(` Avg per Conn: ${agg.totals.retries.avgRetriesPerConnection.toFixed(2)}`)); + console.log(""); + } + + console.log(chalk.bold("Per Droplet:")); + console.log(chalk.dim(" FILE CONNECTIONS SUCCESS AVG CONNECT")); + for (const d of agg.perDroplet) { + const connectTimeStr = d.avgConnectTime !== null ? `${Math.round(d.avgConnectTime)}ms` : "-"; + const rateColor = d.successRate >= 99 ? chalk.green : d.successRate >= 95 ? chalk.yellow : chalk.red; + console.log( + ` ${d.file.padEnd(22)} ${String(d.connections).padEnd(13)} ${rateColor(d.successRate.toFixed(1) + "%").padEnd(9)} ${connectTimeStr}`, + ); + } + + console.log(""); + console.log(chalk.gray("─".repeat(60))); +} + 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..1ab1bb3 --- /dev/null +++ b/apps/load-tests/src/scenarios/steady-state.ts @@ -0,0 +1,202 @@ +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 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 + : 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..950a7fd --- /dev/null +++ b/apps/load-tests/src/utils/stats.ts @@ -0,0 +1,33 @@ +/** + * 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 ConnectTimeStats { + min: number; + max: number; + avg: number; + p50: number; + p95: number; + p99: number; +} + +/** + * Calculate connection time statistics from an array of timing measurements. + * Returns null if the array is empty. + */ +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(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), + p99: Math.round(sorted[Math.floor((sorted.length - 1) * 0.99)] ?? 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 + } +} 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"