load_test_steps.js

// e2e/step_definitions/load_test_steps.js

import { Given, When, Then } from "@cucumber/cucumber";
import fs from "fs";
import path from "path";
import crypto from "crypto";
import * as dotenv from "dotenv";

import resolveBody from "../lib/helpers/resolveBody.js";
import buildK6Script from "../lib/helpers/buildK6Script.js";
import generateHeaders from "../lib/helpers/generateHeaders.js";
import { runK6Script } from "../lib/utils/k6Runner.js";

dotenv.config();

/**
 * @typedef {Object} CustomWorld
 * @property {Object} config
 * @property {Object} aliases
 * @property {Object} lastResponse
 * @property {Object} parameters
 * @property {Function} log
 */

/**
 * @typedef {Object} K6Config
 * @property {string} method - HTTP method for the request (e.g., "GET", "POST").
 * @property {string} [endpoint] - The specific endpoint for a single request.
 * @property {string[]} [endpoints] - An array of endpoints for multiple requests.
 * @property {Object} [headers] - Request headers.
 * @property {string} [body] - Request body content.
 * @property {Object} options - k6 test options (vus, duration, stages, thresholds).
 * @property {Object} options.thresholds - k6 metric thresholds.
 * @property {string[]} options.thresholds.http_req_failed - Thresholds for failed HTTP requests.
 * @property {string[]} options.thresholds.http_req_duration - Thresholds for request duration.
 * @property {string[]} [options.thresholds.error_rate] - Optional threshold for error rate.
 * @property {number} [options.vus] - Number of virtual users (for open model).
 * @property {string} [options.duration] - Test duration (for open model, e.g., "30s").
 * @property {Array<Object>} [options.stages] - Array of stages for a stepped load model.
 */

// ===================================================================================
// K6 SCRIPT CONFIGURATION STEPS
// ===================================================================================

/**
 * Initializes the k6 script configuration by setting the primary HTTP method for the load test.
 *
 * ```gherkin
 * Given I set a k6 script for {word} testing
 * ```
 *
 * @param {string} method - The HTTP method (e.g., "GET", "POST", "PUT", "DELETE").
 * @example
 * Given I set a k6 script for GET testing
 * Given I set a k6 script for POST testing
 * @remarks
 * This step typically starts the definition of a k6 load test scenario.
 * It sets `this.config.method` in the Cucumber World context.
 * Subsequent steps will build upon this configuration.
 * @category k6 Configuration Steps
 */
export async function Given_I_set_k6_script_for_method_testing(method) {
  /** @type {CustomWorld} */ (this).config = { method: method.toUpperCase() };
  /** @type {CustomWorld} */ (this).log?.(
    `⚙️ Initialized k6 script for ${method.toUpperCase()} testing.`
  );
}
Given(
  /^I set a k6 script for (\w+) testing$/,
  Given_I_set_k6_script_for_method_testing
);

/**
 * Configures the k6 script options (VUs, duration, stages, thresholds) from a data table.
 *
 * ```gherkin
 * When I set to run the k6 script with the following configurations:
 * | virtual_users | duration | stages               | http_req_failed | http_req_duration | error_rate      |
 * | 10            | 30       |                      | p(99)<0.01      | p(99)<500         | rate<0.01       |
 * |               |          | [{"duration":"10s","target":10}] | p(90)<0.01      | p(90)<200         | rate<0.001      |
 * ```
 *
 * @param {DataTable} dataTable - A Cucumber data table containing k6 configuration parameters.
 * Expected columns: `virtual_users`, `duration`, `stages` (JSON string), `http_req_failed`, `http_req_duration`, `error_rate`.
 * @example
 * When I set to run the k6 script with the following configurations:
 * | virtual_users | duration | http_req_failed | http_req_duration |
 * | 50            | 60       | p(99)<0.01      | p(99)<1000        |
 * When I set to run the k6 script with the following configurations:
 * | stages                                  | http_req_failed | http_req_duration | error_rate |
 * | [{"duration":"10s","target":10}, {"duration":"20s","target":50}] | p(99)<0.01      | p(99)<500         | rate<0.01  |
 * @remarks
 * This step populates `this.config.options`. It intelligently handles either a simple
 * `virtual_users`/`duration` model or a complex `stages` array. Threshold formats are validated.
 * Example values from scenario outlines are resolved if present.
 * @category k6 Configuration Steps
 */
