iostress
Version:
🚀 Blast your Socket.IO server with this quick and powerful JavaScript testing tool!
746 lines (739 loc) • 26.4 kB
JavaScript
;
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
});