// 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)
}`
);
}
}
);