export async function When_I_set_k6_script_configurations(dataTable) {
  /** @type {CustomWorld} */ (this);
  const rawRow = dataTable.hashes()[0];
  const row = {};

  const exampleMap = {};
  if (this.pickle && this.pickle.astNodeIds && this.gherkinDocument) {
    const scenarioNodeId = this.pickle.astNodeIds.find((id) =>
      id.startsWith("Scenario")
    );
    if (scenarioNodeId) {
      const scenario = this.gherkinDocument.feature.children.find(
        (child) => child.scenario && child.scenario.id === scenarioNodeId
      )?.scenario;

      if (scenario && scenario.examples && scenario.examples.length > 0) {
        const exampleTable = scenario.examples[0].tableBody?.[0];
        const headerCells = scenario.examples[0].tableHeader?.cells || [];
        const dataCells = exampleTable?.cells || [];

        headerCells.forEach((cell, idx) => {
          exampleMap[cell.value] = dataCells[idx]?.value;
        });
      }
    }
  }

  for (const [key, value] of Object.entries(rawRow)) {
    row[key] = value.replace(/<([^>]+)>/g, (_, param) => {
      if (exampleMap.hasOwnProperty(param)) {
        return exampleMap[param];
      }
      return `<${param}>`;
    });
  }

  const validateThreshold = (value, thresholdName) => {
    const regex = /^[\w{}()<>:]+([<>=]=?)\d+(\.\d+)?$/;
    if (value && !regex.test(value)) {
      throw new Error(
        `Invalid k6 threshold format for '${thresholdName}': "${value}". Expected format like 'p(99)<500' or 'rate<0.01'.`
      );
    }
  };

  validateThreshold(row.http_req_failed, "http_req_failed");
  validateThreshold(row.http_req_duration, "http_req_duration");
  if (row.error_rate) {
    validateThreshold(row.error_rate, "error_rate");
  }

  let k6Options;

  if (row.stages) {
    try {
      k6Options = {
        stages: JSON.parse(row.stages),
        thresholds: {
          http_req_failed: [row.http_req_failed],
          http_req_duration: [row.http_req_duration],
        },
      };
    } catch (e) {
      throw new Error(`Invalid 'stages' JSON format: ${e.message}`);
    }
  } else if (row.virtual_users && row.duration) {
    k6Options = {
      vus: parseInt(row.virtual_users),
      duration: `${row.duration}s`,
      thresholds: {
        http_req_failed: [row.http_req_failed],
        http_req_duration: [row.http_req_duration],
      },
    };
  } else {
    throw new Error(
      "k6 configuration requires either 'stages' or 'virtual_users' and 'duration' to be set."
    );
  }

  if (row.error_rate) {
    k6Options.thresholds.error_rate = [row.error_rate];
  }

  this.config.options = k6Options;
  this.log?.(
    `⚙️ k6 script configured with options: ${JSON.stringify(k6Options)}`
  );
}
When(
  /^I set to run the k6 script with the following configurations:$/,
  When_I_set_k6_script_configurations
);

/**
 * Sets request headers for the k6 script. Headers are merged with any existing headers.
 *
 * ```gherkin
 * When I set the request headers:
 * | Header         | Value                |
 * | Content-Type   | application/json     |
 * | Authorization  | Bearer <my_token>    |
 * ```
 *
 * @param {DataTable} dataTable - A Cucumber data table with 'Header' and 'Value' columns.
 *
 * @example
 * When I set the request headers:
 * | Header         | Value               |
 * | Content-Type   | application/json    |
 * | X-Custom-Header| MyValue             |
 *
 * @remarks
 * This step updates `this.config.headers`. Values can include placeholders
 * (e.g., `<my_token>`) if your `resolveBody` or `generateHeaders` helpers handle them.
 * Note: `generateHeaders` is specifically used for authentication types. If your headers
 * contain dynamic values beyond simple alias resolution, ensure your helpers support it.
 * @category k6 Configuration Steps
 */
export async function When_I_set_request_headers(dataTable) {
  /** @type {CustomWorld} */ (this);
  const headers = {};
  dataTable.hashes().forEach(({ Header, Value }) => {
    headers[Header] = Value;
  });

  this.config.headers = {
    ...(this.config.headers || {}),
    ...headers,
  };
  this.log?.(`⚙️ Request headers set: ${JSON.stringify(this.config.headers)}`);
}
When(/^I set the request headers:$/, When_I_set_request_headers);

/**
 * Sets the list of endpoints to be used in the k6 script. These are typically used when
 * the k6 script iterates over multiple URLs.
 *
 * ```gherkin
 * When I set the following endpoints used:
 * /api/v1/users
 * /api/v1/products
 * /api/v1/orders
 * ```
 *
 * @param {string} docString - A DocString containing a newline-separated list of endpoints.
 *
 * @example
 * When I set the following endpoints used:
 * /health
 * /status
 * /metrics
 *
 * @remarks
 * This step populates `this.config.endpoints` as an array of strings.
 * Ensure these endpoints are relative to your k6 `BASE_URL`.
 * @category k6 Configuration Steps
 */
