Files
2026-02-19 23:36:01 -05:00

641 lines
23 KiB
JavaScript

import WebSocket from 'ws';
import { Relay, useWebSocketImplementation } from 'nostr-tools/relay';
import { finalizeEvent, generateSecretKey } from 'nostr-tools/pure';
import fs from 'node:fs';
import path from 'node:path';
import { performance } from 'node:perf_hooks';
import { KIND_APP_DATA, DEFAULT_RELAYS } from '../../src/constants.mjs';
import { ensureDir } from '../../src/utils.mjs';
useWebSocketImplementation(WebSocket);
const KIND_VIEW_EVENT = 1;
const KNOWN_PUBLIC_RELAY_HOSTS = new Set(DEFAULT_RELAYS.map((relay) => new URL(relay).hostname));
const RELAY_SAFETY_ACK_VALUE = 'LOAD_TEST_INFRA_CONFIRMED';
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function parseArgs(argv) {
const args = {};
for (let i = 0; i < argv.length; i += 1) {
const raw = argv[i];
if (!raw.startsWith('--')) continue;
const token = raw.slice(2);
if (!token) continue;
const eqIndex = token.indexOf('=');
if (eqIndex >= 0) {
args[token.slice(0, eqIndex)] = token.slice(eqIndex + 1);
continue;
}
const next = argv[i + 1];
if (next && !next.startsWith('--')) {
args[token] = next;
i += 1;
} else {
args[token] = '1';
}
}
return args;
}
function getConfigValue(args, key, envKey, fallback) {
if (args[key] !== undefined) return args[key];
if (process.env[envKey] !== undefined) return process.env[envKey];
return fallback;
}
function parseIntStrict(value, name) {
if (!/^-?\d+$/.test(String(value))) {
throw new Error(`${name} must be an integer (got "${value}")`);
}
return Number.parseInt(value, 10);
}
function parseFloatStrict(value, name) {
if (!/^[-+]?\d+(\.\d+)?$/.test(String(value))) {
throw new Error(`${name} must be a number (got "${value}")`);
}
return Number.parseFloat(value);
}
function parseBoolean(value, fallback = false) {
if (value === undefined || value === null || value === '') return fallback;
const normalized = String(value).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
return fallback;
}
function assertRange(value, name, min, max) {
if (value < min || value > max) {
throw new Error(`${name} out of range (${min}..${max}): ${value}`);
}
}
function isPrivateIpv4(hostname) {
const match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (!match) return false;
const parts = match.slice(1).map((part) => Number.parseInt(part, 10));
if (parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) return false;
if (parts[0] === 10) return true;
if (parts[0] === 127) return true;
if (parts[0] === 192 && parts[1] === 168) return true;
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
if (parts[0] === 169 && parts[1] === 254) return true;
return false;
}
function isLocalOrPrivateHost(hostname) {
const lower = hostname.toLowerCase();
return lower === 'localhost'
|| lower === '::1'
|| lower.endsWith('.local')
|| isPrivateIpv4(lower);
}
function classifyError(error) {
const text = String(error?.message || error || '').toLowerCase();
if (text.includes('timeout')) return 'timeout';
if (text.includes('reject') || text.includes('invalid signature') || text.includes('validation')) return 'reject';
if (text.includes('closed') || text.includes('disconnect') || text.includes('econnreset')) return 'disconnect';
if (text.includes('refused') || text.includes('enotfound') || text.includes('etimedout')) return 'network';
return 'publish_error';
}
function xmur3(seed) {
let h = 1779033703 ^ seed.length;
for (let i = 0; i < seed.length; i += 1) {
h = Math.imul(h ^ seed.charCodeAt(i), 3432918353);
h = (h << 13) | (h >>> 19);
}
return () => {
h = Math.imul(h ^ (h >>> 16), 2246822507);
h = Math.imul(h ^ (h >>> 13), 3266489909);
h ^= h >>> 16;
return h >>> 0;
};
}
function mulberry32(seedInt) {
let seed = seedInt >>> 0;
return () => {
seed += 0x6d2b79f5;
let t = seed;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function percentile(samples, p) {
if (samples.length === 0) return 0;
const sorted = [...samples].sort((a, b) => a - b);
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
return sorted[index];
}
function summarizePercentiles(samples) {
if (samples.length === 0) {
return {
count: 0,
minMs: 0,
avgMs: 0,
p50Ms: 0,
p90Ms: 0,
p95Ms: 0,
p99Ms: 0,
maxMs: 0,
};
}
const minMs = samples.reduce((min, value) => Math.min(min, value), Number.POSITIVE_INFINITY);
const maxMs = samples.reduce((max, value) => Math.max(max, value), 0);
const sumMs = samples.reduce((sum, value) => sum + value, 0);
return {
count: samples.length,
minMs,
avgMs: sumMs / samples.length,
p50Ms: percentile(samples, 50),
p90Ms: percentile(samples, 90),
p95Ms: percentile(samples, 95),
p99Ms: percentile(samples, 99),
maxMs,
};
}
function withTimeout(promise, timeoutMs) {
let timeoutHandle = null;
return Promise.race([
promise,
new Promise((_, reject) => {
timeoutHandle = setTimeout(() => reject(new Error(`publish timeout after ${timeoutMs}ms`)), timeoutMs);
}),
]).finally(() => {
if (timeoutHandle) clearTimeout(timeoutHandle);
});
}
function buildViewEvent(secretKey, nowMs) {
return finalizeEvent({
kind: KIND_VIEW_EVENT,
created_at: Math.floor(nowMs / 1000),
tags: [
['t', 'torch-load-test'],
['t', 'view-event'],
],
content: JSON.stringify({
type: 'view_event',
source: 'load-test-harness',
viewed_at: new Date(nowMs).toISOString(),
}),
}, secretKey);
}
function buildMultipartMetadataEvent(secretKey, nowMs, rng, sequence, partsPerEvent) {
const videoId = `video-${Math.floor(rng() * 1000000).toString(16)}-${sequence}`;
const part = 1 + Math.floor(rng() * partsPerEvent);
const content = {
type: 'video_metadata_multipart',
source: 'load-test-harness',
schema_basis: 'nostr-app-data-kind-30078',
video_id: videoId,
part,
total_parts: partsPerEvent,
payload: {
title: `Load test clip ${sequence}`,
duration_sec: 30 + Math.floor(rng() * 600),
codecs: ['h264', 'aac'],
chunk_manifest: Array.from({ length: partsPerEvent }, (_, idx) => ({
part: idx + 1,
bytes: 500 + Math.floor(rng() * 2500),
})),
},
};
return finalizeEvent({
kind: KIND_APP_DATA,
created_at: Math.floor(nowMs / 1000),
tags: [
['t', 'torch-load-test'],
['t', 'video-metadata-multipart'],
['d', `${videoId}-${part}`],
],
content: JSON.stringify(content),
}, secretKey);
}
function summarizeRelay(relayUrl) {
const parsed = new URL(relayUrl);
return `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}`;
}
function redactRelayForReport(relayUrl) {
const parsed = new URL(relayUrl);
return {
protocol: parsed.protocol,
hostname: parsed.hostname,
port: parsed.port || '(default)',
};
}
function buildRemediation(report) {
const items = [];
const { results } = report;
if (results.backpressureDrops > 0) {
items.push('Reduce RATE_EPS or increase MAX_INFLIGHT only after confirming relay capacity; backpressure drops indicate local pipeline saturation.');
}
if ((results.errorBreakdown.timeout || 0) > 0) {
items.push('Investigate relay write acknowledgements and tune PUBLISH_TIMEOUT_MS; timeout errors were observed during publish acknowledgements.');
}
if ((results.errorBreakdown.disconnect || 0) > 0 || (results.errorBreakdown.network || 0) > 0) {
items.push('Check relay network stability (TCP resets, DNS, proxy paths) before raising client count.');
}
if (results.latency.p99Ms > 500) {
items.push('Profile relay-side event acceptance path; p99 publish latency exceeded 500ms.');
}
if (results.eventLoopLag.p95Ms > 50) {
items.push('Reduce local scheduling pressure (lower RATE_EPS or CLIENTS) to keep event loop lag under 50ms.');
}
if (results.resource.maxRssMb > 512) {
items.push('Cap MAX_CONNECTIONS or lower CLIENTS to reduce harness memory pressure (RSS exceeded 512MB).');
}
if (items.length === 0) {
items.push('No significant bottlenecks observed in this run; increase load gradually and repeat to find saturation point.');
}
return items.slice(0, 10);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const relayUrlRaw = getConfigValue(args, 'relay-url', 'RELAY_URL', '');
const relayUrl = String(relayUrlRaw || '').trim();
if (!relayUrl) throw new Error('RELAY_URL (or --relay-url) is required.');
const parsedRelay = new URL(relayUrl);
if (!['ws:', 'wss:'].includes(parsedRelay.protocol)) {
throw new Error(`Unsupported relay protocol ${parsedRelay.protocol}. Use ws:// or wss://.`);
}
const allowDedicated = parseBoolean(getConfigValue(args, 'allow-dedicated-test-relay', 'ALLOW_DEDICATED_TEST_RELAY', '0'), false);
const relaySafetyAck = String(getConfigValue(args, 'relay-safety-ack', 'RELAY_SAFETY_ACK', '') || '').trim();
const host = parsedRelay.hostname.toLowerCase();
if (KNOWN_PUBLIC_RELAY_HOSTS.has(host)) {
throw new Error(`Refusing to target known public relay host: ${host}`);
}
if (!isLocalOrPrivateHost(host)) {
if (!allowDedicated || relaySafetyAck !== RELAY_SAFETY_ACK_VALUE) {
throw new Error(
`Refusing non-local relay host "${host}". Set ALLOW_DEDICATED_TEST_RELAY=1 and RELAY_SAFETY_ACK=${RELAY_SAFETY_ACK_VALUE} for dedicated test infrastructure.`,
);
}
}
const clients = parseIntStrict(getConfigValue(args, 'clients', 'CLIENTS', '1000'), 'CLIENTS');
const durationSec = parseIntStrict(getConfigValue(args, 'duration-sec', 'DURATION_SEC', '600'), 'DURATION_SEC');
const rateEps = parseFloatStrict(getConfigValue(args, 'rate-eps', 'RATE_EPS', '20'), 'RATE_EPS');
const mix = parseFloatStrict(getConfigValue(args, 'mix', 'MIX', '0.2'), 'MIX');
const seed = String(getConfigValue(args, 'seed', 'SEED', '') || '').trim();
const dryRun = parseBoolean(getConfigValue(args, 'dry-run', 'DRY_RUN', '1'), true);
const connectConcurrency = parseIntStrict(getConfigValue(args, 'connect-concurrency', 'CONNECT_CONCURRENCY', '25'), 'CONNECT_CONCURRENCY');
const maxConnections = parseIntStrict(getConfigValue(args, 'max-connections', 'MAX_CONNECTIONS', String(Math.min(clients, 250))), 'MAX_CONNECTIONS');
const maxInflight = parseIntStrict(getConfigValue(args, 'max-inflight', 'MAX_INFLIGHT', '300'), 'MAX_INFLIGHT');
const publishTimeoutMs = parseIntStrict(getConfigValue(args, 'publish-timeout-ms', 'PUBLISH_TIMEOUT_MS', '15000'), 'PUBLISH_TIMEOUT_MS');
const resourceSampleMs = parseIntStrict(getConfigValue(args, 'resource-sample-ms', 'RESOURCE_SAMPLE_MS', '5000'), 'RESOURCE_SAMPLE_MS');
const eventLoopSampleMs = parseIntStrict(getConfigValue(args, 'event-loop-sample-ms', 'EVENT_LOOP_SAMPLE_MS', '200'), 'EVENT_LOOP_SAMPLE_MS');
const metadataParts = parseIntStrict(getConfigValue(args, 'metadata-parts', 'METADATA_PARTS', '4'), 'METADATA_PARTS');
assertRange(clients, 'CLIENTS', 1, 100000);
assertRange(durationSec, 'DURATION_SEC', 1, 86400);
assertRange(rateEps, 'RATE_EPS', 0.1, 100000);
assertRange(mix, 'MIX', 0, 1);
assertRange(connectConcurrency, 'CONNECT_CONCURRENCY', 1, 1000);
assertRange(maxConnections, 'MAX_CONNECTIONS', 1, clients);
assertRange(maxInflight, 'MAX_INFLIGHT', 1, 100000);
assertRange(publishTimeoutMs, 'PUBLISH_TIMEOUT_MS', 100, 120000);
assertRange(resourceSampleMs, 'RESOURCE_SAMPLE_MS', 500, 120000);
assertRange(eventLoopSampleMs, 'EVENT_LOOP_SAMPLE_MS', 20, 2000);
assertRange(metadataParts, 'METADATA_PARTS', 2, 20);
const rng = seed ? mulberry32(xmur3(seed)()) : Math.random;
const reportDir = path.resolve(process.cwd(), 'reports', 'load-test');
ensureDir(reportDir);
const relaySummary = summarizeRelay(relayUrl);
console.log('Load test configuration');
console.log(` relay: ${relaySummary}`);
console.log(` clients: ${clients}`);
console.log(` durationSec: ${durationSec}`);
console.log(` rateEps: ${rateEps}`);
console.log(` mix(metadata): ${mix}`);
console.log(` seed: ${seed || '(random)'}`);
console.log(` dryRun: ${dryRun}`);
console.log(` maxConnections: ${maxConnections}`);
console.log(` connectConcurrency: ${connectConcurrency}`);
console.log(` maxInflight: ${maxInflight}`);
if (dryRun) {
console.log('DRY_RUN enabled: no relay connections or publish calls will be made.');
}
const clientKeys = Array.from({ length: clients }, () => generateSecretKey());
const stats = {
startedAtIso: new Date().toISOString(),
startedAtMs: Date.now(),
attempted: 0,
succeeded: 0,
failed: 0,
backpressureDrops: 0,
inFlightPeak: 0,
latenciesMs: [],
errorBreakdown: {},
stackSamples: {},
throughputBySecond: {},
resourceSnapshots: [],
eventLoopLagMs: [],
mode: dryRun ? 'dry-run' : 'live',
};
const cpuStart = process.cpuUsage();
let previousCpu = process.cpuUsage();
let previousCpuWallMs = Date.now();
const eventLoopTimer = setInterval(() => {
const expected = Date.now() + eventLoopSampleMs;
setTimeout(() => {
const lag = Math.max(0, Date.now() - expected);
stats.eventLoopLagMs.push(lag);
}, eventLoopSampleMs);
}, eventLoopSampleMs);
const resourceTimer = setInterval(() => {
const nowMs = Date.now();
const mem = process.memoryUsage();
const cpuDiff = process.cpuUsage(previousCpu);
const wallMs = Math.max(1, nowMs - previousCpuWallMs);
const cpuMs = (cpuDiff.user + cpuDiff.system) / 1000;
const cpuPercent = Number(((cpuMs / wallMs) * 100).toFixed(2));
previousCpu = process.cpuUsage();
previousCpuWallMs = nowMs;
stats.resourceSnapshots.push({
tSec: Math.floor((nowMs - stats.startedAtMs) / 1000),
rssMb: Number((mem.rss / (1024 * 1024)).toFixed(2)),
heapUsedMb: Number((mem.heapUsed / (1024 * 1024)).toFixed(2)),
heapTotalMb: Number((mem.heapTotal / (1024 * 1024)).toFixed(2)),
cpuPercent,
});
}, resourceSampleMs);
const recordAttempt = () => {
const sec = Math.floor((Date.now() - stats.startedAtMs) / 1000);
const bucket = stats.throughputBySecond[sec] || { attempted: 0, succeeded: 0, failed: 0, backpressureDrops: 0 };
bucket.attempted += 1;
stats.throughputBySecond[sec] = bucket;
};
const recordResult = (kind) => {
const sec = Math.floor((Date.now() - stats.startedAtMs) / 1000);
const bucket = stats.throughputBySecond[sec] || { attempted: 0, succeeded: 0, failed: 0, backpressureDrops: 0 };
bucket[kind] += 1;
stats.throughputBySecond[sec] = bucket;
};
const relays = [];
if (!dryRun) {
let nextConnectionIndex = 0;
const connectionErrors = [];
const workers = Array.from({ length: Math.min(connectConcurrency, maxConnections) }, () => (async () => {
while (nextConnectionIndex < maxConnections) {
const current = nextConnectionIndex;
nextConnectionIndex += 1;
try {
const relay = await Relay.connect(relayUrl);
relays[current] = relay;
} catch (error) {
connectionErrors.push(String(error?.message || error));
}
}
})());
await Promise.all(workers);
if (!relays.length) {
throw new Error(`Failed to establish any relay connections. errors=${connectionErrors.slice(0, 3).join(' | ') || '(none)'}`);
}
if (connectionErrors.length) {
stats.errorBreakdown.connection = connectionErrors.length;
stats.stackSamples.connection = connectionErrors.slice(0, 3);
}
console.log(`Established ${relays.length}/${maxConnections} relay connections.`);
}
const publishContext = {
inFlight: 0,
tasks: new Set(),
metadataSeq: 0,
};
async function publishOne(clientIndex, isMetadata) {
const nowMs = Date.now();
const secretKey = clientKeys[clientIndex];
const event = isMetadata
? buildMultipartMetadataEvent(secretKey, nowMs, rng, publishContext.metadataSeq += 1, metadataParts)
: buildViewEvent(secretKey, nowMs);
const started = performance.now();
try {
if (dryRun) {
const syntheticLatency = 5 + Math.floor(rng() * 20);
await sleep(syntheticLatency);
} else {
const relay = relays[clientIndex % relays.length];
await withTimeout(relay.publish(event), publishTimeoutMs);
}
const latencyMs = Number((performance.now() - started).toFixed(3));
stats.succeeded += 1;
stats.latenciesMs.push(latencyMs);
recordResult('succeeded');
} catch (error) {
stats.failed += 1;
const category = classifyError(error);
stats.errorBreakdown[category] = (stats.errorBreakdown[category] || 0) + 1;
const stack = String(error?.stack || error?.message || error);
const trimmed = stack.split('\n').slice(0, 3).join('\n');
if (!stats.stackSamples[category]) stats.stackSamples[category] = [];
if (stats.stackSamples[category].length < 3) {
stats.stackSamples[category].push(trimmed);
}
recordResult('failed');
}
}
const durationMs = durationSec * 1000;
const endAt = stats.startedAtMs + durationMs;
const paceMs = 1000 / rateEps;
let nextTick = Date.now();
while (Date.now() < endAt) {
if (Date.now() < nextTick) {
await sleep(Math.max(0, nextTick - Date.now()));
continue;
}
nextTick += paceMs;
stats.attempted += 1;
recordAttempt();
if (publishContext.inFlight >= maxInflight) {
stats.backpressureDrops += 1;
stats.failed += 1;
stats.errorBreakdown.backpressure = (stats.errorBreakdown.backpressure || 0) + 1;
recordResult('failed');
recordResult('backpressureDrops');
continue;
}
const clientIndex = Math.floor(rng() * clients);
const isMetadata = rng() < mix;
publishContext.inFlight += 1;
stats.inFlightPeak = Math.max(stats.inFlightPeak, publishContext.inFlight);
const task = publishOne(clientIndex, isMetadata).finally(() => {
publishContext.inFlight -= 1;
publishContext.tasks.delete(task);
});
publishContext.tasks.add(task);
}
await Promise.allSettled([...publishContext.tasks]);
clearInterval(eventLoopTimer);
clearInterval(resourceTimer);
for (const relay of relays) relay.close();
const completedAtMs = Date.now();
const completedAtIso = new Date(completedAtMs).toISOString();
const cpuTotal = process.cpuUsage(cpuStart);
const runDurationSec = Math.max(0.001, (completedAtMs - stats.startedAtMs) / 1000);
const latency = summarizePercentiles(stats.latenciesMs);
const eventLoopLag = summarizePercentiles(stats.eventLoopLagMs);
const maxRssMb = stats.resourceSnapshots.reduce((max, item) => Math.max(max, item.rssMb), 0);
const maxHeapMb = stats.resourceSnapshots.reduce((max, item) => Math.max(max, item.heapUsedMb), 0);
const avgCpuPercent = stats.resourceSnapshots.length
? stats.resourceSnapshots.reduce((sum, item) => sum + item.cpuPercent, 0) / stats.resourceSnapshots.length
: 0;
const throughputSeries = Object.entries(stats.throughputBySecond)
.map(([second, values]) => ({ second: Number(second), ...values }))
.sort((a, b) => a.second - b.second);
const report = {
generatedAt: completedAtIso,
config: {
relay: redactRelayForReport(relayUrl),
relaySummary,
dryRun,
clients,
durationSec,
rateEps,
mixMetadata: mix,
seed: seed || null,
connectConcurrency,
maxConnections,
maxInflight,
publishTimeoutMs,
metadataParts,
},
results: {
attempted: stats.attempted,
succeeded: stats.succeeded,
failed: stats.failed,
successRate: Number((stats.succeeded / Math.max(1, stats.attempted)).toFixed(4)),
throughputEps: Number((stats.succeeded / runDurationSec).toFixed(3)),
backpressureDrops: stats.backpressureDrops,
inFlightPeak: stats.inFlightPeak,
latency,
eventLoopLag: {
avgMs: Number(eventLoopLag.avgMs.toFixed(3)),
p95Ms: Number(eventLoopLag.p95Ms.toFixed(3)),
},
errorBreakdown: stats.errorBreakdown,
topStackTraces: stats.stackSamples,
resource: {
samples: stats.resourceSnapshots,
sampleCount: stats.resourceSnapshots.length,
maxRssMb,
maxHeapUsedMb: maxHeapMb,
avgCpuPercent: Number(avgCpuPercent.toFixed(2)),
cpuUserMs: Number((cpuTotal.user / 1000).toFixed(3)),
cpuSystemMs: Number((cpuTotal.system / 1000).toFixed(3)),
},
throughputSeries,
hotFunctions: 'not measured',
startedAtIso: stats.startedAtIso,
completedAtIso,
runDurationSec: Number(runDurationSec.toFixed(3)),
},
};
report.remediation = buildRemediation(report);
const dateStr = new Date().toISOString().slice(0, 10);
const jsonPath = path.join(reportDir, `load-report-${dateStr}.json`);
const markdownPath = path.join(reportDir, `load-test-report-${dateStr}.md`);
fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2));
const markdown = [
`# Load Test Report - ${dateStr}`,
'',
'## Safety',
'- Does not target public relays.',
'- Requires explicit `RELAY_URL`.',
`- Relay target: ${relaySummary}`,
`- Mode: ${dryRun ? 'dry-run (no network sends)' : 'live publish test'}`,
'',
'## Configuration',
`- Clients: ${clients}`,
`- Duration: ${durationSec}s`,
`- Rate: ${rateEps} events/sec`,
`- Metadata mix: ${mix}`,
`- Max connections: ${maxConnections}`,
`- Max in-flight publishes: ${maxInflight}`,
`- Seed: ${seed || '(random)'}`,
'',
'## Summary',
`- Attempted: ${report.results.attempted}`,
`- Succeeded: ${report.results.succeeded}`,
`- Failed: ${report.results.failed}`,
`- Throughput: ${report.results.throughputEps} events/sec`,
`- Success rate: ${(report.results.successRate * 100).toFixed(2)}%`,
'',
'## Latency',
`- p50: ${report.results.latency.p50Ms.toFixed(3)} ms`,
`- p90: ${report.results.latency.p90Ms.toFixed(3)} ms`,
`- p95: ${report.results.latency.p95Ms.toFixed(3)} ms`,
`- p99: ${report.results.latency.p99Ms.toFixed(3)} ms`,
'',
'## Event Loop Lag',
`- Avg: ${report.results.eventLoopLag.avgMs} ms`,
`- p95: ${report.results.eventLoopLag.p95Ms} ms`,
'',
'## Error Taxonomy',
`- ${Object.entries(report.results.errorBreakdown).map(([key, value]) => `${key}: ${value}`).join(', ') || 'none'}`,
'',
'## Hot Functions',
'- not measured',
'',
'## Prioritized Remediation',
...report.remediation.map((item, index) => `${index + 1}. ${item}`),
'',
].join('\n');
fs.writeFileSync(markdownPath, markdown);
console.log(`JSON report written: ${jsonPath}`);
console.log(`Markdown report written: ${markdownPath}`);
}
main().catch((error) => {
console.error(`Load test harness failed: ${error?.stack || error}`);
process.exit(1);
});