UNPKG

iostress

Version:

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

746 lines (739 loc) • 26.4 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { IOStress: () => IOStress, defaultTerminalEventsHandler: () => defaultTerminalEventsHandler, defaultTerminalInterface: () => defaultTerminalInterface, defaultTerminalTerminator: () => defaultTerminalTerminator }); module.exports = __toCommonJS(index_exports); // src/interface/config.ts var v = __toESM(require("valibot")); // src/utils.ts var import_typescript = __toESM(require("typescript")); var import_fs = __toESM(require("fs")); var import_path = __toESM(require("path")); var import_kleur = __toESM(require("kleur")); var import_events = __toESM(require("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 import_events.default { 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: import_typescript.default.ModuleKind.CommonJS, target: import_typescript.default.ScriptTarget.ES2020, noEmitOnError: true, strict: true, esModuleInterop: true, skipLibCheck: true, ...compilerOptions, noEmit: false, declaration: false, rootDir: void 0, outDir }; const program = import_typescript.default.createProgram([scenarioPath], options); const emitResult = program.emit(); const allDiagnostics = import_typescript.default.getPreEmitDiagnostics(program).concat(emitResult.diagnostics); if (allDiagnostics.length) { const formatted = import_typescript.default.formatDiagnosticsWithColorAndContext(allDiagnostics, { getCanonicalFileName: (f) => f, getCurrentDirectory: import_typescript.default.sys.getCurrentDirectory, getNewLine: () => `${import_typescript.default.sys.newLine} -> ` }); throw new Error(`${import_kleur.default.red("[TS] Compilation error:")} ${formatted}`); } }; var loadTsConfig = (startDir) => { let dir = import_path.default.resolve(startDir); let tsconfigPath; while (true) { const configPath = import_path.default.join(dir, "tsconfig.json"); if (import_fs.default.existsSync(configPath)) { tsconfigPath = configPath; break; } const parent = import_path.default.dirname(dir); if (parent === dir) break; dir = parent; } if (!tsconfigPath) return; const configFile = import_typescript.default.readConfigFile(tsconfigPath, import_typescript.default.sys.readFile); if (configFile.error) { throw new Error( `${import_kleur.default.red("[TS] Reading tsconfig.json error:")} - at: ${import_kleur.default.gray( tsconfigPath )} ${import_typescript.default.flattenDiagnosticMessageText( configFile.error.messageText, "\n -> " )}` ); } const configParseResult = import_typescript.default.parseJsonConfigFileContent( configFile.config, import_typescript.default.sys, import_path.default.dirname(tsconfigPath) ); if (configParseResult.errors.length) { const errorsFormatted = import_typescript.default.formatDiagnosticsWithColorAndContext( configParseResult.errors, { getCanonicalFileName: (f) => f, getCurrentDirectory: import_typescript.default.sys.getCurrentDirectory, getNewLine: () => `${import_typescript.default.sys.newLine} -> ` } ); throw new Error( `${import_kleur.default.red("[TS] Parsing tsconfig error:")} ${errorsFormatted}` ); } return configParseResult.options; }; var isValidExtension = (ext) => [".js", ".ts"].includes(ext.toLowerCase()); // src/interface/config.ts var import_node_path = __toESM(require("path")); var import_node_fs = __toESM(require("fs")); var import_kleur2 = __toESM(require("kleur")); var import_nanospinner = require("nanospinner"); var import_node_util = require("util"); var import_node_url = require("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) => import_node_path.default.resolve(value)), v.custom((value) => { if (!import_node_fs.default.existsSync(value)) { throw new Error("Scenario path not exists!"); } if (!import_node_fs.default.statSync(value).isFile()) { throw new Error(`Scenario path is not a file: ${value}`); } const ext = import_node_path.default.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} " + import_kleur2.default.bold(`IO Stress v${"1.0.0"}`)); }).on("phase-started", (phaseName) => { console.log(`---------------------------------------------`); console.log(`Testing phase: ${import_kleur2.default.green(phaseName)}...`); }).on("phase-task", (id, description, data) => { let details = ""; if (id === 1 /* TEST_RUN */) { const { readyClients, runningClients, finishedClients } = data; details = ` ${import_kleur2.default.gray( ` - [${readyClients}] ready clients - [${runningClients}] running clients - [${finishedClients}] finished clients` )}`; } if (activeSpinner) activeSpinner.update(description + details); else { activeSpinner = (0, import_nanospinner.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 ?? import_node_path.default.join(process.cwd(), "iostress-reports"); if (!import_node_fs.default.existsSync(reportsDir)) { import_node_fs.default.mkdirSync(reportsDir, { recursive: true }); } const reportsPath = import_node_path.default.join( reportsDir, `${report.phase.toLowerCase().replaceAll(" ", "-")}-phase.report.json` ); try { import_node_fs.default.writeFileSync(reportsPath, JSON.stringify(report, null, 2)); console.log( import_kleur2.default.gray( `Phase test report saved at: ${import_kleur2.default.cyan(reportsPath)}` ) ); if (Object.keys(workersErrors).length) { console.log(import_kleur2.default.red("Workers errors:")); console.log(import_kleur2.default.gray((0, import_node_util.inspect)(workersErrors))); } } catch (error) { console.error("Failed to write report file!\n" + error); } }); }; } async function resolveScenario(scenarioPath) { const ext = import_node_path.default.extname(scenarioPath); if (ext === ".ts") { const tsConfig = loadTsConfig(import_node_path.default.dirname(scenarioPath)); const outDir = import_node_path.default.join(__dirname, "temp_sc_compiled"); await compileTsScenario(scenarioPath, outDir, tsConfig ?? {}); scenarioPath = import_node_path.default.join(outDir, import_node_path.default.basename(scenarioPath, ext) + ".js"); } await validateScenario(scenarioPath); return scenarioPath; } async function validateScenario(scenarioPath) { scenarioPath = (0, import_node_url.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 var import_os = require("os"); var import_path2 = __toESM(require("path")); var import_node_worker_threads = require("worker_threads"); var import_node_events = __toESM(require("events")); var import_node_url2 = require("url"); var TaskManager = class extends import_node_events.default { 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 = (0, import_os.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 = (0, import_node_url2.pathToFileURL)(this.phase.scenarioPath).href; const worker = new import_node_worker_threads.Worker(import_path2.default.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; } }; // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { IOStress, defaultTerminalEventsHandler, defaultTerminalInterface, defaultTerminalTerminator });