export async function When_I_set_endpoints_used(docString) {
  /** @type {CustomWorld} */ (this);
  this.config.endpoints = docString
    .trim()
    .split("\n")
    .map((line) => line.trim());
  if (this.log)
    this.log(`⚙️ Endpoints set: ${JSON.stringify(this.config.endpoints)}`);
}
When(/^I set the following endpoints used:$/, When_I_set_endpoints_used);

/**
 * Sets the request body for a specific HTTP method and endpoint.
 *
 * ```gherkin
 * When I set the following POST body is used for "/api/v1/create"
 * { "name": "test", "email": "test@example.com" }
 * ```
 *
 * @param {string} method - The HTTP method (e.g., "POST", "PUT").
 * @param {string} endpoint - The specific endpoint URL for this body.
 * @param {string} docString - A DocString containing the request body content (e.g., JSON).
 *
 * @example
 * When I set the following PUT body is used for "/api/v1/update/1"
 * { "status": "active" }
 *
 * @remarks
 * This step sets `this.config.method`, `this.config.endpoint`, and `this.config.body`.
 * The `resolveBody` helper is used to process the DocString, allowing for dynamic values
 * from environment variables.
 * @category k6 Configuration Steps
 */
export async function When_I_set_method_body_for_endpoint(
  method,
  endpoint,
  docString
) {
  /** @type {CustomWorld} */ (this);
  this.config.method = method.toUpperCase();
  this.config.endpoint = endpoint;
  this.config.body = resolveBody(docString, process.env);
  this.log?.(
    `⚙️ Body set for ${this.config.method} to "${
      this.config.endpoint
    }". Body preview: ${this.config.body.slice(0, 100)}...`
  );
}
When(
  /^I set the following (\w+) body is used for "([^"]+)"$/,
  When_I_set_method_body_for_endpoint
);

/**
 * Loads a JSON payload from a file to be used as the request body for a specific
 * method and endpoint in the k6 script.
 *
 * ```gherkin
 * When I use JSON payload from "user_create.json" for POST to "/api/v1/users"
 * ```
 *
 * @param {string} fileName - The name of the JSON payload file (e.g., "user_data.json").
 * @param {string} method - The HTTP method (only "POST", "PUT", "PATCH" are supported for bodies).
 * @param {string} endpoint - The specific endpoint URL.
 *
 * @example
 * When I use JSON payload from "login_payload.json" for POST to "/auth/login"
 *
 * @remarks
 * This step reads the JSON file, resolves any placeholders within it (using `resolveBody`),
 * and sets `this.config.method`, `this.config.endpoint`, and `this.config.body`.
 * It also stores `lastRequest` in `this.lastRequest`.
 * The payload file path is resolved relative to `payloads` directory or `this.parameters.payloadPath`.
 * @category k6 Configuration Steps
 */
export async function When_I_use_JSON_payload_from_file_for_method_to_endpoint(
  fileName,
  method,
  endpoint
) {
  /** @type {CustomWorld} */ (this);
  const allowedMethods = ["POST", "PUT", "PATCH"];
  const methodUpper = method.toUpperCase();

  if (!allowedMethods.includes(methodUpper)) {
    throw new Error(
      `Method "${method}" is not supported for JSON payloads from files. Use one of: ${allowedMethods.join(
        ", "
      )}`
    );
  }

  const projectRoot = path.resolve(__dirname, "..", "..");
  const payloadDir = this.parameters?.payloadPath || "payloads";
  const payloadPath = path.isAbsolute(payloadDir)
    ? path.join(payloadDir, fileName)
    : path.join(projectRoot, payloadDir, fileName);

  if (!fs.existsSync(payloadPath)) {
    throw new Error(`Payload file not found: "${payloadPath}"`);
  }

  const rawTemplate = fs.readFileSync(payloadPath, "utf-8");
  const resolved = resolveBody(rawTemplate, {
    ...process.env,
    ...(this.aliases || {}),
  });

  this.config = {
    ...(this.config || {}),
    method: methodUpper,
    endpoint,
    body: resolved,
    headers: this.config?.headers || {},
  };

  this.lastRequest = {
    method: methodUpper,
    endpoint,
    body: resolved,
  };
  this.log?.(
    `⚙️ JSON payload from "${fileName}" used for ${methodUpper} to "${endpoint}".`
  );
}
When(
  /^I use JSON payload from "([^"]+)" for (\w+) to "([^"]+)"$/,
  When_I_use_JSON_payload_from_file_for_method_to_endpoint
);

/**
 * Sets the authentication type for the k6 request, generating relevant headers.
 *
 * ```gherkin
 * When I set the authentication type to "BearerToken"
 * ```
 *
 * @param {string} authType - The type of authentication (e.g., "BearerToken", "BasicAuth", "APIKey").
 *
 * @example
 * When I set the authentication type to "BearerToken"
 *
 * @remarks
 * This step uses the `generateHeaders` helper to create or modify `this.config.headers`
 * based on the specified `authType` and environment variables/aliases.
 * Ensure your `generateHeaders` helper is configured to handle the `authType` and retrieve
 * necessary credentials (e.g., from `process.env` or `this.aliases`).
 * @category k6 Configuration Steps
 */
export async function When_I_set_authentication_type(authType) {
  /** @type {CustomWorld} */ (this);
  this.config.headers = generateHeaders(
    authType,
    process.env,
    this.aliases || {}
  );
  this.log?.(`⚙️ Authentication type set to "${authType}". Headers updated.`);
}
When(
  /^I set the authentication type to "([^"]+)"$/,
  When_I_set_authentication_type
);

