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