import './setup-test-env.js'; import { parseArgs } from 'node:util'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'url'; import NodeWebSocket from 'ws'; import * as NostrTools from 'nostr-tools'; import fs from 'node:fs'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; // Polyfill global NostrTools for app modules global.NostrTools = NostrTools; // Import Schema Builders import { buildVideoPostEvent, buildViewEvent } from '../../js/nostrEventSchemas.js'; // --- Configuration --- const ARTIFACTS_DIR = 'artifacts'; if (!fs.existsSync(ARTIFACTS_DIR)) { fs.mkdirSync(ARTIFACTS_DIR, { recursive: true }); } // Default Config const DEFAULT_CONFIG = { duration: 600, // seconds clients: 1000, rate: 0.1, // events per second per client? No, that would be 100 events/sec. // Let's interpret 'rate' as TOTAL events per second to target, // OR rate per client. // If I have 1000 clients and rate 0.1, that's 100 events/sec. That's reasonable. relay: 'ws://localhost:8889', }; // Parse Args const { values: args } = parseArgs({ options: { duration: { type: 'string', short: 'd' }, clients: { type: 'string', short: 'c' }, rate: { type: 'string', short: 'r' }, relay: { type: 'string', short: 'u' }, // u for url }, }); const config = { duration: args.duration ? parseInt(args.duration) : DEFAULT_CONFIG.duration, clients: args.clients ? parseInt(args.clients) : DEFAULT_CONFIG.clients, rate: args.rate ? parseFloat(args.rate) : DEFAULT_CONFIG.rate, relay: args.relay || DEFAULT_CONFIG.relay, }; console.log('--- Load Test Configuration ---'); console.log(JSON.stringify(config, null, 2)); // --- State --- const activeClients = []; // { ws, sk, pk, pendingEvents: Map } let relayProcess = null; const metrics = { latencies: [], errors: 0, sent: 0, received: 0, errorCounts: {}, operationTimes: { build: [], sign: [], }, resourceUsage: [], }; // --- Helpers --- async function startRelay() { console.log('[Setup] Starting local relay...'); const relayLog = fs.openSync(path.join(ARTIFACTS_DIR, 'load-relay.log'), 'w'); // Assuming the relay script is in the same directory const relayScript = path.join(path.dirname(fileURLToPath(import.meta.url)), 'load-test-relay.mjs'); const port = new URL(config.relay).port || '8889'; relayProcess = spawn('node', [relayScript], { stdio: ['ignore', relayLog, relayLog], env: { ...process.env, PORT: port } }); // Wait for port await waitForPort(parseInt(port)); console.log('[Setup] Relay started.'); } async function waitForPort(port) { const retryInterval = 200; const maxRetries = 50; // 10 seconds for (let i = 0; i < maxRetries; i++) { try { const controller = new AbortController(); setTimeout(() => controller.abort(), 100); await fetch(`http://127.0.0.1:${port}`, { signal: controller.signal }).catch(() => {}); await new Promise((resolve, reject) => { const ws = new NodeWebSocket(`ws://127.0.0.1:${port}`); ws.on('open', () => { ws.close(); resolve(); }); ws.on('error', reject); }); return; } catch (e) { // console.log('Retrying connection...', e.message); await new Promise(r => setTimeout(r, retryInterval)); } } throw new Error(`Relay did not start on port ${port}`); } function createClient() { const sk = NostrTools.generateSecretKey(); const pk = NostrTools.getPublicKey(sk); const client = { sk, pk, ws: new NodeWebSocket(config.relay), pendingEvents: new Map(), }; return new Promise((resolve) => { client.ws.on('open', () => { resolve(client); }); client.ws.on('message', (data) => { try { const msg = JSON.parse(data); if (msg[0] === 'OK') { const eventId = msg[1]; const accepted = msg[2]; const reason = msg[3]; if (client.pendingEvents.has(eventId)) { const startTime = client.pendingEvents.get(eventId); const duration = performance.now() - startTime; metrics.latencies.push(duration); metrics.received++; client.pendingEvents.delete(eventId); if (!accepted) { metrics.errors++; metrics.errorCounts[reason] = (metrics.errorCounts[reason] || 0) + 1; } } } } catch (e) { // ignore malformed } }); client.ws.on('error', (err) => { metrics.errors++; const msg = err.message || 'connection_error'; metrics.errorCounts[msg] = (metrics.errorCounts[msg] || 0) + 1; }); }); } // --- Main --- async function run() { try { // 1. Start Relay await startRelay(); // 2. Init Clients console.log(`[Setup] Connecting ${config.clients} clients...`); const connections = []; for (let i = 0; i < config.clients; i++) { connections.push(createClient()); if (i % 100 === 0 && i > 0) process.stdout.write('.'); } const results = await Promise.all(connections); activeClients.push(...results); console.log('\n[Setup] Clients connected.'); // 3. Load Loop console.log('[Load] Starting load generation...'); const startTime = performance.now(); const endTime = startTime + (config.duration * 1000); // Total expected events per second const totalRate = config.clients * config.rate; const intervalMs = 1000 / totalRate; console.log(`[Load] Target rate: ${totalRate.toFixed(2)} events/sec`); let running = true; // Monitoring Loop const monitorInterval = setInterval(() => { const cpu = process.cpuUsage(); const mem = process.memoryUsage(); metrics.resourceUsage.push({ timestamp: Date.now(), cpuUser: cpu.user, cpuSystem: cpu.system, rss: mem.rss, heapUsed: mem.heapUsed, }); console.log(`[Monitor] Sent: ${metrics.sent}, Recv: ${metrics.received}, Errors: ${metrics.errors}, Latency p50: ${calculatePercentile(metrics.latencies, 50).toFixed(2)}ms`); }, 5000); // Event Loop const loadLoop = async () => { while (performance.now() < endTime && running) { const iterationStart = performance.now(); // Pick random client const clientIndex = Math.floor(Math.random() * activeClients.length); const client = activeClients[clientIndex]; // Pick event type (10% heavy, 90% light) const isHeavy = Math.random() < 0.1; try { let event; const buildStart = performance.now(); const now = Math.floor(Date.now() / 1000); const hexPk = client.pk; // getPublicKey returns hex in v2 if (isHeavy) { // Video Post const content = { version: 3, title: `Load Test Video ${Date.now()}`, description: 'A description for load testing purposes. '.repeat(50), // make it larger magnet: `magnet:?xt=urn:btih:${Math.random().toString(16).slice(2).repeat(10)}`, mode: 'live', videoRootId: `load-${Date.now()}-${Math.random()}`, isNsfw: false, isForKids: false, }; event = buildVideoPostEvent({ pubkey: hexPk, created_at: now, content, dTagValue: content.videoRootId }); } else { // View Event event = buildViewEvent({ pubkey: hexPk, created_at: now, pointerValue: `load-video-${Math.random()}`, pointerTag: ['d', `load-video-${Math.random()}`] }); } metrics.operationTimes.build.push(performance.now() - buildStart); // Sign const signStart = performance.now(); // We need to pass event to finalizeEvent. // Note: build*Event returns a plain object. // nostr-tools v2 finalizeEvent takes (t, secretKey) const signedEvent = NostrTools.finalizeEvent(event, client.sk); metrics.operationTimes.sign.push(performance.now() - signStart); // Publish client.pendingEvents.set(signedEvent.id, performance.now()); client.ws.send(JSON.stringify(['EVENT', signedEvent])); metrics.sent++; } catch (e) { console.error('Error generating/sending event:', e); metrics.errors++; } // Throttle const elapsed = performance.now() - iterationStart; const delay = Math.max(0, intervalMs - elapsed); if (delay > 0) await new Promise(r => setTimeout(r, delay)); else { // Yield to event loop if we are falling behind await new Promise(r => setImmediate(r)); } } running = false; }; await loadLoop(); clearInterval(monitorInterval); console.log('[Load] Test finished.'); // 4. Report generateReport(); } catch (err) { console.error('Fatal error:', err); } finally { // Teardown console.log('[Teardown] Closing clients...'); for (const c of activeClients) { c.ws.terminate(); } if (relayProcess) { console.log('[Teardown] Killing relay...'); relayProcess.kill(); } } } function calculatePercentile(data, percentile) { if (data.length === 0) return 0; data.sort((a, b) => a - b); const index = Math.floor(data.length * (percentile / 100)); return data[index]; } function generateReport() { const report = { timestamp: new Date().toISOString(), config, metrics: { total_sent: metrics.sent, total_received: metrics.received, errors: metrics.errors, throughput_sent: metrics.sent / config.duration, throughput_recv: metrics.received / config.duration, latency_ms: { p50: calculatePercentile(metrics.latencies, 50), p95: calculatePercentile(metrics.latencies, 95), p99: calculatePercentile(metrics.latencies, 99), max: calculatePercentile(metrics.latencies, 100), }, operation_times_ms: { build_avg: metrics.operationTimes.build.reduce((a, b) => a + b, 0) / metrics.operationTimes.build.length || 0, sign_avg: metrics.operationTimes.sign.reduce((a, b) => a + b, 0) / metrics.operationTimes.sign.length || 0, }, resource_usage: metrics.resourceUsage, error_breakdown: metrics.errorCounts, }, bottlenecks: [], remediation: [] }; // Simple heuristics for bottlenecks if (report.metrics.latency_ms.p99 > 1000) { report.bottlenecks.push('High P99 Latency (>1s)'); report.remediation.push('Relay might be overloaded or network saturated. Investigate relay event loop lag.'); } if (report.metrics.errors > 0) { report.bottlenecks.push('Errors detected'); report.remediation.push('Check error breakdown.'); } // Hot functions proxy if (report.metrics.operation_times_ms.sign_avg > 10) { report.bottlenecks.push('Signing is slow'); report.remediation.push('Optimize signing or offload to worker.'); } const filename = `load-report-${new Date().toISOString().replace(/[:.]/g, '-')}.json`; const reportPath = path.join(ARTIFACTS_DIR, filename); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); console.log(`[Report] Saved to ${reportPath}`); } run();