/**
 * Stores a value from the last API response into the Cucumber World's aliases context.
 *
 * ```gherkin
 * Then I store the value at "data.token" as alias "authToken"
 * ```
 *
 * @param {string} jsonPath - A dot-separated JSON path to the value in the last response (e.g., "data.user.id").
 * @param {string} alias - The name of the alias to store the value under (e.g., "userId").
 *
 * @example
 * Then I store the value at "token" as alias "accessToken"
 * Then I store the value at "user.profile.email" as alias "userEmail"
 *
 * @remarks
 * This step expects `this.lastResponse` to contain a parsed JSON object.
 * It traverses the `jsonPath` to extract the desired value and saves it into
 * `this.aliases`. This alias can then be used in subsequent steps or payload resolutions.
 * @category Data Management Steps
 */
export async function Then_I_store_value_as_alias(jsonPath, alias) {
  /** @type {CustomWorld} */ (this);
  if (!this.lastResponse) {
    throw new Error(
      "No previous API response available to extract value from. Ensure a login or request step was executed."
    );
  }

  const pathParts = jsonPath.split(".");
  let value = this.lastResponse;

  for (const key of pathParts) {
    if (value && typeof value === "object" && key in value) {
      value = value[key];
    } else {
      value = undefined;
      break;
    }
  }

  if (value === undefined) {
    throw new Error(
      `Could not resolve path "${jsonPath}" in the last response. Value is undefined.`
    );
  }

  if (!this.aliases) this.aliases = {};
  this.aliases[alias] = value;

  this.log?.(
    `🧩 Stored alias "${alias}" from response path "${jsonPath}". Value: ${JSON.stringify(
      value
    ).slice(0, 100)}...`
  );
}
Then(
  /^I store the value at "([^"]+)" as alias "([^"]+)"$/,
  Then_I_store_value_as_alias
);

/**
 * Logs in via a POST request to a specified endpoint using a JSON payload from a file.
 * The response data is stored for subsequent steps.
 *
 * ```gherkin
 * When I login via POST to "/auth/login" with payload from "admin_credentials.json"
 * ```
 *
 * @param {string} endpoint - The API endpoint for the login request (relative to `BASE_URL`).
 * @param {string} fileName - The name of the JSON file containing login credentials.
 *
 * @example
 * When I login via POST to "/api/login" with payload from "user_creds.json"
 *
 * @remarks
 * This step constructs and executes a `fetch` POST request. It reads the payload from
 * the specified file (resolved from `payloads` directory), resolves placeholders in the payload,
 * sends the request, and stores the JSON response in `this.lastResponse`.
 * It throws an error if the login request fails (non-2xx status).
 * @category Authentication Steps
 */
export async function When_I_login_via_POST_with_payload_from_file(
  endpoint,
  fileName
) {
  /** @type {CustomWorld} */ (this);
  const payloadDir = this.parameters?.payloadPath || "payloads";
  const projectRoot = path.resolve(__dirname, "..", "..");
  const payloadPath = path.isAbsolute(payloadDir)
    ? path.join(payloadDir, fileName)
    : path.join(projectRoot, payloadDir, fileName);

  if (!fs.existsSync(payloadPath)) {
    throw new Error(`Payload file not found: "${payloadPath}"`);
  }

  const rawTemplate = fs.readFileSync(payloadPath, "utf-8");
  const resolved = resolveBody(rawTemplate, {
    ...process.env,
    ...(this.aliases || {}),
  });

  try {
    const baseUrl = process.env.BASE_URL;
    if (!baseUrl) {
      throw new Error("Missing BASE_URL environment variable.");
    }
    const fullUrl = `${baseUrl.replace(/\/+$/, "")}${endpoint}`;

    this.log?.(
      `🔐 Attempting login via POST to "${fullUrl}" with payload from "${fileName}".`
    );

    const response = await fetch(fullUrl, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify(resolved),
    });

    const data = await response.json();

    if (!response.ok) {
      this.log?.(
        `❌ Login request failed for "${fullUrl}". Status: ${
          response.status
        }. Response body: ${JSON.stringify(data).slice(0, 100)}...`
      );
      throw new Error(
        `Login request failed with status ${response.status} for endpoint "${endpoint}".`
      );
    }

    this.lastResponse = data;
    this.log?.(
      "🔐 Login successful, response data saved to 'this.lastResponse'."
    );
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    this.log?.(`❌ Login request failed: ${message}`);
    throw new Error(
      `Login request failed for endpoint "${endpoint}": ${message}`
    );
  }
}
When(
  /^I login via POST to "([^"]+)" with payload from "([^"]+)"$/,
  When_I_login_via_POST_with_payload_from_file
);

const genScriptDir = path.resolve(process.cwd(), "genScript");
if (!fs.existsSync(genScriptDir)) {
  fs.mkdirSync(genScriptDir, { recursive: true });
}

const reportDir =
  process.env.REPORT_OUTPUT_DIR ||
  process.env.K6_REPORT_DIR ||
  process.env.npm_config_report_output_dir ||
  "reports";

if (!fs.existsSync(reportDir)) {
  fs.mkdirSync(reportDir, { recursive: true });
}

Then(
  /^I see the API should handle the (\w+) request successfully$/,
  { timeout: 300000 },
  async function (method) {
    if (!this.config || !this.config.method) {
      throw new Error("Configuration is missing or incomplete.");
    }
    const expectedMethod = method.toUpperCase();
    const actualMethod = this.config.method.toUpperCase();
    if (actualMethod !== expectedMethod) {
      throw new Error(
        `Mismatched HTTP method: expected "${expectedMethod}", got "${actualMethod}"`
      );
    }
    try {
      const scriptContent = buildK6Script(this.config);
      const uniqueId = crypto.randomBytes(8).toString("hex");
      const scriptFileName = `k6-script-${uniqueId}.js`;
      const scriptPath = path.join(reportDir, scriptFileName);
      fs.writeFileSync(scriptPath, scriptContent, "utf-8");
      this.log?.(`✅ k6 script generated at: "${scriptPath}"`);

      this.log?.(`🚀 Running k6 script: "${scriptFileName}"...`);
      const { stdout, stderr, code } = await runK6Script(
        scriptPath,
        process.env.K6_CUCUMBER_OVERWRITE === "true"
      );
      if (stdout) this.log?.(`k6 STDOUT:\n${stdout}`);
      if (stderr) this.log?.(`k6 STDERR:\n${stderr}`);

      if (code !== 0) {
        throw new Error(
          `k6 process exited with code ${code}. Check k6 output for details.`
        );
      }
      this.log?.(
        `✅ k6 script executed successfully for ${expectedMethod} request.`
      );

      const saveK6Script =
        process.env.saveK6Script === "true" ||
        process.env.SAVE_K6_SCRIPT === "true" ||
        this.parameters?.saveK6Script === true;

      if (!saveK6Script) {
        try {
          fs.unlinkSync(scriptPath);
          this.log?.(`🧹 Temporary k6 script deleted: "${scriptPath}"`);
        } catch (cleanupErr) {
          this.log?.(
            `⚠️ Warning: Could not delete temporary k6 script file: "${scriptPath}". Error: ${
              cleanupErr instanceof Error
                ? cleanupErr.message
                : String(cleanupErr)
            }`
          );
        }
      } else {
        this.log?.(
          `ℹ️  k6 script kept at: "${scriptPath}". Set SAVE_K6_SCRIPT=false to delete automatically.`
        );
      }
    } catch (error) {
      this.log?.(
        `❌ Failed to generate or run k6 script: ${
          error instanceof Error ? error.message : String(error)
        }`
      );
      throw new Error(
        `k6 script generation or execution failed: ${
          error instanceof Error ? error.message : String(error)
        }`
      );
    }
  }
);