k6-cucumber-steps 1.2.8 โ†’ 1.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,14 +1,14 @@
1
1
  # k6-cucumber-steps ๐Ÿฅ’๐Ÿงช
2
2
 
3
3
  <table align="center" style="margin-bottom:30px;"><tr><td align="center" width="9999" heigth="9999" >
4
- <img src="assets/paschal logo (2).png" alt="paschal Logo" style="margin-top:25px;" align="center"/>
4
+ <img src="https://github.com/qaPaschalE/k6-cucumber-steps/blob/main/assets/paschal%20logo%20(2).png?raw=true" alt="paschal Logo" style="margin-top:25px;" align="center"/>
5
5
  </td></tr></table>
6
6
 
7
7
  [![npm version](https://img.shields.io/npm/v/k6-cucumber-steps.svg)](https://www.npmjs.com/package/k6-cucumber-steps)
8
- [![Built with k6](https://img.shields.io/badge/built%20with-k6-7D64FF?logo=k6&logoColor=white&style=flat-square)](https://k6.io/)
8
+ [![Built for k6](https://img.shields.io/badge/built%20with-k6-7D64FF?logo=k6&logoColor=white&style=flat-square)](https://k6.io/)
9
9
  [![license](https://img.shields.io/npm/l/k6-cucumber-steps?logo=github)](https://github.com/qaPaschalE/k6-cucumber-steps/blob/main/LICENSE)
10
- [![Built for Cucumber](https://img.shields.io/badge/built%20for-Cucumber-23d96c?logo=cucumber&logoColor=white&style=flat-square)](https://cucumber.io/)
11
- [![Node.js](https://img.shields.io/badge/node-%3E=18-green.svg)](https://nodejs.org/)
10
+ [![Built with Cucumber](https://img.shields.io/badge/built%20for-Cucumber-23d96c?logo=cucumber&logoColor=white&style=flat-square)](https://cucumber.io/)
11
+ [![Node.js](https://img.shields.io/badge/node-%3E=20-green.svg)](https://nodejs.org/)
12
12
  [![Build Status](https://github.com/qaPaschalE/k6-cucumber-steps/actions/workflows/k6-load-test.yml/badge.svg)](https://github.com/qaPaschalE/k6-cucumber-steps/actions/workflows/k6-load-test.yml)
13
13
  [![Issues](https://img.shields.io/github/issues/qaPaschalE/k6-cucumber-steps?style=flat-square)](https://github.com/qaPaschalE/k6-cucumber-steps/issues)
14
14
  [![Stars](https://img.shields.io/github/stars/qaPaschalE/k6-cucumber-steps?style=flat-square)](https://github.com/qaPaschalE/k6-cucumber-steps/stargazers)
@@ -16,6 +16,8 @@
16
16
 
17
17
  Run [k6](https://k6.io/) performance/load tests using [Cucumber](https://cucumber.io/) BDD syntax with ease.
18
18
 
19
+ ๐Ÿ‘‰ [View Steps Documentation](https://qapaschale.github.io/k6-cucumber-steps/)
20
+
19
21
  ---
20
22
 
21
23
  ## โœจ Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "k6-cucumber-steps",
3
- "version": "1.2.8",
3
+ "version": "1.2.10",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -23,7 +23,8 @@
23
23
  "test": "cucumber-js features/ --require-module @babel/register --require step_definitions/",
24
24
  "build": "npm run clean && npm run compile",
25
25
  "clean": "rm -rf dist/",
26
- "compile": "babel src/ -d dist/"
26
+ "compile": "babel src/ -d dist/",
27
+ "docs": "jsdoc --configure jsdoc.json --verbose"
27
28
  },
28
29
  "cucumber": {
29
30
  "require": [
@@ -56,7 +57,7 @@
56
57
  "engines": {
57
58
  "node": ">=20"
58
59
  },
59
- "author": "qaPaschalE",
60
+ "author": "qaPaschalE <paschal.enyimiri@gmail.com>",
60
61
  "description": "Cucumber step definitions for running k6 performance tests.",
61
62
  "peerDependencies": {
62
63
  "@cucumber/cucumber": "*",
@@ -72,5 +73,10 @@
72
73
  "commander": "^14.0.0",
73
74
  "dotenv": "^16.5.0",
74
75
  "html-minifier-terser": "^7.2.0"
76
+ },
77
+ "devDependencies": {
78
+ "clean-jsdoc-theme": "^4.3.0",
79
+ "jsdoc": "^4.0.4",
80
+ "taffydb": "^2.7.3"
75
81
  }
76
82
  }
@@ -1,271 +1,571 @@
1
- // load_test_steps.js
2
-
3
- const { Given, When, Then } = require("@cucumber/cucumber");
4
- const fs = require("fs");
5
- const path = require("path");
6
- const resolveBody = require("../lib/helpers/resolveBody.js");
7
- const buildK6Script = require("../lib/helpers/buildK6Script.js");
8
- const generateHeaders = require("../lib/helpers/generateHeaders.js");
9
- const { generateK6Script, runK6Script } = require("../lib/utils/k6Runner.js");
10
- const os = require("os");
11
- const crypto = require("crypto");
12
- require("dotenv").config();
1
+ // e2e/step_definitions/load_test_steps.js
2
+
3
+ import { Given, When, Then } from "@cucumber/cucumber";
4
+ import fs from "fs";
5
+ import path from "path";
6
+ import crypto from "crypto";
7
+ import * as dotenv from "dotenv";
8
+
9
+ import resolveBody from "../lib/helpers/resolveBody.js";
10
+ import buildK6Script from "../lib/helpers/buildK6Script.js";
11
+ import generateHeaders from "../lib/helpers/generateHeaders.js";
12
+ import { runK6Script } from "../lib/utils/k6Runner.js";
13
+
14
+ dotenv.config();
13
15
 
14
16
  /**
15
- * Sets the HTTP method for the k6 script.
17
+ * @typedef {Object} CustomWorld
18
+ * @property {Object} config
19
+ * @property {Object} aliases
20
+ * @property {Object} lastResponse
21
+ * @property {Object} parameters
22
+ * @property {Function} log
16
23
  */
17
- Given(/^I set a k6 script for (\w+) testing$/, async function (method) {
18
- this.config = { method: method.toUpperCase() };
19
- });
20
24
 
21
25
  /**
22
- * Sets k6 script options from a configuration table.
26
+ * @typedef {Object} K6Config
27
+ * @property {string} method - HTTP method for the request (e.g., "GET", "POST").
28
+ * @property {string} [endpoint] - The specific endpoint for a single request.
29
+ * @property {string[]} [endpoints] - An array of endpoints for multiple requests.
30
+ * @property {Object} [headers] - Request headers.
31
+ * @property {string} [body] - Request body content.
32
+ * @property {Object} options - k6 test options (vus, duration, stages, thresholds).
33
+ * @property {Object} options.thresholds - k6 metric thresholds.
34
+ * @property {string[]} options.thresholds.http_req_failed - Thresholds for failed HTTP requests.
35
+ * @property {string[]} options.thresholds.http_req_duration - Thresholds for request duration.
36
+ * @property {string[]} [options.thresholds.error_rate] - Optional threshold for error rate.
37
+ * @property {number} [options.vus] - Number of virtual users (for open model).
38
+ * @property {string} [options.duration] - Test duration (for open model, e.g., "30s").
39
+ * @property {Array<Object>} [options.stages] - Array of stages for a stepped load model.
23
40
  */
24
- When(
25
- /^I set to run the k6 script with the following configurations:$/,
26
- async function (dataTable) {
27
- const rawRow = dataTable.hashes()[0];
28
- const row = {};
29
-
30
- // Extract example values manually from this.pickle
31
- const exampleMap = {};
32
- if (this.pickle && this.pickle.astNodeIds && this.gherkinDocument) {
33
- const scenario = this.gherkinDocument.feature.children.find((child) => {
34
- return child.scenario && child.scenario.examples?.length;
35
- });
36
-
37
- const exampleValues =
38
- scenario?.scenario?.examples?.[0]?.tableBody?.[0]?.cells?.map(
39
- (cell) => cell.value
40
- ) || [];
41
-
42
- const exampleKeys =
43
- scenario?.scenario?.examples?.[0]?.tableHeader?.cells?.map(
44
- (cell) => cell.value
45
- ) || [];
46
-
47
- exampleKeys.forEach((key, idx) => {
48
- exampleMap[key] = exampleValues[idx];
49
- });
50
- }
51
41
 
52
- for (const [key, value] of Object.entries(rawRow)) {
53
- row[key] = value.replace(/<([^>]+)>/g, (_, param) => {
54
- return exampleMap[param] || value;
55
- });
56
- }
42
+ // ===================================================================================
43
+ // K6 SCRIPT CONFIGURATION STEPS
44
+ // ===================================================================================
45
+
46
+ /**
47
+ * Initializes the k6 script configuration by setting the primary HTTP method for the load test.
48
+ *
49
+ * ```gherkin
50
+ * Given I set a k6 script for {word} testing
51
+ * ```
52
+ *
53
+ * @param {string} method - The HTTP method (e.g., "GET", "POST", "PUT", "DELETE").
54
+ * @example
55
+ * Given I set a k6 script for GET testing
56
+ * Given I set a k6 script for POST testing
57
+ * @remarks
58
+ * This step typically starts the definition of a k6 load test scenario.
59
+ * It sets `this.config.method` in the Cucumber World context.
60
+ * Subsequent steps will build upon this configuration.
61
+ * @category k6 Configuration Steps
62
+ */
63
+ export async function Given_I_set_k6_script_for_method_testing(method) {
64
+ /** @type {CustomWorld} */ (this).config = { method: method.toUpperCase() };
65
+ /** @type {CustomWorld} */ (this).log?.(
66
+ `โš™๏ธ Initialized k6 script for ${method.toUpperCase()} testing.`
67
+ );
68
+ }
69
+ Given(
70
+ /^I set a k6 script for (\w+) testing$/,
71
+ Given_I_set_k6_script_for_method_testing
72
+ );
57
73
 
58
- const validateThreshold = (value) => {
59
- const regex = /^[\w{}()<>:]+[<>=]\d+(\.\d+)?$/;
60
- if (value && !regex.test(value)) {
61
- throw new Error(`Invalid threshold format: ${value}`);
74
+ /**
75
+ * Configures the k6 script options (VUs, duration, stages, thresholds) from a data table.
76
+ *
77
+ * ```gherkin
78
+ * When I set to run the k6 script with the following configurations:
79
+ * | virtual_users | duration | stages | http_req_failed | http_req_duration | error_rate |
80
+ * | 10 | 30 | | p(99)<0.01 | p(99)<500 | rate<0.01 |
81
+ * | | | [{"duration":"10s","target":10}] | p(90)<0.01 | p(90)<200 | rate<0.001 |
82
+ * ```
83
+ *
84
+ * @param {DataTable} dataTable - A Cucumber data table containing k6 configuration parameters.
85
+ * Expected columns: `virtual_users`, `duration`, `stages` (JSON string), `http_req_failed`, `http_req_duration`, `error_rate`.
86
+ * @example
87
+ * When I set to run the k6 script with the following configurations:
88
+ * | virtual_users | duration | http_req_failed | http_req_duration |
89
+ * | 50 | 60 | p(99)<0.01 | p(99)<1000 |
90
+ * When I set to run the k6 script with the following configurations:
91
+ * | stages | http_req_failed | http_req_duration | error_rate |
92
+ * | [{"duration":"10s","target":10}, {"duration":"20s","target":50}] | p(99)<0.01 | p(99)<500 | rate<0.01 |
93
+ * @remarks
94
+ * This step populates `this.config.options`. It intelligently handles either a simple
95
+ * `virtual_users`/`duration` model or a complex `stages` array. Threshold formats are validated.
96
+ * Example values from scenario outlines are resolved if present.
97
+ * @category k6 Configuration Steps
98
+ */
99
+ export async function When_I_set_k6_script_configurations(dataTable) {
100
+ /** @type {CustomWorld} */ (this);
101
+ const rawRow = dataTable.hashes()[0];
102
+ const row = {};
103
+
104
+ const exampleMap = {};
105
+ if (this.pickle && this.pickle.astNodeIds && this.gherkinDocument) {
106
+ const scenarioNodeId = this.pickle.astNodeIds.find((id) =>
107
+ id.startsWith("Scenario")
108
+ );
109
+ if (scenarioNodeId) {
110
+ const scenario = this.gherkinDocument.feature.children.find(
111
+ (child) => child.scenario && child.scenario.id === scenarioNodeId
112
+ )?.scenario;
113
+
114
+ if (scenario && scenario.examples && scenario.examples.length > 0) {
115
+ const exampleTable = scenario.examples[0].tableBody?.[0];
116
+ const headerCells = scenario.examples[0].tableHeader?.cells || [];
117
+ const dataCells = exampleTable?.cells || [];
118
+
119
+ headerCells.forEach((cell, idx) => {
120
+ exampleMap[cell.value] = dataCells[idx]?.value;
121
+ });
62
122
  }
63
- };
123
+ }
124
+ }
64
125
 
65
- validateThreshold(row.http_req_failed);
66
- validateThreshold(row.http_req_duration);
67
- validateThreshold(row.error_rate);
68
-
69
- if (row.stages) {
70
- try {
71
- this.config.options = {
72
- stages: JSON.parse(row.stages),
73
- thresholds: {
74
- http_req_failed: [row.http_req_failed],
75
- http_req_duration: [row.http_req_duration],
76
- },
77
- };
78
- } catch {
79
- throw new Error("Invalid stages JSON format.");
126
+ for (const [key, value] of Object.entries(rawRow)) {
127
+ row[key] = value.replace(/<([^>]+)>/g, (_, param) => {
128
+ if (exampleMap.hasOwnProperty(param)) {
129
+ return exampleMap[param];
80
130
  }
81
- } else {
82
- this.config.options = {
83
- vus: parseInt(row.virtual_users),
84
- duration: `${row.duration}s`,
131
+ return `<${param}>`;
132
+ });
133
+ }
134
+
135
+ const validateThreshold = (value, thresholdName) => {
136
+ const regex = /^[\w{}()<>:]+([<>=]=?)\d+(\.\d+)?$/;
137
+ if (value && !regex.test(value)) {
138
+ throw new Error(
139
+ `Invalid k6 threshold format for '${thresholdName}': "${value}". Expected format like 'p(99)<500' or 'rate<0.01'.`
140
+ );
141
+ }
142
+ };
143
+
144
+ validateThreshold(row.http_req_failed, "http_req_failed");
145
+ validateThreshold(row.http_req_duration, "http_req_duration");
146
+ if (row.error_rate) {
147
+ validateThreshold(row.error_rate, "error_rate");
148
+ }
149
+
150
+ let k6Options;
151
+
152
+ if (row.stages) {
153
+ try {
154
+ k6Options = {
155
+ stages: JSON.parse(row.stages),
85
156
  thresholds: {
86
157
  http_req_failed: [row.http_req_failed],
87
158
  http_req_duration: [row.http_req_duration],
88
159
  },
89
160
  };
161
+ } catch (e) {
162
+ throw new Error(`Invalid 'stages' JSON format: ${e.message}`);
90
163
  }
164
+ } else if (row.virtual_users && row.duration) {
165
+ k6Options = {
166
+ vus: parseInt(row.virtual_users),
167
+ duration: `${row.duration}s`,
168
+ thresholds: {
169
+ http_req_failed: [row.http_req_failed],
170
+ http_req_duration: [row.http_req_duration],
171
+ },
172
+ };
173
+ } else {
174
+ throw new Error(
175
+ "k6 configuration requires either 'stages' or 'virtual_users' and 'duration' to be set."
176
+ );
177
+ }
91
178
 
92
- if (row.error_rate) {
93
- this.config.options.thresholds.error_rate = [row.error_rate];
94
- }
179
+ if (row.error_rate) {
180
+ k6Options.thresholds.error_rate = [row.error_rate];
95
181
  }
182
+
183
+ this.config.options = k6Options;
184
+ this.log?.(
185
+ `โš™๏ธ k6 script configured with options: ${JSON.stringify(k6Options)}`
186
+ );
187
+ }
188
+ When(
189
+ /^I set to run the k6 script with the following configurations:$/,
190
+ When_I_set_k6_script_configurations
96
191
  );
97
192
 
98
193
  /**
99
- * Sets request headers for the k6 script.
194
+ * Sets request headers for the k6 script. Headers are merged with any existing headers.
195
+ *
196
+ * ```gherkin
197
+ * When I set the request headers:
198
+ * | Header | Value |
199
+ * | Content-Type | application/json |
200
+ * | Authorization | Bearer <my_token> |
201
+ * ```
202
+ *
203
+ * @param {DataTable} dataTable - A Cucumber data table with 'Header' and 'Value' columns.
204
+ *
205
+ * @example
206
+ * When I set the request headers:
207
+ * | Header | Value |
208
+ * | Content-Type | application/json |
209
+ * | X-Custom-Header| MyValue |
210
+ *
211
+ * @remarks
212
+ * This step updates `this.config.headers`. Values can include placeholders
213
+ * (e.g., `<my_token>`) if your `resolveBody` or `generateHeaders` helpers handle them.
214
+ * Note: `generateHeaders` is specifically used for authentication types. If your headers
215
+ * contain dynamic values beyond simple alias resolution, ensure your helpers support it.
216
+ * @category k6 Configuration Steps
100
217
  */
101
- When(/^I set the request headers:$/, async function (dataTable) {
218
+ export async function When_I_set_request_headers(dataTable) {
219
+ /** @type {CustomWorld} */ (this);
102
220
  const headers = {};
103
221
  dataTable.hashes().forEach(({ Header, Value }) => {
104
222
  headers[Header] = Value;
105
223
  });
106
224
 
107
225
  this.config.headers = {
108
- ...this.config.headers,
226
+ ...(this.config.headers || {}),
109
227
  ...headers,
110
228
  };
111
- });
229
+ this.log?.(`โš™๏ธ Request headers set: ${JSON.stringify(this.config.headers)}`);
230
+ }
231
+ When(/^I set the request headers:$/, When_I_set_request_headers);
112
232
 
113
233
  /**
114
- * Sets endpoints for the k6 script.
234
+ * Sets the list of endpoints to be used in the k6 script. These are typically used when
235
+ * the k6 script iterates over multiple URLs.
236
+ *
237
+ * ```gherkin
238
+ * When I set the following endpoints used:
239
+ * /api/v1/users
240
+ * /api/v1/products
241
+ * /api/v1/orders
242
+ * ```
243
+ *
244
+ * @param {string} docString - A DocString containing a newline-separated list of endpoints.
245
+ *
246
+ * @example
247
+ * When I set the following endpoints used:
248
+ * /health
249
+ * /status
250
+ * /metrics
251
+ *
252
+ * @remarks
253
+ * This step populates `this.config.endpoints` as an array of strings.
254
+ * Ensure these endpoints are relative to your k6 `BASE_URL`.
255
+ * @category k6 Configuration Steps
115
256
  */
116
- When(/^I set the following endpoints used:$/, async function (docString) {
257
+ export async function When_I_set_endpoints_used(docString) {
258
+ /** @type {CustomWorld} */ (this);
117
259
  this.config.endpoints = docString
118
260
  .trim()
119
261
  .split("\n")
120
262
  .map((line) => line.trim());
121
- });
263
+ if (this.log)
264
+ this.log(`โš™๏ธ Endpoints set: ${JSON.stringify(this.config.endpoints)}`);
265
+ }
266
+ When(/^I set the following endpoints used:$/, When_I_set_endpoints_used);
122
267
 
123
268
  /**
124
- * Sets the request body for a specific method and endpoint.
269
+ * Sets the request body for a specific HTTP method and endpoint.
270
+ *
271
+ * ```gherkin
272
+ * When I set the following POST body is used for "/api/v1/create"
273
+ * { "name": "test", "email": "test@example.com" }
274
+ * ```
275
+ *
276
+ * @param {string} method - The HTTP method (e.g., "POST", "PUT").
277
+ * @param {string} endpoint - The specific endpoint URL for this body.
278
+ * @param {string} docString - A DocString containing the request body content (e.g., JSON).
279
+ *
280
+ * @example
281
+ * When I set the following PUT body is used for "/api/v1/update/1"
282
+ * { "status": "active" }
283
+ *
284
+ * @remarks
285
+ * This step sets `this.config.method`, `this.config.endpoint`, and `this.config.body`.
286
+ * The `resolveBody` helper is used to process the DocString, allowing for dynamic values
287
+ * from environment variables.
288
+ * @category k6 Configuration Steps
125
289
  */
290
+ export async function When_I_set_method_body_for_endpoint(
291
+ method,
292
+ endpoint,
293
+ docString
294
+ ) {
295
+ /** @type {CustomWorld} */ (this);
296
+ this.config.method = method.toUpperCase();
297
+ this.config.endpoint = endpoint;
298
+ this.config.body = resolveBody(docString, process.env);
299
+ this.log?.(
300
+ `โš™๏ธ Body set for ${this.config.method} to "${
301
+ this.config.endpoint
302
+ }". Body preview: ${this.config.body.slice(0, 100)}...`
303
+ );
304
+ }
126
305
  When(
127
306
  /^I set the following (\w+) body is used for "([^"]+)"$/,
128
- async function (method, endpoint, docString) {
129
- this.config.method = method.toUpperCase();
130
- this.config.body = resolveBody(docString, process.env);
131
- this.config.endpoint = endpoint;
132
- }
307
+ When_I_set_method_body_for_endpoint
133
308
  );
134
309
 
135
310
  /**
136
- * Loads a JSON payload for a method and endpoint.
311
+ * Loads a JSON payload from a file to be used as the request body for a specific
312
+ * method and endpoint in the k6 script.
313
+ *
314
+ * ```gherkin
315
+ * When I use JSON payload from "user_create.json" for POST to "/api/v1/users"
316
+ * ```
317
+ *
318
+ * @param {string} fileName - The name of the JSON payload file (e.g., "user_data.json").
319
+ * @param {string} method - The HTTP method (only "POST", "PUT", "PATCH" are supported for bodies).
320
+ * @param {string} endpoint - The specific endpoint URL.
321
+ *
322
+ * @example
323
+ * When I use JSON payload from "login_payload.json" for POST to "/auth/login"
324
+ *
325
+ * @remarks
326
+ * This step reads the JSON file, resolves any placeholders within it (using `resolveBody`),
327
+ * and sets `this.config.method`, `this.config.endpoint`, and `this.config.body`.
328
+ * It also stores `lastRequest` in `this.lastRequest`.
329
+ * The payload file path is resolved relative to `payloads` directory or `this.parameters.payloadPath`.
330
+ * @category k6 Configuration Steps
137
331
  */
138
- When(
139
- /^I use JSON payload from "([^"]+)" for (\w+) to "([^"]+)"$/,
140
- async function (fileName, method, endpoint) {
141
- const allowedMethods = ["POST", "PUT", "PATCH"];
142
- const methodUpper = method.toUpperCase();
143
-
144
- if (!allowedMethods.includes(methodUpper)) {
145
- throw new Error(
146
- `Method "${method}" is not supported. Use one of: ${allowedMethods.join(
147
- ", "
148
- )}`
149
- );
150
- }
332
+ export async function When_I_use_JSON_payload_from_file_for_method_to_endpoint(
333
+ fileName,
334
+ method,
335
+ endpoint
336
+ ) {
337
+ /** @type {CustomWorld} */ (this);
338
+ const allowedMethods = ["POST", "PUT", "PATCH"];
339
+ const methodUpper = method.toUpperCase();
340
+
341
+ if (!allowedMethods.includes(methodUpper)) {
342
+ throw new Error(
343
+ `Method "${method}" is not supported for JSON payloads from files. Use one of: ${allowedMethods.join(
344
+ ", "
345
+ )}`
346
+ );
347
+ }
151
348
 
152
- const projectRoot = path.resolve(__dirname, "..", "..");
153
- const payloadDir = this.parameters?.payloadPath || "payloads";
154
- const payloadPath = path.isAbsolute(payloadDir)
155
- ? path.join(payloadDir, fileName)
156
- : path.join(projectRoot, payloadDir, fileName);
349
+ const projectRoot = path.resolve(__dirname, "..", "..");
350
+ const payloadDir = this.parameters?.payloadPath || "payloads";
351
+ const payloadPath = path.isAbsolute(payloadDir)
352
+ ? path.join(payloadDir, fileName)
353
+ : path.join(projectRoot, payloadDir, fileName);
157
354
 
158
- if (!fs.existsSync(payloadPath)) {
159
- throw new Error(`Payload file not found: ${payloadPath}`);
160
- }
355
+ if (!fs.existsSync(payloadPath)) {
356
+ throw new Error(`Payload file not found: "${payloadPath}"`);
357
+ }
161
358
 
162
- const rawTemplate = fs.readFileSync(payloadPath, "utf-8");
163
- const resolved = resolveBody(rawTemplate, {
164
- ...process.env,
165
- ...(this.aliases || {}),
166
- });
359
+ const rawTemplate = fs.readFileSync(payloadPath, "utf-8");
360
+ const resolved = resolveBody(rawTemplate, {
361
+ ...process.env,
362
+ ...(this.aliases || {}),
363
+ });
167
364
 
168
- this.config = {
169
- ...this.config,
170
- method: methodUpper,
171
- endpoint,
172
- body: resolved,
173
- headers: this.config?.headers || {},
174
- };
365
+ this.config = {
366
+ ...(this.config || {}),
367
+ method: methodUpper,
368
+ endpoint,
369
+ body: resolved,
370
+ headers: this.config?.headers || {},
371
+ };
175
372
 
176
- this.lastRequest = {
177
- method: methodUpper,
178
- endpoint,
179
- body: resolved,
180
- };
181
- }
373
+ this.lastRequest = {
374
+ method: methodUpper,
375
+ endpoint,
376
+ body: resolved,
377
+ };
378
+ this.log?.(
379
+ `โš™๏ธ JSON payload from "${fileName}" used for ${methodUpper} to "${endpoint}".`
380
+ );
381
+ }
382
+ When(
383
+ /^I use JSON payload from "([^"]+)" for (\w+) to "([^"]+)"$/,
384
+ When_I_use_JSON_payload_from_file_for_method_to_endpoint
182
385
  );
183
386
 
184
387
  /**
185
- * Sets the authentication type for the request.
388
+ * Sets the authentication type for the k6 request, generating relevant headers.
389
+ *
390
+ * ```gherkin
391
+ * When I set the authentication type to "BearerToken"
392
+ * ```
393
+ *
394
+ * @param {string} authType - The type of authentication (e.g., "BearerToken", "BasicAuth", "APIKey").
395
+ *
396
+ * @example
397
+ * When I set the authentication type to "BearerToken"
398
+ *
399
+ * @remarks
400
+ * This step uses the `generateHeaders` helper to create or modify `this.config.headers`
401
+ * based on the specified `authType` and environment variables/aliases.
402
+ * Ensure your `generateHeaders` helper is configured to handle the `authType` and retrieve
403
+ * necessary credentials (e.g., from `process.env` or `this.aliases`).
404
+ * @category k6 Configuration Steps
186
405
  */
187
- When(/^I set the authentication type to "([^"]+)"$/, async function (authType) {
406
+ export async function When_I_set_authentication_type(authType) {
407
+ /** @type {CustomWorld} */ (this);
188
408
  this.config.headers = generateHeaders(
189
409
  authType,
190
410
  process.env,
191
411
  this.aliases || {}
192
412
  );
193
- });
413
+ this.log?.(`โš™๏ธ Authentication type set to "${authType}". Headers updated.`);
414
+ }
415
+ When(
416
+ /^I set the authentication type to "([^"]+)"$/,
417
+ When_I_set_authentication_type
418
+ );
194
419
 
195
420
  /**
196
- * Stores a value from the last response as an alias.
421
+ * Stores a value from the last API response into the Cucumber World's aliases context.
422
+ *
423
+ * ```gherkin
424
+ * Then I store the value at "data.token" as alias "authToken"
425
+ * ```
426
+ *
427
+ * @param {string} jsonPath - A dot-separated JSON path to the value in the last response (e.g., "data.user.id").
428
+ * @param {string} alias - The name of the alias to store the value under (e.g., "userId").
429
+ *
430
+ * @example
431
+ * Then I store the value at "token" as alias "accessToken"
432
+ * Then I store the value at "user.profile.email" as alias "userEmail"
433
+ *
434
+ * @remarks
435
+ * This step expects `this.lastResponse` to contain a parsed JSON object.
436
+ * It traverses the `jsonPath` to extract the desired value and saves it into
437
+ * `this.aliases`. This alias can then be used in subsequent steps or payload resolutions.
438
+ * @category Data Management Steps
197
439
  */
198
- Then(
199
- /^I store the value at "([^"]+)" as alias "([^"]+)"$/,
200
- async function (jsonPath, alias) {
201
- if (!this.lastResponse) {
202
- throw new Error("No previous response available.");
203
- }
440
+ export async function Then_I_store_value_as_alias(jsonPath, alias) {
441
+ /** @type {CustomWorld} */ (this);
442
+ if (!this.lastResponse) {
443
+ throw new Error(
444
+ "No previous API response available to extract value from. Ensure a login or request step was executed."
445
+ );
446
+ }
204
447
 
205
- const pathParts = jsonPath.split(".");
206
- let value = this.lastResponse;
448
+ const pathParts = jsonPath.split(".");
449
+ let value = this.lastResponse;
207
450
 
208
- for (const key of pathParts) {
209
- value = value?.[key];
210
- if (value === undefined) break;
451
+ for (const key of pathParts) {
452
+ if (value && typeof value === "object" && key in value) {
453
+ value = value[key];
454
+ } else {
455
+ value = undefined;
456
+ break;
211
457
  }
458
+ }
212
459
 
213
- if (value === undefined) {
214
- throw new Error(`Could not resolve path "${jsonPath}" in the response`);
215
- }
460
+ if (value === undefined) {
461
+ throw new Error(
462
+ `Could not resolve path "${jsonPath}" in the last response. Value is undefined.`
463
+ );
464
+ }
216
465
 
217
- if (!this.aliases) this.aliases = {};
218
- this.aliases[alias] = value;
466
+ if (!this.aliases) this.aliases = {};
467
+ this.aliases[alias] = value;
219
468
 
220
- console.log(`๐Ÿงฉ Stored alias "${alias}":`, value);
221
- }
469
+ this.log?.(
470
+ `๐Ÿงฉ Stored alias "${alias}" from response path "${jsonPath}". Value: ${JSON.stringify(
471
+ value
472
+ ).slice(0, 100)}...`
473
+ );
474
+ }
475
+ Then(
476
+ /^I store the value at "([^"]+)" as alias "([^"]+)"$/,
477
+ Then_I_store_value_as_alias
222
478
  );
223
479
 
224
480
  /**
225
- * Logs in via POST to an endpoint with a payload from a file.
481
+ * Logs in via a POST request to a specified endpoint using a JSON payload from a file.
482
+ * The response data is stored for subsequent steps.
483
+ *
484
+ * ```gherkin
485
+ * When I login via POST to "/auth/login" with payload from "admin_credentials.json"
486
+ * ```
487
+ *
488
+ * @param {string} endpoint - The API endpoint for the login request (relative to `BASE_URL`).
489
+ * @param {string} fileName - The name of the JSON file containing login credentials.
490
+ *
491
+ * @example
492
+ * When I login via POST to "/api/login" with payload from "user_creds.json"
493
+ *
494
+ * @remarks
495
+ * This step constructs and executes a `fetch` POST request. It reads the payload from
496
+ * the specified file (resolved from `payloads` directory), resolves placeholders in the payload,
497
+ * sends the request, and stores the JSON response in `this.lastResponse`.
498
+ * It throws an error if the login request fails (non-2xx status).
499
+ * @category Authentication Steps
226
500
  */
227
- When(
228
- /^I login via POST to "([^"]+)" with payload from "([^"]+)"$/,
229
- async function (endpoint, fileName) {
230
- const payloadDir = this.parameters?.payloadPath || "payloads";
231
- const projectRoot = path.resolve(__dirname, "..", "..");
232
- const payloadPath = path.isAbsolute(payloadDir)
233
- ? path.join(payloadDir, fileName)
234
- : path.join(projectRoot, payloadDir, fileName);
235
-
236
- if (!fs.existsSync(payloadPath)) {
237
- throw new Error(`Payload file not found: ${payloadPath}`);
238
- }
239
-
240
- const rawTemplate = fs.readFileSync(payloadPath, "utf-8");
241
- const resolved = resolveBody(rawTemplate, {
242
- ...process.env,
243
- ...(this.aliases || {}),
244
- });
501
+ export async function When_I_login_via_POST_with_payload_from_file(
502
+ endpoint,
503
+ fileName
504
+ ) {
505
+ /** @type {CustomWorld} */ (this);
506
+ const payloadDir = this.parameters?.payloadPath || "payloads";
507
+ const projectRoot = path.resolve(__dirname, "..", "..");
508
+ const payloadPath = path.isAbsolute(payloadDir)
509
+ ? path.join(payloadDir, fileName)
510
+ : path.join(projectRoot, payloadDir, fileName);
511
+
512
+ if (!fs.existsSync(payloadPath)) {
513
+ throw new Error(`Payload file not found: "${payloadPath}"`);
514
+ }
245
515
 
246
- try {
247
- const response = await fetch(`${process.env.BASE_URL}${endpoint}`, {
248
- method: "POST",
249
- headers: {
250
- "Content-Type": "application/json",
251
- },
252
- body: JSON.stringify(resolved),
253
- });
516
+ const rawTemplate = fs.readFileSync(payloadPath, "utf-8");
517
+ const resolved = resolveBody(rawTemplate, {
518
+ ...process.env,
519
+ ...(this.aliases || {}),
520
+ });
254
521
 
255
- const data = await response.json();
522
+ try {
523
+ const baseUrl = process.env.BASE_URL;
524
+ if (!baseUrl) {
525
+ throw new Error("Missing BASE_URL environment variable.");
526
+ }
527
+ const fullUrl = `${baseUrl.replace(/\/+$/, "")}${endpoint}`;
528
+
529
+ this.log?.(
530
+ `๐Ÿ” Attempting login via POST to "${fullUrl}" with payload from "${fileName}".`
531
+ );
532
+
533
+ const response = await fetch(fullUrl, {
534
+ method: "POST",
535
+ headers: {
536
+ "Content-Type": "application/json",
537
+ },
538
+ body: JSON.stringify(resolved),
539
+ });
256
540
 
257
- if (!response.ok) {
258
- console.error("โŒ Login request failed:", data);
259
- throw new Error(`Login request failed with status ${response.status}`);
260
- }
541
+ const data = await response.json();
261
542
 
262
- this.lastResponse = data;
263
- console.log("๐Ÿ” Login successful, response saved to alias context.");
264
- } catch (err) {
265
- console.error("โŒ Login request failed:", err.message);
266
- throw new Error("Login request failed");
543
+ if (!response.ok) {
544
+ this.log?.(
545
+ `โŒ Login request failed for "${fullUrl}". Status: ${
546
+ response.status
547
+ }. Response body: ${JSON.stringify(data).slice(0, 100)}...`
548
+ );
549
+ throw new Error(
550
+ `Login request failed with status ${response.status} for endpoint "${endpoint}".`
551
+ );
267
552
  }
553
+
554
+ this.lastResponse = data;
555
+ this.log?.(
556
+ "๐Ÿ” Login successful, response data saved to 'this.lastResponse'."
557
+ );
558
+ } catch (err) {
559
+ const message = err instanceof Error ? err.message : String(err);
560
+ this.log?.(`โŒ Login request failed: ${message}`);
561
+ throw new Error(
562
+ `Login request failed for endpoint "${endpoint}": ${message}`
563
+ );
268
564
  }
565
+ }
566
+ When(
567
+ /^I login via POST to "([^"]+)" with payload from "([^"]+)"$/,
568
+ When_I_login_via_POST_with_payload_from_file
269
569
  );
270
570
 
271
571
  const genScriptDir = path.resolve(process.cwd(), "genScript");
@@ -273,11 +573,11 @@ if (!fs.existsSync(genScriptDir)) {
273
573
  fs.mkdirSync(genScriptDir, { recursive: true });
274
574
  }
275
575
 
276
- // Determine the report/output directory from env, CLI, or default to "reports"
277
576
  const reportDir =
278
577
  process.env.REPORT_OUTPUT_DIR ||
279
578
  process.env.K6_REPORT_DIR ||
280
- (process.env.npm_config_report_output_dir || "reports");
579
+ process.env.npm_config_report_output_dir ||
580
+ "reports";
281
581
 
282
582
  if (!fs.existsSync(reportDir)) {
283
583
  fs.mkdirSync(reportDir, { recursive: true });
@@ -300,22 +600,28 @@ Then(
300
600
  try {
301
601
  const scriptContent = buildK6Script(this.config);
302
602
  const uniqueId = crypto.randomBytes(8).toString("hex");
303
- const scriptPath = path.join(reportDir, `k6-script-${uniqueId}.js`);
603
+ const scriptFileName = `k6-script-${uniqueId}.js`;
604
+ const scriptPath = path.join(reportDir, scriptFileName);
304
605
  fs.writeFileSync(scriptPath, scriptContent, "utf-8");
305
- console.log(`โœ… k6 script generated at: ${scriptPath}`);
606
+ this.log?.(`โœ… k6 script generated at: "${scriptPath}"`);
306
607
 
307
- // Always run k6 automatically, regardless of VU count
608
+ this.log?.(`๐Ÿš€ Running k6 script: "${scriptFileName}"...`);
308
609
  const { stdout, stderr, code } = await runK6Script(
309
610
  scriptPath,
310
611
  process.env.K6_CUCUMBER_OVERWRITE === "true"
311
612
  );
312
- if (stdout) console.log(stdout);
313
- if (stderr) console.error(stderr);
613
+ if (stdout) this.log?.(`k6 STDOUT:\n${stdout}`);
614
+ if (stderr) this.log?.(`k6 STDERR:\n${stderr}`);
615
+
314
616
  if (code !== 0) {
315
- throw new Error(`k6 exited with code ${code}`);
617
+ throw new Error(
618
+ `k6 process exited with code ${code}. Check k6 output for details.`
619
+ );
316
620
  }
621
+ this.log?.(
622
+ `โœ… k6 script executed successfully for ${expectedMethod} request.`
623
+ );
317
624
 
318
- // Remove the script unless saveK6Script is true in env/config/cli
319
625
  const saveK6Script =
320
626
  process.env.saveK6Script === "true" ||
321
627
  process.env.SAVE_K6_SCRIPT === "true" ||
@@ -324,20 +630,32 @@ Then(
324
630
  if (!saveK6Script) {
325
631
  try {
326
632
  fs.unlinkSync(scriptPath);
633
+ this.log?.(`๐Ÿงน Temporary k6 script deleted: "${scriptPath}"`);
327
634
  } catch (cleanupErr) {
328
- console.warn(`Warning: Could not delete temp script file: ${scriptPath}`);
635
+ this.log?.(
636
+ `โš ๏ธ Warning: Could not delete temporary k6 script file: "${scriptPath}". Error: ${
637
+ cleanupErr instanceof Error
638
+ ? cleanupErr.message
639
+ : String(cleanupErr)
640
+ }`
641
+ );
329
642
  }
330
643
  } else {
331
- console.log(`โ„น๏ธ k6 script kept at: ${scriptPath}`);
644
+ this.log?.(
645
+ `โ„น๏ธ k6 script kept at: "${scriptPath}". Set SAVE_K6_SCRIPT=false to delete automatically.`
646
+ );
332
647
  }
333
648
  } catch (error) {
334
- console.error(
335
- "Failed to generate or run k6 script:",
336
- error.stack || error
649
+ this.log?.(
650
+ `โŒ Failed to generate or run k6 script: ${
651
+ error instanceof Error ? error.message : String(error)
652
+ }`
653
+ );
654
+ throw new Error(
655
+ `k6 script generation or execution failed: ${
656
+ error instanceof Error ? error.message : String(error)
657
+ }`
337
658
  );
338
- throw new Error("k6 script generation or execution failed");
339
659
  }
340
660
  }
341
661
  );
342
-
343
- // Repeat this pattern for all other step definitions!