UNPKG

tressi

Version:

A lightweight, declarative stress testing CLI for modern developers.

2,047 lines 67.1 kB
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
  for (var name in all)
    __defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
  if (from && typeof from === "object" || typeof from === "function") {
    for (let key of __getOwnPropNames(from))
      if (!__hasOwnProp.call(to, key) && key !== except)
        __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
  }
  return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
  // If the importer is in node compatibility mode or this is not an ESM
  // file that has been converted to a CommonJS file using a Babel-
  // compatible transform (i.e. "__esModule" has not been set), then set
  // "default" to the CommonJS "module.exports" for node compatibility.
  isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
  mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);

// src/index.ts
var index_exports = {};
__export(index_exports, {
  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