tressi
Version:
A lightweight, declarative stress testing CLI for modern developers.
2,047 lines • 67.1 kB
JavaScript
"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, {
runLoadTest: () => runLoadTest
});
module.exports = __toCommonJS(index_exports);
var import_chalk2 = __toESM(require("chalk"));
var import_cli_table3 = __toESM(require("cli-table3"));
var import_fs2 = require("fs");
var import_ora2 = __toESM(require("ora"));
var import_path2 = __toESM(require("path"));
var import_perf_hooks2 = require("perf_hooks");
var import_zod2 = require("zod");
// package.json
var package_default = {
name: "tressi",
version: "0.0.12",
description: "A lightweight, declarative stress testing CLI for modern developers.",
license: "Unlicense",
keywords: [
"performance",
"load",
"stress",
"testing",
"cli",
"automation"
],
author: "kevinchatham",
type: "commonjs",
main: "dist/index.js",
bin: {
tressi: "dist/cli.js"
},
scripts: {
build: "tsup",
format: "prettier --write . --config .prettierrc --ignore-path .prettierignore",
"lint:inspect": "eslint --inspect-config",
"lint:watch": 'chokidar "**/*.ts" -c "npm run lint" --ignore "**/node_modules/**"',
lint: "eslint --fix --format unix --config ./eslint.config.mjs .",
prebuild: "npm run schema:generate",
"pretest:dev": "npm run schema:generate",
"pretest:autoscale": "npm run schema:generate",
"pretest:basic": "npm run schema:generate",
"pretest:ci": "npm run schema:generate",
"pretest:ramp": "npm run schema:generate",
"pretest:soak": "npm run schema:generate",
"pretest:spike": "npm run schema:generate",
"schema:generate": "tsx scripts/generate-schema.ts && npm run format",
start: "npm run build && node dist/cli.js",
"test:dev": "tsx src/cli.ts --duration 60 --rps 5 --export temp/report",
"test:autoscale": "tsx src/cli.ts --autoscale --workers 50 --rps 1000 --duration 15",
"test:basic": "tsx src/cli.ts --workers 10 --duration 15 --rps 200",
"test:ci": "tsx src/cli.ts --workers 5 --duration 15 --rps 300 --no-ui --export",
"test:local": 'concurrently -k "tsx simple-server.ts" "sleep 2 && tsx src/cli.ts --config simple-server.json --workers 100 --duration 10 --no-ui"',
"test:ramp": "tsx src/cli.ts --workers 20 --duration 15 --rps 500 --ramp-up-time 10",
"test:soak": "tsx src/cli.ts --workers 5 --duration 150 --rps 50",
"test:spike": "tsx src/cli.ts --workers 100 --duration 10",
"test:ui": "vitest --ui",
test: "npm run typecheck:tests && vitest run",
typecheck: "tsc --noEmit",
"typecheck:tests": "tsc -p tsconfig.tests.json --noEmit"
},
dependencies: {
blessed: "^0.1.81",
"blessed-contrib": "^4.11.0",
chalk: "^5.4.1",
"cli-table3": "^0.6.5",
commander: "^14.0.0",
"hdr-histogram-js": "^3.0.1",
ora: "^8.2.0",
undici: "^7.3.0",
xlsx: "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
zod: "^3.25.74"
},
devDependencies: {
"@eslint/eslintrc": "^3.1.0",
"@types/blessed": "^0.1.25",
"@types/node": "^22.0.0",
"@vitest/ui": "^3.2.4",
"chokidar-cli": "^3.0.0",
concurrently: "^9.2.0",
"eslint-formatter-unix": "^8.40.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
prettier: "^3.5.3",
tsup: "^8.5.0",
tsx: "^4.20.3",
typescript: "^5.8.3",
"typescript-eslint": "^8.1.0",
vitest: "^3.2.4",
"zod-to-json-schema": "^3.24.6"
}
};
// src/config.ts
var import_fs = require("fs");
var import_path = __toESM(require("path"));
var import_undici = require("undici");
var import_zod = require("zod");
var RequestConfigSchema = import_zod.z.object({
/** The URL to send the request to. */
url: import_zod.z.string().url(),
/** The request payload. Can be a JSON object or an array. */
payload: import_zod.z.record(import_zod.z.string(), import_zod.z.unknown()).or(import_zod.z.array(import_zod.z.unknown())).optional(),
/** The HTTP method to use for the request. Defaults to GET. */
method: import_zod.z.preprocess(
(val) => typeof val === "string" ? val.toUpperCase() : val,
import_zod.z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"])
).default("GET"),
/** Headers to be sent with this specific request. Merged with global headers. */
headers: import_zod.z.record(import_zod.z.string(), import_zod.z.string()).optional()
});
var TressiConfigSchema = import_zod.z.object({
/** A URL to the JSON schema for this configuration file. */
$schema: import_zod.z.string().optional(),
/** Global headers to be sent with every request. */
headers: import_zod.z.record(import_zod.z.string(), import_zod.z.string()).optional(),
/** An array of request configurations. */
requests: import_zod.z.array(RequestConfigSchema)
});
async function loadConfig(configInput) {
if (typeof configInput === "object") {
return TressiConfigSchema.parse(configInput);
}
let rawContent;
if (configInput.startsWith("http://") || configInput.startsWith("https://")) {
const { statusCode, body } = await (0, import_undici.request)(configInput);
if (statusCode >= 400) {
throw new Error(`Remote config fetch failed: ${statusCode}`);
}
rawContent = await body.json();
} else {
const absolutePath = import_path.default.resolve(configInput);
const fileContent = await import_fs.promises.readFile(absolutePath, "utf-8");
rawContent = JSON.parse(fileContent);
}
return TressiConfigSchema.parse(rawContent);
}
// src/exporter.ts
var import_chalk = __toESM(require("chalk"));
var import_promises = require("fs/promises");
var import_ora = __toESM(require("ora"));
var xlsx = __toESM(require("xlsx"));
// src/stats.ts
function getStatusCodeDistributionByCategory(statusCodeMap) {
const distribution = {
"2xx": 0,
"3xx": 0,
"4xx": 0,
"5xx": 0,
other: 0
};
for (const [codeStr, count] of Object.entries(statusCodeMap)) {
const code = Number(codeStr);
if (code >= 200 && code < 300) {
distribution["2xx"] += count;
} else if (code >= 300 && code < 400) {
distribution["3xx"] += count;
} else if (code >= 400 && code < 500) {
distribution["4xx"] += count;
} else if (code >= 500 && code < 600) {
distribution["5xx"] += count;
} else {
distribution.other += count;
}
}
return distribution;
}
// src/exporter.ts
async function exportRawLog(path3, results) {
const headers = [
"timestamp",
"url",
"status",
"latencyMs",
"success",
"error"
];
const rows = results.map(
(r) => [
r.timestamp,
`"${r.url}"`,
r.status,
r.latencyMs.toFixed(0),
r.success,
`"${r.error || ""}"`
].join(",")
);
const csv = [headers.join(","), ...rows].join("\n");
await (0, import_promises.writeFile)(path3, csv, "utf-8");
}
async function exportXlsx(path3, results, summary, runner) {
const { global: globalSummary, endpoints: endpointSummary } = summary;
const wb = xlsx.utils.book_new();
const globalArray = Object.entries(globalSummary).map(([key, value]) => ({
Stat: key,
Value: typeof value === "number" ? Math.round(value) : value
}));
globalArray.unshift({ Stat: "Tressi Version", Value: summary.tressiVersion });
const wsGlobal = xlsx.utils.json_to_sheet(globalArray);
xlsx.utils.book_append_sheet(wb, wsGlobal, "Global Summary");
const formattedEndpoints = endpointSummary.map((endpoint) => ({
...endpoint,
avgLatencyMs: Math.round(endpoint.avgLatencyMs),
minLatencyMs: Math.round(endpoint.minLatencyMs),
maxLatencyMs: Math.round(endpoint.maxLatencyMs),
p95LatencyMs: Math.round(endpoint.p95LatencyMs),
p99LatencyMs: Math.round(endpoint.p99LatencyMs)
}));
const wsEndpoints = xlsx.utils.json_to_sheet(formattedEndpoints);
xlsx.utils.book_append_sheet(wb, wsEndpoints, "Endpoint Summary");
const statusCodeMap = runner.getStatusCodeMap();
const statusCodeDistribution = getStatusCodeDistributionByCategory(statusCodeMap);
const formattedStatusCodeDistribution = Object.entries(
statusCodeDistribution
).map(([category, count]) => ({
"Status Code Category": category,
Count: count
}));
const wsStatusCode = xlsx.utils.json_to_sheet(
formattedStatusCodeDistribution
);
xlsx.utils.book_append_sheet(wb, wsStatusCode, "Status Code Distribution");
const sampledResponses = results.filter((r) => r.body);
if (sampledResponses.length > 0) {
const uniqueSamples = /* @__PURE__ */ new Map();
for (const r of sampledResponses) {
const key = `${r.method} ${r.url} ${r.status}`;
if (!uniqueSamples.has(key)) {
uniqueSamples.set(key, r);
}
}
const samplesForSheet = Array.from(uniqueSamples.values()).sort((a, b) => a.status - b.status).map((r) => ({
Method: r.method,
URL: r.url,
"Status Code": r.status,
"Response Body": r.body
}));
if (samplesForSheet.length > 0) {
const wsSamples = xlsx.utils.json_to_sheet(samplesForSheet);
xlsx.utils.book_append_sheet(wb, wsSamples, "Sampled Responses");
}
}
await xlsx.writeFile(wb, path3);
}
async function exportDataFiles(summary, results, directory, runner) {
const exportSpinner = (0, import_ora.default)(`Exporting data files (CSV, XLSX)...`).start();
try {
const csvBasePath = `${directory}/results.csv`;
const xlsxPath = `${directory}/report.xlsx`;
const promises = [
exportRawLog(csvBasePath, results),
exportXlsx(xlsxPath, results, summary, runner)
];
await Promise.all(promises);
const successMessage = `Successfully exported raw log`;
exportSpinner.succeed(successMessage + " (CSV & XLSX)");
} catch (err) {
exportSpinner.fail(
import_chalk.default.red(`Failed to save data files: ${err.message}`)
);
}
}
// src/runner.ts
var import_events = require("events");
var import_hdr_histogram_js = require("hdr-histogram-js");
var import_perf_hooks = require("perf_hooks");
var import_undici3 = require("undici");
// src/circular-buffer.ts
var CircularBuffer = class {
buffer;
capacity;
head = 0;
tail = 0;
isFull = false;
/**
* Creates a new CircularBuffer instance.
* @param capacity The maximum number of items the buffer can hold.
*/
constructor(capacity) {
this.capacity = capacity;
this.buffer = new Array(capacity);
}
/**
* Adds an item to the buffer. If the buffer is full, it overwrites the oldest item.
* @param item The item to add.
*/
add(item) {
this.buffer[this.tail] = item;
this.tail = (this.tail + 1) % this.capacity;
if (this.isFull) {
this.head = (this.head + 1) % this.capacity;
} else if (this.tail === this.head) {
this.isFull = true;
}
}
/**
* Returns all the items in the buffer.
* @returns An array containing the items.
*/
getAll() {
const result = [];
let i = this.head;
const end = this.tail;
if (this.isFull) {
do {
result.push(this.buffer[i]);
i = (i + 1) % this.capacity;
} while (i !== end);
} else {
while (i !== end) {
result.push(this.buffer[i]);
i = (i + 1) % this.capacity;
}
}
return result;
}
/**
* Gets the current number of items in the buffer.
*/
size() {
if (this.isFull) {
return this.capacity;
}
if (this.tail >= this.head) {
return this.tail - this.head;
}
return this.capacity - this.head + this.tail;
}
};
// src/distribution.ts
var PERCENTILES = [0.5, 0.75, 0.9, 0.95, 0.99, 0.999, 1];
var Distribution = class {
buffer = [];
isSorted = false;
/**
* Adds a new latency measurement to the distribution.
* @param latency The latency value in milliseconds.
*/
add(latency) {
this.buffer.push(latency);
this.isSorted = false;
}
/**
* Gets the total number of latency measurements recorded.
* @returns The total count of items.
*/
getTotalCount() {
return this.buffer.length;
}
/**
* Calculates the latency at various predefined percentiles.
* @returns An array of objects, each containing a percentile and the corresponding latency.
*/
getPercentiles() {
if (!this.isSorted) {
this.buffer.sort((a, b) => a - b);
this.isSorted = true;
}
return PERCENTILES.map((percentile) => {
const index = Math.floor(this.buffer.length * percentile) - 1;
return {
percentile,
latency: this.buffer[Math.max(0, index)]
};
});
}
/**
* Generates a latency distribution report with a specified number of buckets.
* This is used to create tables and charts for the UI and reports.
* @param options - The options for generating the distribution.
* @param options.count - The number of buckets to group latencies into.
* @param options.chartWidth - The maximum width of the chart bar.
* @returns An array of objects representing each bucket in the distribution.
*/
getLatencyDistribution(options) {
if (this.buffer.length === 0) {
return [];
}
if (!this.isSorted) {
this.buffer.sort((a, b) => a - b);
this.isSorted = true;
}
const min = this.buffer[0];
const max = this.buffer[this.buffer.length - 1];
const range = max - min;
const bucketSize = Math.ceil(range / options.count) || 1;
const buckets = Array.from({ length: options.count }, (_, i) => {
const bucketMin = min + i * bucketSize;
const bucketMax = bucketMin + bucketSize - 1;
return {
min: bucketMin,
max: bucketMax,
count: 0
};
});
for (const latency of this.buffer) {
let bucketIndex = Math.floor((latency - min) / bucketSize);
bucketIndex = Math.min(bucketIndex, options.count - 1);
if (buckets[bucketIndex]) {
buckets[bucketIndex].count++;
}
}
let cumulativeCount = 0;
const totalCount = this.buffer.length;
const maxCountInBucket = Math.max(...buckets.map((b) => b.count));
const chartWidth = options.chartWidth || 20;
return buckets.map((bucket, i) => {
cumulativeCount += bucket.count;
const percent = (bucket.count / totalCount * 100).toFixed(0);
const cumulativePercent = (cumulativeCount / totalCount * 100).toFixed(
0
);
const chartBar = maxCountInBucket > 0 ? "\u2588".repeat(
Math.round(bucket.count / maxCountInBucket * chartWidth)
) : "";
const rangeLabel = i === options.count - 1 ? `${Math.round(bucket.min)}+` : `${Math.round(bucket.min)}-${Math.round(bucket.max)}`;
return {
latency: rangeLabel,
count: bucket.count.toString(),
percent: `${percent}%`,
cumulative: `${cumulativePercent}%`,
chart: chartBar
};
});
}
};
// src/http-agent.ts
var import_undici2 = require("undici");
var HttpAgentManager = class {
agents = /* @__PURE__ */ new Map();
defaultConfig;
constructor(defaultConfig = {}) {
this.defaultConfig = {
connections: 1024,
keepAliveTimeout: 4e3,
keepAliveMaxTimeout: 6e4,
headersTimeout: 3e4,
bodyTimeout: 3e4,
...defaultConfig
};
}
/**
* Get or create an agent for a specific endpoint
* @param endpointUrl The endpoint URL
* @param config Optional configuration override for this endpoint
* @returns Dispatcher instance for the endpoint
*/
getAgent(endpointUrl, config) {
if (process.env.NODE_ENV === "test") {
return void 0;
}
const key = this.getEndpointKey(endpointUrl);
if (!this.agents.has(key)) {
const mergedConfig = { ...this.defaultConfig, ...config };
const agent = new import_undici2.Agent({
connections: mergedConfig.connections,
keepAliveTimeout: mergedConfig.keepAliveTimeout,
keepAliveMaxTimeout: mergedConfig.keepAliveMaxTimeout,
headersTimeout: mergedConfig.headersTimeout,
bodyTimeout: mergedConfig.bodyTimeout
});
this.agents.set(key, agent);
}
return this.agents.get(key);
}
/**
* Remove an agent for a specific endpoint
* @param endpointUrl The endpoint URL
*/
removeAgent(endpointUrl) {
const key = this.getEndpointKey(endpointUrl);
const agent = this.agents.get(key);
if (agent) {
if ("close" in agent && typeof agent.close === "function") {
agent.close();
}
return this.agents.delete(key);
}
return false;
}
/**
* Get all active agents
* @returns Array of endpoint-agent pairs
*/
getAllAgents() {
return Array.from(this.agents.entries()).map(([url, agent]) => ({
url,
agent
}));
}
/**
* Close all agents and clear the cache
*/
closeAll() {
for (const [, agent] of this.agents.entries()) {
if ("close" in agent && typeof agent.close === "function") {
agent.close();
}
}
this.agents.clear();
}
/**
* Get the number of active agents
*/
getAgentCount() {
return this.agents.size;
}
/**
* Generate a consistent key for an endpoint URL
* @param endpointUrl The endpoint URL
* @returns Normalized key for the endpoint
*/
getEndpointKey(endpointUrl) {
try {
const url = new URL(endpointUrl);
return url.origin;
} catch {
return endpointUrl;
}
}
};
var globalAgentManager = new HttpAgentManager();
// src/runner.ts
async function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
var Runner = class extends import_events.EventEmitter {
options;
requests;
headers;
sampledResults = [];
histogram;
distribution;
endpointHistograms = /* @__PURE__ */ new Map();
recentLatenciesForSpinner;
recentRequestTimestamps;
statusCodeMap = {};
successfulRequestsByEndpoint = /* @__PURE__ */ new Map();
failedRequestsByEndpoint = /* @__PURE__ */ new Map();
stopped = false;
startTime = 0;
currentTargetRps;
successfulRequests = 0;
failedRequests = 0;
activeWorkers = [];
testTimeout;
rampUpInterval;
autoscaleInterval;
// Object allocation optimizations
headersPool = [];
resultPool = [];
endpointKeyCache = /* @__PURE__ */ new Map();
responseSamplingSets = /* @__PURE__ */ new Map();
maxPoolSize = 1e3;
/**
* Creates a new Runner instance.
* @param options The run options.
* @param requests An array of request configurations to be used in the test.
* @param headers A record of global headers to be sent with each request.
*/
constructor(options, requests, headers) {
super();
const validatedOptions = this.validateEarlyExitOptions(options);
this.options = validatedOptions;
this.requests = requests;
this.headers = headers;
this.histogram = (0, import_hdr_histogram_js.build)();
this.distribution = new Distribution();
this.currentTargetRps = options.rampUpTimeSec && options.rps ? 0 : options.rps || 0;
const maxRps = options.rps || 1e3;
const bufferSize = Math.max(1e4, maxRps * 2);
this.recentRequestTimestamps = new CircularBuffer(bufferSize);
this.recentLatenciesForSpinner = new CircularBuffer(1e3);
this.headersPool = [];
this.resultPool = [];
this.endpointKeyCache = /* @__PURE__ */ new Map();
this.responseSamplingSets = /* @__PURE__ */ new Map();
}
/**
* Validates early exit configuration options with proper defaults and constraints.
* @param options The raw RunOptions to validate
* @returns Validated RunOptions with defaults applied
*/
validateEarlyExitOptions(options) {
const validated = { ...options };
validated.earlyExitOnError = options.earlyExitOnError ?? false;
validated.errorRateThreshold = options.errorRateThreshold;
validated.errorCountThreshold = options.errorCountThreshold;
validated.errorStatusCodes = options.errorStatusCodes;
if (validated.earlyExitOnError) {
if (validated.errorRateThreshold !== void 0) {
if (typeof validated.errorRateThreshold !== "number" || validated.errorRateThreshold < 0 || validated.errorRateThreshold > 1) {
throw new Error(
"errorRateThreshold must be a number between 0.0 and 1.0"
);
}
}
if (validated.errorCountThreshold !== void 0) {
if (!Number.isInteger(validated.errorCountThreshold) || validated.errorCountThreshold < 0) {
throw new Error("errorCountThreshold must be a non-negative integer");
}
}
if (validated.errorStatusCodes !== void 0) {
if (!Array.isArray(validated.errorStatusCodes)) {
throw new Error("errorStatusCodes must be an array of numbers");
}
for (const code of validated.errorStatusCodes) {
if (!Number.isInteger(code) || code < 100 || code > 599) {
throw new Error(
`Invalid HTTP status code: ${code}. Must be between 100-599`
);
}
}
}
if (validated.errorRateThreshold === void 0 && validated.errorCountThreshold === void 0 && validated.errorStatusCodes === void 0) {
throw new Error(
"When earlyExitOnError is enabled, at least one of errorRateThreshold, errorCountThreshold, or errorStatusCodes must be provided"
);
}
}
return validated;
}
/**
* Records a new request result, updating all relevant statistics.
* @param result The `RequestResult` to record.
*/
onResult(result) {
if (this.sampledResults.length < 1e3) {
this.sampledResults.push({ ...result });
}
this.histogram.recordValue(result.latencyMs);
this.distribution.add(result.latencyMs);
const endpointKey = this.getEndpointKey(result.method, result.url);
if (!this.endpointHistograms.has(endpointKey)) {
this.endpointHistograms.set(endpointKey, (0, import_hdr_histogram_js.build)());
}
this.endpointHistograms.get(endpointKey).recordValue(result.latencyMs);
if (!this.options.useUI) {
this.recentLatenciesForSpinner.add(result.latencyMs);
}
this.recentRequestTimestamps.add(import_perf_hooks.performance.now());
this.statusCodeMap[result.status] = (this.statusCodeMap[result.status] || 0) + 1;
if (result.success) {
this.successfulRequests++;
this.successfulRequestsByEndpoint.set(
endpointKey,
(this.successfulRequestsByEndpoint.get(endpointKey) || 0) + 1
);
} else {
this.failedRequests++;
this.failedRequestsByEndpoint.set(
endpointKey,
(this.failedRequestsByEndpoint.get(endpointKey) || 0) + 1
);
}
this.releaseResultObject(result);
}
/**
* Gets a sample of the results collected during the test run.
* @returns An array of `RequestResult` objects.
*/
getSampledResults() {
return this.sampledResults;
}
/**
* Gets the histogram containing all latency values.
* @returns The HDR histogram instance.
*/
getHistogram() {
return this.histogram;
}
/**
* Generates a latency distribution report.
* @param options - The options for generating the distribution.
* @param options.count - The number of buckets to group latencies into.
* @returns An array of objects representing each bucket in the distribution.
*/
getLatencyDistribution(options) {
return this.distribution.getLatencyDistribution(options);
}
/**
* Gets the full Distribution instance.
* @returns The `Distribution` instance containing all latency data.
*/
getDistribution() {
return this.distribution;
}
/**
* Gets the map of histograms for each endpoint.
* @returns A map where keys are endpoint identifiers and values are HDR histograms.
*/
getEndpointHistograms() {
return this.endpointHistograms;
}
/**
* Gets an array of recent latency values for the non-UI spinner.
* @returns An array of latency numbers in milliseconds.
*/
getRecentLatencies() {
return this.recentLatenciesForSpinner.getAll();
}
/**
* Gets a map of status codes and their counts.
* @returns A record where keys are status codes and values are their counts.
*/
getStatusCodeMap() {
return this.statusCodeMap;
}
/**
* Gets the total count of successful requests.
* @returns The number of successful requests.
*/
getSuccessfulRequestsCount() {
return this.successfulRequests;
}
/**
* Gets the total count of failed requests.
* @returns The number of failed requests.
*/
getFailedRequestsCount() {
return this.failedRequests;
}
getSuccessfulRequestsByEndpoint() {
return this.successfulRequestsByEndpoint;
}
getFailedRequestsByEndpoint() {
return this.failedRequestsByEndpoint;
}
/**
* Calculates and returns the average latency for all requests.
* @returns The average latency in milliseconds.
*/
getAverageLatency() {
return this.histogram.mean;
}
/**
* Gets the timestamp when the test run started.
* @returns The start time as a Unix timestamp.
*/
getStartTime() {
return this.startTime;
}
/**
* Gets the current target requests per second (Req/s).
* This value changes during ramp-up.
* @returns The current target Req/s.
*/
getCurrentTargetRps() {
return Math.round(this.currentTargetRps);
}
/**
* Calculates the actual requests per second (Req/s) over the last second.
* @returns The current actual Req/s.
*/
getCurrentRps() {
const now = import_perf_hooks.performance.now();
const oneSecondAgo = now - 1e3;
const timestamps = this.recentRequestTimestamps.getAll();
let count = 0;
for (let i = timestamps.length - 1; i >= 0; i--) {
if (timestamps[i] >= oneSecondAgo) {
count++;
} else {
break;
}
}
return count;
}
/**
* Gets the current number of active workers.
* @returns The number of workers.
*/
getWorkerCount() {
if (this.options.autoscale) {
return this.activeWorkers.length;
}
return this.options.workers ?? 10;
}
/**
* Starts the load test. This is the main entry point for the runner.
* It sets up workers, timers, and ramp-up/autoscaling logic.
*/
async run() {
this.startTime = import_perf_hooks.performance.now();
const {
workers = 10,
durationSec = 10,
rampUpTimeSec = 0,
rps = 0,
autoscale = false
} = this.options;
const durationMs = durationSec * 1e3;
this.testTimeout = setTimeout(() => this.stop(), durationMs);
if (rampUpTimeSec > 0) {
this.rampUpInterval = setInterval(() => {
const elapsedTimeSec = (import_perf_hooks.performance.now() - this.startTime) / 1e3;
if (this.stopped) {
clearInterval(this.rampUpInterval);
return;
}
const rampUpProgress = Math.min(elapsedTimeSec / rampUpTimeSec, 1);
if (rps > 0) {
this.currentTargetRps = Math.round(rps * rampUpProgress);
} else {
const arbitraryMaxRps = (this.options.workers || 10) * 1e3;
this.currentTargetRps = Math.round(arbitraryMaxRps * rampUpProgress);
}
}, 1e3);
}
if (autoscale) {
this.addWorker();
this.autoscaleInterval = setInterval(() => {
if (this.stopped) {
clearInterval(this.autoscaleInterval);
return;
}
const currentRps = this.getCurrentRps();
const currentWorkers = this.activeWorkers.length;
if (currentWorkers === 0) {
this.addWorker();
return;
}
const targetRps = this.options.rps;
if (!targetRps) return;
const scaleUpThreshold = targetRps * 0.9;
const scaleDownThreshold = targetRps * 1.1;
if (currentRps < scaleUpThreshold && currentWorkers < workers) {
const rpsDeficit = targetRps - currentRps;
const avgRpsPerWorker = currentWorkers > 0 ? currentRps / currentWorkers : 10;
const workersNeeded = rpsDeficit / avgRpsPerWorker;
let workersToAdd = Math.ceil(workersNeeded * 0.25);
workersToAdd = Math.max(1, workersToAdd);
workersToAdd = Math.min(workersToAdd, workers - currentWorkers);
for (let i = 0; i < workersToAdd; i++) {
this.addWorker();
}
} else if (currentRps > scaleDownThreshold && currentWorkers > 1) {
const rpsSurplus = currentRps - targetRps;
const avgRpsPerWorker = currentRps / currentWorkers;
const workersToCut = rpsSurplus / avgRpsPerWorker;
let workersToRemove = Math.ceil(workersToCut * 0.25);
workersToRemove = Math.max(1, workersToRemove);
workersToRemove = Math.min(workersToRemove, currentWorkers - 1);
for (let i = 0; i < workersToRemove; i++) {
this.removeWorker();
}
}
}, 2e3);
await Promise.all(this.activeWorkers.map((w) => w.promise));
} else {
const workerPromises = Array.from(
{ length: workers },
() => this.runWorker()
);
await Promise.all(workerPromises);
}
this.cleanup();
}
stop() {
if (this.stopped) return;
this.stopped = true;
this.activeWorkers.forEach((w) => w.stop());
this.cleanup();
this.emit("stop");
}
/**
* Cleans up all active timers and intervals.
*/
cleanup() {
if (this.testTimeout) {
clearTimeout(this.testTimeout);
this.testTimeout = void 0;
}
if (this.rampUpInterval) {
clearInterval(this.rampUpInterval);
this.rampUpInterval = void 0;
}
if (this.autoscaleInterval) {
clearInterval(this.autoscaleInterval);
this.autoscaleInterval = void 0;
}
this.headersPool.length = 0;
this.resultPool.length = 0;
this.endpointKeyCache.clear();
this.responseSamplingSets.clear();
globalAgentManager.closeAll();
}
/**
* Adds a new worker to the pool. Used by the autoscaler.
*/
addWorker() {
let workerStopped = false;
const stop = () => {
workerStopped = true;
};
const promise = this.runWorker(() => workerStopped);
this.activeWorkers.push({ promise, stop });
}
/**
* Removes a worker from the pool and stops it. Used by the autoscaler.
*/
removeWorker() {
const worker = this.activeWorkers.pop();
if (worker) {
worker.stop();
}
}
/**
* Gets a reusable headers object from the pool or creates a new one
*/
getHeadersObject() {
return this.headersPool.pop() || {};
}
/**
* Returns a headers object to the pool for reuse
*/
releaseHeadersObject(headers) {
if (this.headersPool.length < this.maxPoolSize) {
for (const key in headers) {
delete headers[key];
}
this.headersPool.push(headers);
}
}
/**
* Gets a RequestResult object from the pool or creates a new one
*/
getResultObject() {
return this.resultPool.pop() || {};
}
/**
* Returns a RequestResult object to the pool for reuse
*/
releaseResultObject(result) {
if (this.resultPool.length < this.maxPoolSize) {
result.method = "";
result.url = "";
result.status = 0;
result.latencyMs = 0;
result.success = false;
result.body = void 0;
result.error = void 0;
result.timestamp = 0;
this.resultPool.push(result);
}
}
/**
* Gets a cached endpoint key to avoid string concatenation
*/
getEndpointKey(method, url) {
const cacheKey = `${method}|${url}`;
let endpointKey = this.endpointKeyCache.get(cacheKey);
if (!endpointKey) {
endpointKey = `${method} ${url}`;
this.endpointKeyCache.set(cacheKey, endpointKey);
}
return endpointKey;
}
/**
* Gets a reusable Set for response sampling
*/
getResponseSamplingSet(endpointKey) {
let set = this.responseSamplingSets.get(endpointKey);
if (!set) {
set = /* @__PURE__ */ new Set();
this.responseSamplingSets.set(endpointKey, set);
}
return set;
}
/**
* Checks if early exit conditions are met based on configured thresholds.
* This method is thread-safe as it only reads atomic counters and maps.
* @returns true if early exit conditions are met, false otherwise
*/
shouldEarlyExit() {
if (!this.options.earlyExitOnError) {
return false;
}
const totalRequests = this.successfulRequests + this.failedRequests;
if (totalRequests === 0) {
return false;
}
if (this.options.errorRateThreshold !== void 0) {
const errorRate = this.failedRequests / totalRequests;
if (errorRate >= this.options.errorRateThreshold) {
return true;
}
}
if (this.options.errorCountThreshold !== void 0) {
if (this.failedRequests >= this.options.errorCountThreshold) {
return true;
}
}
if (this.options.errorStatusCodes !== void 0) {
for (const code of this.options.errorStatusCodes) {
if (this.statusCodeMap[code] > 0) {
return true;
}
}
}
return false;
}
/**
* Makes a single HTTP request and returns the result
* @param req The request configuration
* @returns Promise<RequestResult> The request result
*/
async makeSingleRequest(req) {
const start = import_perf_hooks.performance.now();
const headers = this.getHeadersObject();
const result = this.getResultObject();
try {
Object.assign(headers, this.headers, req.headers);
const dispatcher = process.env.NODE_ENV !== "test" ? globalAgentManager.getAgent(req.url) : void 0;
const { statusCode, body: responseBody } = await (0, import_undici3.request)(req.url, {
method: req.method || "GET",
headers,
body: req.payload === void 0 ? void 0 : JSON.stringify(req.payload),
dispatcher
});
let body;
const method = req.method || "GET";
const endpointKey = this.getEndpointKey(method, req.url);
const sampledCodesForEndpoint = this.getResponseSamplingSet(endpointKey);
if (!sampledCodesForEndpoint.has(statusCode)) {
try {
body = await responseBody.text();
sampledCodesForEndpoint.add(statusCode);
} catch (e) {
body = `(Could not read body: ${e.message}`;
}
}
const latencyMs = Math.max(0, import_perf_hooks.performance.now() - start);
result.method = method;
result.url = req.url;
result.status = statusCode;
result.latencyMs = latencyMs;
result.success = statusCode >= 200 && statusCode < 300;
result.body = body;
result.timestamp = import_perf_hooks.performance.now();
return result;
} catch (err) {
const latencyMs = Math.max(0, import_perf_hooks.performance.now() - start);
result.method = req.method || "GET";
result.url = req.url;
result.status = 0;
result.latencyMs = latencyMs;
result.success = false;
result.error = err.message;
result.timestamp = import_perf_hooks.performance.now();
return result;
} finally {
this.releaseHeadersObject(headers);
}
}
/**
* Calculates the optimal concurrency level for this worker based on target RPS and worker count
* @returns The number of concurrent requests this worker should make
*/
calculateOptimalConcurrency() {
const workerCount = this.getWorkerCount();
const targetRps = this.currentTargetRps;
if (targetRps <= 0 || workerCount <= 0) {
return this.options.concurrentRequestsPerWorker ?? 10;
}
const targetRpsPerWorker = targetRps / workerCount;
if (this.options.concurrentRequestsPerWorker !== void 0) {
return Math.min(
this.options.concurrentRequestsPerWorker,
Math.max(1, Math.ceil(targetRpsPerWorker))
);
}
const dynamicConcurrency = Math.min(
50,
Math.max(1, Math.ceil(targetRpsPerWorker))
);
return dynamicConcurrency;
}
/**
* The core worker function. It runs in a loop, making concurrent requests and respecting
* the rate limit (RPS) until instructed to stop.
* @param isStopped A function that returns true if the worker should stop.
* Defaults to checking the main runner's stopped flag.
*/
async runWorker(isStopped = () => this.stopped) {
while (!isStopped()) {
if (this.shouldEarlyExit()) {
this.stop();
return;
}
const requestsToMake = this.calculateOptimalConcurrency();
const batchRequests = Array.from(
{ length: requestsToMake },
() => this.requests[Math.floor(Math.random() * this.requests.length)]
);
const requestPromises = batchRequests.map(
(req) => this.makeSingleRequest(req)
);
const results = await Promise.allSettled(requestPromises);
for (const result of results) {
if (result.status === "fulfilled") {
this.onResult(result.value);
} else {
const errorResult = this.getResultObject();
errorResult.method = "GET";
errorResult.url = "unknown";
errorResult.status = 0;
errorResult.latencyMs = 0;
errorResult.success = false;
errorResult.error = result.reason?.toString() || "Unknown error";
errorResult.timestamp = import_perf_hooks.performance.now();
this.onResult(errorResult);
}
}
if (this.shouldEarlyExit()) {
this.stop();
return;
}
if (this.currentTargetRps > 0) {
const workerCount = this.getWorkerCount();
if (workerCount > 0) {
const batchDelay = 1e3 * workerCount / this.currentTargetRps;
await sleep(batchDelay);
} else {
await sleep(10);
}
} else {
await sleep(0);
}
}
}
};
// src/summarizer.ts
function generateSummary(runner, options, actualDurationSec) {
const histogram = runner.getHistogram();
const endpointHistograms = runner.getEndpointHistograms();
if (histogram.totalCount === 0) {
return {
global: {
totalRequests: 0,
successfulRequests: 0,
failedRequests: 0,
avgLatencyMs: 0,
minLatencyMs: 0,
maxLatencyMs: 0,
p95LatencyMs: 0,
p99LatencyMs: 0,
actualRps: 0,
theoreticalMaxRps: 0,
achievedPercentage: 0,
duration: 0
},
endpoints: [],
tressiVersion: package_default.version || "unknown"
};
}
const { durationSec = 10, rps } = options;
const totalRequests = histogram.totalCount;
const effectiveDuration = actualDurationSec ?? durationSec;
const actualRps = effectiveDuration > 0 ? totalRequests / effectiveDuration : 0;
const avgLatency = histogram.mean;
const theoreticalMaxRps = rps ? Math.min(
1e3 / (avgLatency || 1) * (options.workers || 10),
options.rps || Infinity
) : 0;
const achievedPercentage = rps && theoreticalMaxRps ? actualRps / theoreticalMaxRps * 100 : 0;
const endpointSummaries = Array.from(endpointHistograms.entries()).map(
([endpointKey, endpointHistogram]) => {
const [method, url] = endpointKey.split(" ");
const successfulRequests = runner.getSuccessfulRequestsByEndpoint().get(endpointKey) || 0;
const failedRequests = runner.getFailedRequestsByEndpoint().get(endpointKey) || 0;
return {
method,
url,
totalRequests: successfulRequests + failedRequests,
successfulRequests,
failedRequests,
avgLatencyMs: endpointHistogram?.mean || 0,
minLatencyMs: endpointHistogram?.minNonZeroValue || 0,
maxLatencyMs: endpointHistogram?.maxValue || 0,
p95LatencyMs: endpointHistogram?.getValueAtPercentile(95) || 0,
p99LatencyMs: endpointHistogram?.getValueAtPercentile(99) || 0
};
}
);
return {
global: {
totalRequests,
successfulRequests: runner.getSuccessfulRequestsCount(),
failedRequests: runner.getFailedRequestsCount(),
avgLatencyMs: avgLatency,
minLatencyMs: histogram.minNonZeroValue,
maxLatencyMs: histogram.maxValue,
p95LatencyMs: histogram.getValueAtPercentile(95),
p99LatencyMs: histogram.getValueAtPercentile(99),
actualRps,
theoreticalMaxRps,
achievedPercentage,
duration: effectiveDuration
},
endpoints: endpointSummaries,
tressiVersion: package_default.version || "unknown"
};
}
function generateMarkdownReport(summary, options, runner, config, metadata) {
const { global: g, endpoints: e } = summary;
const distribution = runner.getDistribution();
const { workers = 10, durationSec = 10, rps, autoscale } = options;
let md = `# Tressi Load Test Report
`;
md += `| Metric | Value |
`;
md += `|---|---|
`;
md += `| Version | ${summary.tressiVersion} |
`;
if (metadata?.exportName) {
md += `| Export Name | ${metadata.exportName} |
`;
}
if (metadata?.runDate) {
md += `| Test Time | ${metadata.runDate.toLocaleString()} |
`;
}
md += `
`;
const warnings = [];
if (rps && g.achievedPercentage && g.achievedPercentage < 80) {
const maxRpsPerWorker = 1e3 / g.avgLatencyMs + 1;
const suggestedWorkers = Math.ceil(rps / maxRpsPerWorker) + 1;
warnings.push(
`**Target RPS Unreachable**: The target of ${rps} RPS was not met. The test achieved ~${g.actualRps.toFixed(
0
)} RPS (${g.achievedPercentage.toFixed(
0
)}% of the target). Based on the average latency of ${g.avgLatencyMs.toFixed(
0
)}ms, you would need at least **${suggestedWorkers}** workers to meet the target.`
);
}
for (const endpoint of e) {
const failureRate = endpoint.failedRequests / endpoint.totalRequests * 100;
if (failureRate > 10) {
warnings.push(
`**High Failure Rate**: The endpoint \`${endpoint.url}\` had a failure rate of ${failureRate.toFixed(
1
)}%. This may indicate a problem under load.`
);
}
}
if (warnings.length > 0) {
md += `## Analysis & Warnings \u26A0\uFE0F
`;
md += `> *This section highlights potential performance issues or configuration problems detected during the test.*
`;
for (const warning of warnings) {
md += `* ${warning}
`;
}
md += `
`;
}
md += `<details>
`;
md += `<summary>View Full Test Configuration</summary>
`;
md += "```json\n";
md += `${JSON.stringify(config, null, 2)}
`;
md += "```\n\n";
md += `</details>
`;
md += `## Run Configuration
`;
md += `> *This table shows the main parameters used for the load test run.*
`;
md += `| Option | Setting | Argument |
`;
md += `|---|---|---|
`;
md += `| Workers | ${autoscale ? `Up to ${workers}` : workers} | \`--workers\` |
`;
md += `| Duration | ${durationSec}s | \`--duration\` |
`;
if (rps) md += `| Target Req/s | ${rps} | \`--rps\` |
`;
if (autoscale) md += `| Autoscale | Enabled | \`--autoscale\` |
`;
md += `## Global Summary
`;
md += `> *A high-level overview of the entire test performance across all endpoints.*
`;
md += `| Stat | Value |
| --- | --- |
`;
md += `| Duration | ${g.duration.toFixed(0)}s |
`;
md += `| Total Requests | ${g.totalRequests} |
`;
md += `| Successful | ${g.successfulRequests} |
`;
md += `| Failed | ${g.failedRequests} |
`;
if (options.rps && g.theoreticalMaxRps) {
md += `| Req/s (Actual/Target) | ${g.actualRps.toFixed(0)} / ${options.rps} |
`;
md += `| Req/m (Actual/Target) | ${(g.actualRps * 60).toFixed(0)} / ${options.rps * 60} |
`;
md += `| Theoretical Max Req/s | ${g.theoreticalMaxRps.toFixed(0)} |
`;
md += `| Achieved % | ${g.achievedPercentage.toFixed(0)}% |
`;
} else {
md += `| Req/s | ${g.actualRps.toFixed(0)} |
`;
md += `| Req/m | ${(g.actualRps * 60).toFixed(0)} |
`;
}
md += `| Avg Latency | ${g.avgLatencyMs.toFixed(0)}ms |
`;
md += `| Min Latency | ${g.minLatencyMs.toFixed(0)}ms |
`;
md += `| Max Latency | ${g.maxLatencyMs.toFixed(0)}ms |
`;
md += `| p95 Latency | ${g.p95LatencyMs.toFixed(0)}ms |
`;
md += `| p99 Latency | ${g.p99LatencyMs.toFixed(0)}ms |
`;
if (distribution.getTotalCount() > 0) {
const distributionResult = distribution.getLatencyDistribution({
count: 8,
chartWidth: 20
});
md += `## Latency Distribution
`;
md += `> *This table shows how request latencies were distributed. **% of Total** is the percentage of requests that fell into that specific time range. **Cumulative %** is the running total, showing the percentage of requests at or below that latency.*
`;
md += `| Range (ms) | Count | % of Total | Cumulative % | Chart |
`;
md += `|---|---|---|---|---|
`;
for (const bucket of distributionResult) {
if (bucket.count === "0") continue;
md += `| ${bucket.latency}ms | ${bucket.count} | ${bucket.percent} | ${bucket.cumulative} | ${bucket.chart} |
`;
}
md += `
`;
}
if (g.failedRequests > 0) {
md += `## Error Summary
`;
md += `> *A total of ${g.failedRequests} requests failed. Detailed error messages are available in the raw log (if exported).*
`;
}
const statusCodeMap = runner.getStatusCodeMap();
if (Object.keys(statusCodeMap).length > 0) {
const statusCodeDistribution = getStatusCodeDistributionByCategory(statusCodeMap);
md += `## Responses by Status Code
`;
md += `> *A breakdown of all responses by their HTTP status code categories.
`;
md += `| Status Code Category | Count |
`;
md += `|---|---|
`;
for (const [category, count] of Object.entries(statusCodeDistribution)) {
md += `| ${category} | ${count} |
`;
}
md += `
`;
}
const sampledResults = runner.getSampledResults();
const sampledResponses = sampledResults.filter((r) => r.body);
if (sampledResponses.length > 0) {
md += `## Sampled Responses by Endpoint
`;
md += `> *A sample response body for each unique status code received per endpoint. This is useful for debugging unexpected responses.*
`;
const samplesByEndpoint = sampledResponses.reduce(
(acc, r) => {
const key = `${r.method} ${r.url}`;
if (!acc[key]) {
acc[key] = [];
}
acc[key].push(r);
return acc;
},
{}
);
for (const [endpoint, samples] of Object.entries(samplesByEndpoint)) {
md += `#### \`${endpoint}\`
`;
const uniqueSamples = /* @__PURE__ */ new Map();
for (const r of samples) {
if (!uniqueSamples.has(r.status)) {
uniqueSamples.set(r.status, r);
}
}
Array.from(uniqueSamples.values()).sort((a, b) => a.status - b.status).forEach((r) => {
md += `<details>
`;
md += `<summary><strong>${r.status}</strong></summary>
`;
md += "```\n";
md += `${r.body || "(No body captured)"}
`;
md += "```\n\n";
md += `</details>
`;
});
md += `
`;
}
}
if (e.length > 0) {
md += `## Endpoint Summary
`;
md += `> *A summary of request outcomes for each endpoint.*
`;
md += `| Endpoint | Success | Failed |
`;
md += `|---|---|---|
`;
for (const endpoint of e) {
md += `| ${endpoint.method} ${endpoint.url} | ${endpoint.successfulRequests} | ${endpoint.failedRequests} |
`;
}
md += `
`;
md += `## Endpoint Latency
`;
md += `> *A detailed latency breakdown for each individual API endpoint.*
`;
md += `| Endpoint | Avg | Min | Max | P95 | P99 |
`;
md += `|---|---|---|---|---|---|
`;
for (const endpoint of e) {
md += `| ${endpoint.method} ${endpoint.url} | ${endpoint.avgLatencyMs.toFixed(0)}ms | ${endpoint.minLatencyMs.toFixed(0)}ms | ${endpoint.maxLatencyMs.toFixed(0)}ms | ${endpoint.p95LatencyMs.toFixed(0)}ms | ${endpoint.p99LatencyMs.toFixed(0)}ms |
`;
}
}
return md;
}
// src/ui.ts
var import_blessed = __toESM(require("blessed"));
var import_blessed_contrib = __toESM(require("blessed-contrib"));
var TUI = class {
screen;
latencyChart;
responseCodeChart;
responseCodeLegend;
statsTable;
latencyDistributionTable;
tressiVersion;
successData;
redirectData;
clientErrorData;
serverErrorData;
avgLatencyData;
/**
* Creates a new TUI instance.
* @param onExit A callback function to be called when the user exits the UI.
*/
constructor(onExit, tressiVersion) {
this.screen = import_blessed.default.screen({ smartCSR: true });
this.tressiVersion = tressiVersion;
const grid = new import_blessed_contrib.default.grid({ rows: 12, cols: 12, screen: this.screen });
this.successData = new CircularBuffer(100);
this.redirectData = new CircularBuffer(100);
this.clientErrorData = new CircularBuffer(100);
this.serverErrorData = new CircularBuffer(100);
this.avgLatencyData = new CircularBuffer(100);
this.latencyChart = grid.set(0, 0, 6, 6, import_blessed_contrib.default.line, {
label: "Avg Latency (ms)",
showLegend: false,
maxY: 1e3,
valign: "bottom"
});
this.responseCodeChart = grid.set(0, 6, 6, 5, import_blessed_contrib.default.line, {
label: "Response Codes Over Time",
showLegend: false,
valign: "bottom",
wholeNumbersOnly: true
});
this.responseCodeLegend = grid.set(0, 11, 6, 1, import_blessed.default.box, {
content: `{green-fg}\u25A0 2xx{/}
{yellow-fg}\u25A0 3xx{/}
{red-fg}\u25A0 4xx{/}
{magenta-fg}\u25A0 5xx{/}`,
tags: true
});
this.statsTable = grid.set(6, 0, 6, 6, import_blessed_contrib.default.table, {
label: "Live Stats",
interactive: false,
columnWidth: [25, 20]
});
this.latencyDistributionTable = grid.set(6, 6, 6, 6, import_blessed_contrib.default.table, {
label: "Latency Distribution (ms)",
interactive: false,
columnSpacing: 1,
columnWidth: [15, 10, 15, 15]
});
this.screen.key(["escape", "q", "C-c"], () => {
onExit();
});
import_blessed.default.text({
parent: this.screen,
bottom: 0,
left: "center",
content: " q / esc / ctrl+c to quit ",
style: {
fg: "white"
}
});
import_blessed.default.text({
parent: this.screen,
bottom: 0,
left: 0,
content: `tressi v${this.tressiVersion}`,
style: {
fg: "white"
}
});
}
/**
* Updates the UI with new data from the load test.
* @param runner The `Runner` instance for the test.
* @param elapsedSec The elapsed time of the test in seconds.
* @param totalSec The total duration of the test in seconds.
* @param targetReqPerSec The target requests per second, if any.
*/
update(runner, elapsedSec, totalSec, targetReqPerSec) {
const histogram = runner.getHistogram();
const statusCodeMap = runner.getStatusCodeMap();
const currentReqPerSec = runner.getCurrentRps();
const successfulRequests = runner.getSuccessfulRequestsCount();
const failedRequests = runner.getFailedRequestsCount();
const averageLatency = runner.getAverageLatency();
const workerCount = runner.getWorkerCount();
const avgLatencyForInterval = histogram.mean;
this.avgLatencyData.add(avgLatencyForInterval);
const latencyDistribution = runner.getLatencyDistribution({
count: 10
});
this.latencyDistributionTable.setData({
headers: ["Range", "Count", "% of Total", "Cumulative"],
data: latencyDistribution.map((b) => [
b.latency,
b.count,
b.percent,
b.cumulative
])
});
const currentDistribution = getStatusCodeDistributionByCategory(statusCodeMap);
this.successData.add(currentDistribution["2xx"]);
this.redirectData.add(currentDistribution["3xx"]);
this.clientErrorData.add(currentDistribution["4xx"]);
this.serverErrorData.add(currentDistribution["5xx"]);
const successArray = this.successData.getAll();
const redirectArray = this.redirectData.getAll();
const clientErrorArray = this.clientErrorData.getAll();
const serverErrorArray = this.serverErrorData.getAll();
const avgLatencyArray = this.avgLatencyData.getAll();
const dataPointsCount = successArray.length;
const x_labels = Array.from({ length: dataPointsCount }, (_, i) => {
const timeAgoSec = (dataPointsCount - 1 - i) * 0.5;
const timeSec = elapsedSec - timeAgoSec;
return timeSec < 0 ? `0s` : `${Math.round(timeSec)}s`;
});
this.latencyChart.setData([
{
title: "Latency",
x: x_labels,
y: avgLatencyArray.map((x) => Math.round(x))
}
]);
const series = [];
if (successArray.some((v) => v > 0)) {
series.push({
title: "2xx",
x: x_labels,
y: successArray,
style: { line: "green" }
});
}
if (redirectArray.some((v) => v > 0)) {
series.push({
title: "3xx",
x: x_labels,
y: redirectArray,
style: { line: "yellow" }
});
}
if (clientErrorArray.some((v) => v > 0)) {
series.push({
title: "4xx",
x: x_labels,
y: clientErrorArray,
style: { line: "red" }
});
}
if (serverErrorArray.some((v) => v > 0)) {
series.push({
title: "5xx",
x: x_labels,
y: serverErrorArray,
style: { line: "magenta" }
});
}
if (series.length === 0) {
this.responseCodeChart.setData([
{
title: "",
x: [],
y: []
}
]);
} else {
this.responseCodeChart.setData(series);
}
const rpsStat = targetReqPerSec ? `${currentReqPerSec} / ${targetReqPerSec}` : currentReqPerSec.toString();
const data = [
["Time", `${elapsedSec.toFixed(0)}s / ${totalSec}s`],
["Workers", workerCount],
["Req/s (Actual/Target)", rpsStat],
["Success / Fail", `${successfulRequests} / ${failedRequests}`],
["Avg Latency (ms)", Math.round(averageLatency)]
];
this.statsTable.setData({
headers: ["Stat", "Value"],
data: data.map((row) => row.map((cell) => cell.toString()))
});
this.screen.render();
}
/**
* Destroys the TUI screen, cleaning up resources.
*/
destroy() {
this.screen.destroy();
}
};
// src/utils.ts
function getSafeDirectoryName(input) {
if (!input || typeof input !== "string") {
return "_unnamed";
}
const windowsReserved = [
"CON",
"PRN",
"AUX",
"NUL",
"COM1",
"COM2",
"COM3",
"COM4",
"COM5",
"COM6",
"COM7",
"COM8",
"COM9",
"LPT1",
"LPT2",
"LPT3",
"LPT4",
"LPT5",
"LPT6",
"LPT7",
"LPT8",
"LPT9"
];
let safeName = input.replace(/[<>:"|?*\x00-\x1f]/g, "-").replace(/:/g, "-").trim().replace(/^\.+|\.+$/g, "").replace(/[\s-]+/g, "_").replace(/_+/g, "_").replace(/[^a-zA-Z0-9._\-\/\\]/g, "_");
if (!safeName) {
return "_unnamed";
}
const upperName = safeName.toUpperCase();
if (windowsReserved.includes(upperName)) {
safeName = "_" + safeName;
}
safeName = safeName.replace(/[. ]+$/, "");
if (safeName.length > 200) {
safeName = safeName.substring(0, 200);
}
if (!safeName || safeName === "_" || safeName === "-") {
return "_unnamed";
}
return safeName;
}
// src/index.ts
function printReportInfo(summary, options) {
const reportInfoTable = new import_cli_table3.default({
head: ["Metric", "Value"],
colWidths: [20, 35]
});
reportInfoTable.push(["Version", summary.tressiVersion]);
if (options.exportPath) {
const baseExportName = typeof options.exportPath === "string" ? options.exportPath : "tressi-report";
reportInfoTable.push(["Export Name", baseExportName]);
reportInfoTable.push(["Test Time", (/* @__PURE__ */ new Date()).toLocaleString()]);
}
console.log("\n" + import_chalk2.default.bold("Report Information"));
console.log(reportInfoTable.toString());
}
function printRunConfiguration(options) {
const {
workers = 10,
durationSec = 10,
rps,
rampUpTimeSec,
autoscale
} = options;
const configTable = new import_cli_table3.default({
head: ["Option", "Setting", "Argument"],
colWidths: [20, 15, 20]
});
configTable.push([
"Workers",
autoscale ? `Up to ${workers}` : workers,
"--workers"
]);
configTable.push(["Duration", `${durationSec}s`, "--duration"]);
if (autoscale) {
configTable.push(["Autoscale", "Enabled", "--autoscale"]);
}
if (rps) {
configTable.push(["Target Req/s", rps, "--rps"]);
}
if (rampUpTimeSec) {
configTable.push(["Ramp-up Time", `${rampUpTimeSec}s`, "--ramp-up-time"]);
}
console.log("\n" + import_chalk2.default.bold("Run Configuration"));
console.log(configTable.toString());
}
function printGlobalSummary(summary, options) {
const { global: globalSummary } = summary;
const { rps } = options;
const summaryTable = new import_cli_table3.default({
head: ["Stat", "Value"],
colWidths: [30, 20]
});
summaryTable.push(
["Duration", `${Math.ceil(globalSummary.duration)}s`],
["Total Requests", globalSummary.totalRequests],
[import_chalk2.default.green("Successful"), globalSummary.successfulRequests],
[import_chalk2.default.red("Failed"), globalSummary.failedRequests]
);
if (rps && globalSummary.theoreticalMaxRps) {
summaryTable.push(
[
"Req/s (Actual/Target)",
`${Math.ceil(globalSummary.actualRps)} / ${rps}`
],
[
"Req/m (Actual/Target)",
`${Math.ceil(globalSummary.actualRps * 60)} / ${rps * 60}`
],
["Theoretical Max Req/s", globalSummary.theoreticalMaxRps.toFixed(0)],
["Achieved %", `${globalSummary.achievedPercentage.toFixed(0)}%`]
);
} else {
summaryTable.push(
["Req/s", Math.ceil(globalSummary.actualRps)],
["Req/m", Math.ceil(globalSummary.actualRps * 60)]
);
}
summaryTable.push(
["Avg Latency", `${Math.ceil(globalSummary.avgLatencyMs)}ms`],
["Min Latency", `${Math.ceil(globalSummary.minLatencyMs)}ms`],
["Max Latency", `${Math.ceil(globalSummary.maxLatencyMs)}ms`],
["p95 Latency", `${Math.ceil(globalSummary.p95LatencyMs)}ms`],
["p99 Latency", `${Math.ceil(globalSummary.p99LatencyMs)}ms`]
);
console.log("\n" + import_chalk2.default.bold("Global Test Summary"));
console.log(summaryTable.toString());
}
function printEndpointSummary(summary) {
const { endpoints } = summary;
if (endpoints.length === 0) return;
const endpointSummaryTable = new import_cli_table3.default({
head: ["Endpoint", "Success", "Failed"],
colWidths: [50, 10, 10]
});
const endpointLatencyTable = new import_cli_table3.default({
head: ["Endpoint", "Avg", "Min", "Max", "P95", "P99"],
colWidths: [50, 10, 10, 10, 10, 10]
});
for (const endpoint of endpoints) {
const url = endpoint.url;
const maxUrlLength = 48;
const displayUrl = url.length > maxUrlLength ? `...${url.slice(url.length - (maxUrlLength - 3))}` : url;
endpointSummaryTable.push([
displayUrl,
import_chalk2.default.green(endpoint.successfulRequests),
import_chalk2.default.red(endpoint.failedRequests)
]);
endpointLatencyTable.push([
displayUrl,
`${Math.round(endpoint.avgLatencyMs)}ms`,
`${Math.round(endpoint.minLatencyMs)}ms`,
`${Math.round(endpoint.maxLatencyMs)}ms`,
`${Math.round(endpoint.p95LatencyMs)}ms`,
`${Math.round(endpoint.p99LatencyMs)}ms`
]);
}
console.log("\n" + import_chalk2.default.bold("Endpoint Summary"));
console.log(endpointSummaryTable.toString());
console.log("\n" + import_chalk2.default.bold("Endpoint Latency"));
console.log(endpointLatencyTable.toString());
}
function printLatencyDistribution(runner) {
const histogram = runner.getHistogram();
if (histogram.totalCount === 0) return;
const distribution = runner.getLatencyDistribution({
count: 8,
chartWidth: 20
});
const distributionTable = new import_cli_table3.default({
head: ["Range (ms)", "Count", "% of Total", "Cumulative %", "Chart"],
colWidths: [15, 10, 15, 15, 25]
});
for (const bucket of distribution) {
if (bucket.count === "0") continue;
distributionTable.push([
bucket.latency,
bucket.count,
bucket.percent,
bucket.cumulative,
import_chalk2.default.green(bucket.chart)
]);
}
console.log("\n" + import_chalk2.default.bold("Latency Distribution"));
console.log(distributionTable.toString());
}
function printStatusCodeDistribution(runner) {
const statusCodeMap = runner.getStatusCodeMap();
if (Object.keys(statusCodeMap).length === 0) return;
const distribution = getStatusCodeDistributionByCategory(statusCodeMap);
const distributionTable = new import_cli_table3.default({
head: ["Status Code", "Count"],
colWidths: [15, 10]
});
for (const [code, count] of Object.entries(distribution)) {
distributionTable.push([code, count]);
}
console.log("\n" + import_chalk2.default.bold("Status Code Distribution"));
console.log(distributionTable.toString());
}
function printSummary(runner, options, summary) {
printReportInfo(summary, options);
printRunConfiguration(options);
printGlobalSummary(summary, options);
printEndpointSummary(summary);
printStatusCodeDistribution(runner);
printLatencyDistribution(runner);
}
async function runLoadTest(options) {
const { silent = false, useUI = true } = options;
const spinner = (0, import_ora2.default)({
text: "Loading config...",
isEnabled: !silent
}).start();
let loadedConfig;
try {
loadedConfig = await loadConfig(options.config);
spinner.succeed(`Loaded ${loadedConfig.requests.length} request targets`);
} catch (err) {
if (err instanceof import_zod2.z.ZodError) {
spinner.fail("Config validation failed:");
if (!silent) {
console.error(JSON.stringify(err.errors, null, 2));
}
} else {
spinner.fail(`Failed to load config: ${err.message}`);
}
throw err;
}
const runner = new Runner(
options,
loadedConfig.requests,
loadedConfig.headers || {}
);
if (useUI && !silent) {
const tui = new TUI(() => runner.stop(), package_default.version || "unknown");
const tuiInterval = setInterval(() => {
const startTime2 = runner.getStartTime();
const elapsedSec = startTime2 > 0 ? (import_perf_hooks2.performance.now() - startTime2) / 1e3 : 0;
const totalSec = options.durationSec || 10;
tui.update(runner, elapsedSec, totalSec, options.rps);
}, 500);
runner.on("stop", () => {
clearInterval(tuiInterval);
tui?.destroy();
});
} else {
const noUiSpinner = (0, import_ora2.default)({
text: "Test starting...",
isEnabled: !silent
}).start();
const noUiInterval = setInterval(() => {
const startTime2 = runner.getStartTime();
const elapsedSec = startTime2 > 0 ? (import_perf_hooks2.performance.now() - startTime2) / 1e3 : 0;
const totalSec = options.durationSec || 10;
const rps = runner.getCurrentRps();
const successful = runner.getSuccessfulRequestsCount();
const failed = runner.getFailedRequestsCount();
const workers = runner.getWorkerCount();
const histogram = runner.getHistogram();
const avgLatency = histogram.mean;
const p95 = histogram.getValueAtPercentile(95);
const p99 = histogram.getValueAtPercentile(99);
const rpsDisplay = options.rps ? `${rps}/${options.rps}` : `${rps}`;
const successDisplay = import_chalk2.default.green(successful);
const failDisplay = failed > 0 ? import_chalk2.default.red(failed) : import_chalk2.default.gray(0);
noUiSpinner.text = `[${elapsedSec.toFixed(0)}s/${totalSec}s] RPS: ${rpsDisplay} | W: ${workers} | OK/Fail: ${successDisplay}/${failDisplay} | Avg: ${avgLatency.toFixed(
0
)}ms | p95: ${p95.toFixed(0)}ms | p99: ${p99.toFixed(0)}ms`;
}, 1e3);
const handleNoUiExit = () => {
runner.stop();
};
process.on("SIGINT", handleNoUiExit);
runner.on("stop", () => {
clearInterval(noUiInterval);
process.removeListener("SIGINT", handleNoUiExit);
noUiSpinner.succeed("Test finished. Generating summary...");
});
}
await runner.run();
const startTime = runner.getStartTime();
const actualDurationSec = startTime > 0 ? (import_perf_hooks2.performance.now() - startTime) / 1e3 : 0;
const summary = generateSummary(runner, options, actualDurationSec);
if (options.exportPath) {
const exportSpinner = (0, import_ora2.default)({
text: "Exporting results...",
isEnabled: !silent
}).start();
try {
const baseExportName = typeof options.exportPath === "string" ? options.exportPath : "tressi-report";
const runDate = /* @__PURE__ */ new Date();
const reportDir = import_path2.default.resolve(
process.cwd(),
getSafeDirectoryName(`${baseExportName}-${runDate.toISOString()}`)
);
await import_fs2.promises.mkdir(reportDir, { recursive: true });
const markdownReport = generateMarkdownReport(
summary,
options,
runner,
loadedConfig,
{
exportName: baseExportName,
runDate
}
);
await import_fs2.promises.writeFile(import_path2.default.join(reportDir, "report.md"), markdownReport);
await exportDataFiles(
summary,
runner.getSampledResults(),
reportDir,
runner
);
exportSpinner.succeed(`Successfully exported results to ${reportDir}`);
} catch (err) {
exportSpinner.fail(
import_chalk2.default.red(`Failed to export results: ${err.message}`)
);
}
}
if (!silent) {
printSummary(runner, options, summary);
}
return summary;
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
runLoadTest
});
//# sourceMappingURL=index.js.map