Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,13 @@ packages/*/docs
.yarn

# typescript
packages/*/*.tsbuildinfo
**/*.tsbuildinfo

# LLM
.llm.txt
.llm-packages.txt
.llm-apps.txt
.todo.md
.todo.md

# Temp working documents
temp/
20 changes: 20 additions & 0 deletions apps/load-tests/.env.example
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions apps/load-tests/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Environment files (except example)
.env
.env.local

# Results (created programmatically, no .gitkeep needed)
results/
28 changes: 28 additions & 0 deletions apps/load-tests/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
55 changes: 55 additions & 0 deletions apps/load-tests/src/cli/infra.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#!/usr/bin/env node
import chalk from "chalk";
import { Command } from "commander";

const program = new Command();

program
.name("infra")
.description("Manage DigitalOcean infrastructure for distributed load testing")
.version("0.0.1");

program
.command("create")
.description("Create DigitalOcean droplets for load testing")
.action(() => {
console.log(chalk.yellow("[infra create] Not implemented yet - coming in next PR"));
});

program
.command("list")
.description("List current load test droplets")
.action(() => {
console.log(chalk.yellow("[infra list] Not implemented yet - coming in next PR"));
});

program
.command("destroy")
.description("Destroy all load test droplets")
.action(() => {
console.log(chalk.yellow("[infra destroy] Not implemented yet - coming in next PR"));
});

program
.command("exec")
.description("Execute a command on all droplets")
.action(() => {
console.log(chalk.yellow("[infra exec] Not implemented yet - coming in next PR"));
});

program
.command("update")
.description("Update code on all droplets")
.action(() => {
console.log(chalk.yellow("[infra update] Not implemented yet - coming in next PR"));
});

program
.command("collect")
.description("Collect results from all droplets")
.action(() => {
console.log(chalk.yellow("[infra collect] Not implemented yet - coming in next PR"));
});

program.parse();

20 changes: 20 additions & 0 deletions apps/load-tests/src/cli/results.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env node
import chalk from "chalk";
import { Command } from "commander";

const program = new Command();

program
.name("results")
.description("Process and aggregate load test results")
.version("0.0.1");

program
.command("aggregate")
.description("Aggregate results from multiple load test runs")
.action(() => {
console.log(chalk.yellow("[results aggregate] Not implemented yet - coming in next PR"));
});

program.parse();

167 changes: 167 additions & 0 deletions apps/load-tests/src/cli/run.ts
Original file line number Diff line number Diff line change
@@ -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 <url>", "WebSocket URL of the relay server")
.option(
"--scenario <name>",
"Scenario to run: connection-storm, steady-state",
"connection-storm",
)
.option("--connections <number>", "Number of connections to create", "100")
.option(
"--duration <seconds>",
"Test duration in seconds (for steady-state)",
"60",
)
.option(
"--ramp-up <seconds>",
"Seconds to ramp up to full connection count",
"10",
)
.option("--output <path>", "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();
Loading