UNPKG

iostress

Version:

🚀 Blast your Socket.IO server with this quick and powerful JavaScript testing tool!

713 lines (707 loc) • 23.7 kB
// node_modules/tsup/assets/esm_shims.js import { fileURLToPath } from "url"; import path from "path"; var getFilename = () => fileURLToPath(import.meta.url); var getDirname = () => path.dirname(getFilename()); var __dirname = /* @__PURE__ */ getDirname(); // src/interface/config.ts import * as v from "valibot"; // src/utils.ts import ts from "typescript"; import fs from "fs"; import path2 from "path"; import kleur from "kleur"; import EventEmitter from "events"; var Performance = class { timers = {}; incrementorIdx = 0; measure(timerId, keepTimer = false) { if (!this.timers[timerId]) return -1; const [seconds, nanoseconds] = process.hrtime(this.timers[timerId]); const milliseconds = seconds * 1e3 + nanoseconds / 1e6; if (!keepTimer) this.delete(timerId); return milliseconds; } delete(timerId) { if (this.timers[timerId]) delete this.timers[timerId]; } start() { this.timers[this.incrementorIdx] = process.hrtime(); return this.incrementorIdx++; } }; var StressEventsEmitter = class extends EventEmitter { on(eventName, listener) { return super.on(eventName, listener); } emit(eventName, ...args) { return super.emit(eventName, ...args); } once(eventName, listener) { return super.once(eventName, listener); } off(eventName, listener) { return super.off(eventName, listener); } }; var calculatePercentiles = (percentiles, latencies) => { const sortedLatencies = [...latencies].sort((a, b) => a - b); const results = {}; for (const percentile of percentiles) { const index = Math.ceil(percentile / 100 * sortedLatencies.length) - 1; results[`p${percentile}`] = sortedLatencies[index]; } return results; }; var compileTsScenario = async (scenarioPath, outDir, compilerOptions = {}) => { const options = { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, noEmitOnError: true, strict: true, esModuleInterop: true, skipLibCheck: true, ...compilerOptions, noEmit: false, declaration: false, rootDir: void 0, outDir }; const program = ts.createProgram([scenarioPath], options); const emitResult = program.emit(); const allDiagnostics = ts.getPreEmitDiagnostics(program).concat(emitResult.diagnostics); if (allDiagnostics.length) { const formatted = ts.formatDiagnosticsWithColorAndContext(allDiagnostics, { getCanonicalFileName: (f) => f, getCurrentDirectory: ts.sys.getCurrentDirectory, getNewLine: () => `${ts.sys.newLine} -> ` }); throw new Error(`${kleur.red("[TS] Compilation error:")} ${formatted}`); } }; var loadTsConfig = (startDir) => { let dir = path2.resolve(startDir); let tsconfigPath; while (true) { const configPath = path2.join(dir, "tsconfig.json"); if (fs.existsSync(configPath)) { tsconfigPath = configPath; break; } const parent = path2.dirname(dir); if (parent === dir) break; dir = parent; } if (!tsconfigPath) return; const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile); if (configFile.error) { throw new Error( `${kleur.red("[TS] Reading tsconfig.json error:")} - at: ${kleur.gray( tsconfigPath )} ${ts.flattenDiagnosticMessageText( configFile.error.messageText, "\n -> " )}` ); } const configParseResult = ts.parseJsonConfigFileContent( configFile.config, ts.sys, path2.dirname(tsconfigPath) ); if (configParseResult.errors.length) { const errorsFormatted = ts.formatDiagnosticsWithColorAndContext( configParseResult.errors, { getCanonicalFileName: (f) => f, getCurrentDirectory: ts.sys.getCurrentDirectory, getNewLine: () => `${ts.sys.newLine} -> ` } ); throw new Error( `${kleur.red("[TS] Parsing tsconfig error:")} ${errorsFormatted}` ); } return configParseResult.options; }; var isValidExtension = (ext) => [".js", ".ts"].includes(ext.toLowerCase()); // src/interface/config.ts import path3 from "node:path"; import fs2 from "node:fs"; import kleur2 from "kleur"; import { createSpinner } from "nanospinner"; import { inspect } from "node:util"; import { pathToFileURL } from "node:url"; function validateOptions(options) { const interfaceOptions = v.optional( v.object({ terminator: v.optional(v.function()), eventsHandler: v.optional(v.function()), logsDir: v.optional(v.string()) }) ); const schema = v.object({ target: v.pipe(v.string(), v.url()), interfaceOptions, phases: v.array( v.pipe( v.object({ name: v.string(), minClients: v.pipe(v.number(), v.minValue(1)), maxClients: v.optional(v.pipe(v.number(), v.minValue(1))), rampDelayRate: v.optional(v.pipe(v.number(), v.minValue(100))), scenarioInitializer: v.optional(v.function()), scenarioPath: v.pipe( v.string(), v.transform((value) => path3.resolve(value)), v.custom((value) => { if (!fs2.existsSync(value)) { throw new Error("Scenario path not exists!"); } if (!fs2.statSync(value).isFile()) { throw new Error(`Scenario path is not a file: ${value}`); } const ext = path3.extname(value); if (!isValidExtension(ext)) { throw new Error(`Invalid file extension for scenario: ${ext}`); } return true; }) ), scenarioTimeout: v.optional(v.pipe(v.number(), v.minValue(1e3))), interfaceOptions, reportsPath: v.optional(v.string()), logsPath: v.optional(v.string()) }), v.custom((value) => { if (value.maxClients !== void 0 && value.maxClients <= value.minClients) { throw new Error("Max clients must be greater than min clients!"); } return true; }) ) ) }); v.parse(schema, options); } async function resolvePhaseOptions(options, phaseIndex) { const phase = options.phases[phaseIndex]; options.interfaceOptions = options.interfaceOptions || defaultTerminalInterface(); if (!options.interfaceOptions.terminator) options.interfaceOptions.terminator = defaultTerminalTerminator(); if (!options.interfaceOptions.eventsHandler) options.interfaceOptions.eventsHandler = defaultTerminalEventsHandler(); if (!options.interfaceOptions.logsDir) options.interfaceOptions.logsDir = process.cwd(); phase.scenarioPath = await resolveScenario(phase.scenarioPath); const interfaceOptions = phase.interfaceOptions ? { ...options.interfaceOptions, ...phase.interfaceOptions } : options.interfaceOptions; const eventsEmitter = new StressEventsEmitter(); interfaceOptions.eventsHandler(eventsEmitter); return { target: options.target, interfaceOptions: { terminator: interfaceOptions.terminator(), eventsEmitter, logsDir: interfaceOptions.logsDir }, name: phase.name, minClients: phase.minClients, maxClients: phase.maxClients ?? phase.minClients, rampDelayRate: phase.rampDelayRate ?? 100, scenarioInitializer: () => ({}), scenarioPath: phase.scenarioPath, scenarioTimeout: phase.scenarioTimeout }; } function defaultTerminalInterface({ softTerminatorSignal, hardTerminatorSignal, reportsDir, logsDir } = {}) { return { terminator: defaultTerminalTerminator( softTerminatorSignal, hardTerminatorSignal ), eventsHandler: defaultTerminalEventsHandler(reportsDir), logsDir: logsDir ?? process.cwd() }; } function defaultTerminalTerminator(softTerminatorSignal = "t", hardTerminatorSignal = "") { if (softTerminatorSignal === hardTerminatorSignal) throw new Error( "softTerminatorSignal and hardTerminatorSignal cannot be the same!" ); if (softTerminatorSignal.length !== 1) throw new Error("softTerminatorSignal must be a single character!"); if (hardTerminatorSignal.length !== 1) throw new Error("hardTerminatorSignal must be a single character!"); return function terminalTerminator() { const abortController = new AbortController(); process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.setEncoding("utf8"); process.stdin.on("data", (key) => { if (key.toString() === softTerminatorSignal) abortController.abort(0 /* SIG_SOFT */); else if (key.toString() === hardTerminatorSignal) abortController.abort(1 /* SIG_HARD */); }); abortController.signal.addEventListener( "abort", () => { process.stdin.setRawMode(false); process.stdin.removeAllListeners("data"); process.stdin.pause(); }, { once: true } ); return abortController; }; } function defaultTerminalEventsHandler(reportsDir) { let activeSpinner = null; return function terminalEventsHandler(eventEmitter) { eventEmitter.on("process-started", () => { console.log("\u{1F680} " + kleur2.bold(`IO Stress v${"1.0.0"}`)); }).on("phase-started", (phaseName) => { console.log(`---------------------------------------------`); console.log(`Testing phase: ${kleur2.green(phaseName)}...`); }).on("phase-task", (id, description, data) => { let details = ""; if (id === 1 /* TEST_RUN */) { const { readyClients, runningClients, finishedClients } = data; details = ` ${kleur2.gray( ` - [${readyClients}] ready clients - [${runningClients}] running clients - [${finishedClients}] finished clients` )}`; } if (activeSpinner) activeSpinner.update(description + details); else { activeSpinner = createSpinner(description + details).start(); } }).on("phase-task-result", (_, description, success) => { if (activeSpinner) { if (success) activeSpinner.success(description); else activeSpinner.error(description); activeSpinner = null; } }).on("phase-result", (report, workersErrors) => { reportsDir = reportsDir ?? path3.join(process.cwd(), "iostress-reports"); if (!fs2.existsSync(reportsDir)) { fs2.mkdirSync(reportsDir, { recursive: true }); } const reportsPath = path3.join( reportsDir, `${report.phase.toLowerCase().replaceAll(" ", "-")}-phase.report.json` ); try { fs2.writeFileSync(reportsPath, JSON.stringify(report, null, 2)); console.log( kleur2.gray( `Phase test report saved at: ${kleur2.cyan(reportsPath)}` ) ); if (Object.keys(workersErrors).length) { console.log(kleur2.red("Workers errors:")); console.log(kleur2.gray(inspect(workersErrors))); } } catch (error) { console.error("Failed to write report file!\n" + error); } }); }; } async function resolveScenario(scenarioPath) { const ext = path3.extname(scenarioPath); if (ext === ".ts") { const tsConfig = loadTsConfig(path3.dirname(scenarioPath)); const outDir = path3.join(__dirname, "temp_sc_compiled"); await compileTsScenario(scenarioPath, outDir, tsConfig ?? {}); scenarioPath = path3.join(outDir, path3.basename(scenarioPath, ext) + ".js"); } await validateScenario(scenarioPath); return scenarioPath; } async function validateScenario(scenarioPath) { scenarioPath = pathToFileURL(scenarioPath).href; let fn = (await import(scenarioPath)).default; if (typeof fn !== "function" && typeof fn.default !== "function") throw new Error( `Scenario file must export a default function! - esm: export default ... - cjs: module.exports = ...;` ); return; } // src/runner/task-manager.ts import { availableParallelism } from "os"; import path4 from "path"; import { Worker } from "node:worker_threads"; import EventEmitter2 from "node:events"; import { pathToFileURL as pathToFileURL2 } from "node:url"; var TaskManager = class extends EventEmitter2 { constructor(phase) { super(); this.phase = phase; } performance = new Performance(); //? latency measuring tool phaseTimer; //? whole phase test duration timer workersStatus = {}; workerErrors = {}; clientsStatus = { readyClients: 0, runningClients: 0, finishedClients: 0 }; async run() { const threadsCount = availableParallelism() || 1; const starterClientsPerThread = Math.ceil( this.phase.minClients / threadsCount ); const finalClientsPerThread = this.phase.maxClients ? Math.ceil( (this.phase.maxClients - this.phase.minClients) / threadsCount ) : 0; this.phaseTimer = this.performance.start(); for (let i = 0; i < threadsCount; i++) { const starterInitializers = this.phase.initializers.slice( i * starterClientsPerThread, (i + 1) * starterClientsPerThread ); const starterGap = threadsCount * starterClientsPerThread; const finalInitializers = this.phase.initializers.slice( i * finalClientsPerThread + starterGap, (i + 1) * finalClientsPerThread + starterGap ); if (!starterInitializers.length && !finalInitializers.length) continue; const scenarioPath = pathToFileURL2(this.phase.scenarioPath).href; const worker = new Worker(path4.join(__dirname, "test-runner.js"), { stdout: false, workerData: { target: this.phase.target, name: this.phase.name, starterInitializers, finalInitializers, rampDelayRate: this.phase.rampDelayRate, scenarioPath, scenarioTimeout: this.phase.scenarioTimeout, logsDir: this.phase.interfaceOptions.logsDir } }); this.workersStatus[worker.threadId] = { workerStatus: "running", clientStatus: { readyClients: 0, runningClients: 0, finishedClients: 0 }, softTerminator: () => { worker.postMessage({ event: "SIGTERM" }); }, hardTerminator: () => { return worker.terminate(); } }; worker.on("message", async (message) => { if (message.event === "status") { this.workersStatus[worker.threadId].clientStatus = message.data; this.clientsStatus = this.reCalculateClientsStatus(); this.emit("status", this.clientsStatus); } else if (message.event === "finished") { this.workersStatus[worker.threadId].workerStatus = "finished"; this.workersStatus[worker.threadId].report = message.report; return this.handleReport(); } }); worker.on("error", (error) => { if (this.workerErrors[worker.threadId]) this.workerErrors[worker.threadId].push(error); else this.workerErrors[worker.threadId] = [error]; }); worker.on("exit", (code) => { if (code !== 0) { if (this.workerErrors[worker.threadId]) this.workerErrors[worker.threadId].push( new Error(`Worker stopped with exit code ${code}`) ); else this.workerErrors[worker.threadId] = [ new Error(`Worker stopped with exit code ${code}`) ]; } }); } if (this.phase.interfaceOptions.terminator) { this.phase.interfaceOptions.terminator.signal.addEventListener( "abort", () => { if (this.phase.interfaceOptions.terminator?.signal.reason == 1 /* SIG_HARD */) { this.hardStop(); } else if (this.phase.interfaceOptions.terminator?.signal.reason == 0 /* SIG_SOFT */) { this.sendSIGTERM(); } }, { once: true } ); } } reCalculateClientsStatus() { const status = { readyClients: 0, runningClients: 0, finishedClients: 0 }; for (const { clientStatus } of Object.values(this.workersStatus)) { status.readyClients += clientStatus.readyClients; status.runningClients += clientStatus.runningClients; status.finishedClients += clientStatus.finishedClients; } return status; } async handleReport() { for (const { workerStatus } of Object.values(this.workersStatus)) { if (workerStatus === "running") { return; } } for (const { hardTerminator } of Object.values(this.workersStatus)) { await hardTerminator(); } this.phase.interfaceOptions.terminator.abort(2 /* SIG_CLEANUP */); this.emit("gathering"); const finalReport = { phase: this.phase.name, testDuration: this.performance.measure(this.phaseTimer) / 1e3, connections: { attempted: 0, successful: 0, failed: 0, averageConnectionTime: 0, reconnectAttempts: 0 }, events: { sent: 0, received: 0, successful: 0, failed: 0, throughput: 0 }, latency: { average: 0, min: -1, max: -1, p50: 0, p85: 0, p95: 0, p99: 0 }, errors: { total: 0, byType: {} } }; const connectionsFramesData = { total: 0, sum: 0 }; const latencyFrames = []; for (const { report } of Object.values(this.workersStatus)) { if (!report) continue; finalReport.connections.attempted += report.connections.attempted; finalReport.connections.successful += report.connections.successful; finalReport.connections.failed += report.connections.failed; finalReport.connections.reconnectAttempts += report.connections.reconnectAttempts; finalReport.events.sent += report.events.sent; finalReport.events.received += report.events.received; finalReport.events.successful += report.events.successful; finalReport.events.failed += report.events.failed; finalReport.latency.min = finalReport.latency.min === -1 ? Math.min(...report.events.latencyFrames) : Math.min(...report.events.latencyFrames, finalReport.latency.min); finalReport.latency.max = Math.max( ...report.events.latencyFrames, finalReport.latency.max ); finalReport.errors.total += report.errors.total; for (const errorType in report.errors.byType) { if (finalReport.errors.byType[errorType]) { finalReport.errors.byType[errorType] += report.errors.byType[errorType]; } else { finalReport.errors.byType[errorType] = report.errors.byType[errorType]; } } connectionsFramesData.total += report.connections.latencyFrames.length; connectionsFramesData.sum += report.connections.latencyFrames.reduce( (acc, cur) => acc + cur, 0 ); latencyFrames.push(...report.events.latencyFrames); } finalReport.connections.averageConnectionTime = connectionsFramesData.sum / connectionsFramesData.total; finalReport.latency.average = latencyFrames.reduce((acc, cur) => acc + cur, 0) / latencyFrames.length; finalReport.events.throughput = Number( (1e3 / finalReport.latency.average).toFixed(2) ); const { p50, p85, p95, p99 } = calculatePercentiles( [50, 85, 95, 99], latencyFrames ); finalReport.latency.p50 = p50; finalReport.latency.p85 = p85; finalReport.latency.p95 = p95; finalReport.latency.p99 = p99; this.emit("finished", { report: finalReport, workerErrors: this.workerErrors }); } sendSIGTERM() { for (const { softTerminator } of Object.values(this.workersStatus)) { softTerminator(); } } async hardStop() { for (const { hardTerminator } of Object.values(this.workersStatus)) { await hardTerminator(); } this.emit("hard-stopped"); } }; // src/interface/interface.ts var IOStress = class { constructor(options) { this.options = options; validateOptions(options); } async run() { for (let i = 0; i < this.options.phases.length; i++) { const resolvedOptions = await resolvePhaseOptions(this.options, i); if (!i) resolvedOptions.interfaceOptions.eventsEmitter.emit("process-started"); const aborted = await this.testPhase(resolvedOptions); resolvedOptions.interfaceOptions.eventsEmitter.removeAllListeners(); if (aborted) break; } } testPhase(phase) { return new Promise(async (resolve, reject) => { try { phase.interfaceOptions.eventsEmitter.emit("phase-started", phase.name); phase.interfaceOptions.eventsEmitter.emit( "phase-task", 0 /* INITIALIZERS_BUILD */, "Building phase initializers..." ); let initializers; try { initializers = await this.buildPhaseInitializers(phase); } catch (error) { phase.interfaceOptions.eventsEmitter.emit( "phase-task-result", 0 /* INITIALIZERS_BUILD */, "Failed to build phase initializers!", false ); return reject(error.message); } phase.interfaceOptions.eventsEmitter.emit( "phase-task-result", 0 /* INITIALIZERS_BUILD */, "Phase initializers built", true ); const taskManager = new TaskManager({ ...phase, initializers }); phase.interfaceOptions.eventsEmitter.emit( "phase-task", 1 /* TEST_RUN */, "Running test...", { readyClients: 0, runningClients: 0, finishedClients: 0 } ); taskManager.on("status", (data) => { phase.interfaceOptions.eventsEmitter.emit( "phase-task", 1 /* TEST_RUN */, "Running test...", data ); }); taskManager.on("hard-stopped", () => { phase.interfaceOptions.eventsEmitter.emit( "phase-task-result", 1 /* TEST_RUN */, "Test has been aborted by user!", false ); resolve(true); }); taskManager.on("gathering", () => { phase.interfaceOptions.eventsEmitter.emit( "phase-task-result", 1 /* TEST_RUN */, "Finished", true ); phase.interfaceOptions.eventsEmitter.emit( "phase-task", 2 /* REPORT_GENERATE */, "Generating report..." ); }); taskManager.on( "finished", (phaseResult) => { phase.interfaceOptions.eventsEmitter.emit( "phase-task-result", 2 /* REPORT_GENERATE */, "Report generated", true ); phase.interfaceOptions.eventsEmitter.emit( "phase-result", phaseResult.report, phaseResult.workerErrors ); resolve(false); } ); taskManager.run(); } catch (error) { reject(error); } }); } async buildPhaseInitializers(phase) { const initializers = []; const { scenarioInitializer, minClients, maxClients } = phase; for (let i = 0; i < (maxClients ?? minClients); i++) { if (scenarioInitializer) { initializers.push(await scenarioInitializer(i)); } else { initializers.push({}); } } return initializers; } }; export { IOStress, defaultTerminalEventsHandler, defaultTerminalInterface, defaultTerminalTerminator };