qase-javascript-commons 2.5.5 → 2.5.7

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/changelog.md CHANGED
@@ -1,3 +1,17 @@
1
+ # qase-javascript-commons@2.5.7
2
+
3
+ ## Bug fixes
4
+
5
+ - Moved profiler modules to a separate sub-path export (`qase-javascript-commons/profilers`) to fix Webpack `UnhandledSchemeError` when using Cypress. The main entry point no longer pulls in `node:` URI imports (`async_hooks`, `http`, `https`, `diagnostics_channel`).
6
+
7
+ # qase-javascript-commons@2.5.6
8
+
9
+ ## What's new
10
+
11
+ - Added Network Profiler framework for automatic HTTP/HTTPS request interception during test execution.
12
+ - Added `StepType.REQUEST` step type for network request data in test results.
13
+ - Added configurable profiler options in `qase.config.json` (`profilers` and `networkProfiler` fields).
14
+
1
15
  # qase-javascript-commons@2.5.5
2
16
 
3
17
  ## What's new
@@ -21,6 +21,7 @@ export declare class ClientV2 extends ClientV1 {
21
21
  private createBaseResultStep;
22
22
  private processTextStep;
23
23
  private processGherkinStep;
24
+ private processRequestStep;
24
25
  private getExecution;
25
26
  private getRelation;
26
27
  private getDefaultSuiteRelation;
@@ -173,9 +173,12 @@ class ClientV2 extends clientV1_1.ClientV1 {
173
173
  if (step.step_type === models_1.StepType.TEXT) {
174
174
  this.processTextStep(step, resultStep, testTitle);
175
175
  }
176
- else {
176
+ else if (step.step_type === models_1.StepType.GHERKIN) {
177
177
  this.processGherkinStep(step, resultStep);
178
178
  }
179
+ else if (step.step_type === models_1.StepType.REQUEST) {
180
+ this.processRequestStep(step, resultStep);
181
+ }
179
182
  if (step.steps.length > 0) {
180
183
  resultStep.steps = await this.transformSteps(step.steps, testTitle);
181
184
  }
@@ -215,6 +218,13 @@ class ClientV2 extends clientV1_1.ClientV1 {
215
218
  const stepData = step.data;
216
219
  resultStep.data.action = stepData.keyword;
217
220
  }
221
+ processRequestStep(step, resultStep) {
222
+ if (!('request_method' in step.data) || !resultStep.data) {
223
+ return;
224
+ }
225
+ const stepData = step.data;
226
+ resultStep.data.action = `${stepData.request_method} ${stepData.request_url}`;
227
+ }
218
228
  getExecution(exec) {
219
229
  return {
220
230
  status: ClientV2.statusMap[exec.status],
@@ -298,5 +298,29 @@ export declare const configValidationSchema: {
298
298
  };
299
299
  };
300
300
  };
301
+ profilers: {
302
+ type: string;
303
+ items: {
304
+ type: string;
305
+ };
306
+ nullable: boolean;
307
+ };
308
+ networkProfiler: {
309
+ type: string;
310
+ nullable: boolean;
311
+ properties: {
312
+ skip_domains: {
313
+ type: string;
314
+ items: {
315
+ type: string;
316
+ };
317
+ nullable: boolean;
318
+ };
319
+ track_on_fail: {
320
+ type: string;
321
+ nullable: boolean;
322
+ };
323
+ };
324
+ };
301
325
  };
302
326
  };
@@ -282,5 +282,27 @@ exports.configValidationSchema = {
282
282
  },
283
283
  },
284
284
  },
285
+ profilers: {
286
+ type: 'array',
287
+ items: {
288
+ type: 'string',
289
+ },
290
+ nullable: true,
291
+ },
292
+ networkProfiler: {
293
+ type: 'object',
294
+ nullable: true,
295
+ properties: {
296
+ skip_domains: {
297
+ type: 'array',
298
+ items: { type: 'string' },
299
+ nullable: true,
300
+ },
301
+ track_on_fail: {
302
+ type: 'boolean',
303
+ nullable: true,
304
+ },
305
+ },
306
+ },
285
307
  },
286
308
  };
@@ -2,6 +2,7 @@ export { TestResultType } from './test-result';
2
2
  export type { Relation, Suite, SuiteData, TestopsProjectMapping } from './test-result';
3
3
  export { TestExecution, TestStatusEnum } from './test-execution';
4
4
  export { TestStepType, StepType } from './test-step';
5
+ export type { StepRequestData, StepTextData, StepGherkinData } from './step-data';
5
6
  export { StepStatusEnum } from './step-execution';
6
7
  export type { Attachment } from './attachment';
7
8
  export type { Report } from './report';
@@ -8,3 +8,12 @@ export interface StepGherkinData {
8
8
  name: string;
9
9
  line: number;
10
10
  }
11
+ export interface StepRequestData {
12
+ request_method: string;
13
+ request_url: string;
14
+ request_headers: Record<string, string> | null;
15
+ request_body: string | null;
16
+ status_code: number | null;
17
+ response_body: string | null;
18
+ response_headers: Record<string, string> | null;
19
+ }
@@ -1,14 +1,15 @@
1
- import { StepGherkinData, StepTextData } from './step-data';
1
+ import { StepGherkinData, StepRequestData, StepTextData } from './step-data';
2
2
  import { StepExecution } from './step-execution';
3
3
  import { Attachment } from './attachment';
4
4
  export declare enum StepType {
5
5
  TEXT = "text",
6
- GHERKIN = "gherkin"
6
+ GHERKIN = "gherkin",
7
+ REQUEST = "request"
7
8
  }
8
9
  export declare class TestStepType {
9
10
  id: string;
10
11
  step_type: StepType;
11
- data: StepTextData | StepGherkinData;
12
+ data: StepTextData | StepGherkinData | StepRequestData;
12
13
  parent_id: string | null;
13
14
  execution: StepExecution;
14
15
  attachments: Attachment[];
@@ -6,6 +6,7 @@ var StepType;
6
6
  (function (StepType) {
7
7
  StepType["TEXT"] = "text";
8
8
  StepType["GHERKIN"] = "gherkin";
9
+ StepType["REQUEST"] = "request";
9
10
  })(StepType || (exports.StepType = StepType = {}));
10
11
  class TestStepType {
11
12
  id;
@@ -29,13 +30,24 @@ class TestStepType {
29
30
  data: null,
30
31
  };
31
32
  }
32
- else {
33
+ else if (type === StepType.GHERKIN) {
33
34
  this.data = {
34
35
  keyword: '',
35
36
  name: '',
36
37
  line: 0,
37
38
  };
38
39
  }
40
+ else {
41
+ this.data = {
42
+ request_method: '',
43
+ request_url: '',
44
+ request_headers: null,
45
+ request_body: null,
46
+ status_code: null,
47
+ response_body: null,
48
+ response_headers: null,
49
+ };
50
+ }
39
51
  }
40
52
  }
41
53
  exports.TestStepType = TestStepType;
@@ -31,6 +31,11 @@ export type OptionsType = {
31
31
  /** Multi-project configuration (used when mode is testops_multi). */
32
32
  testops_multi?: TestOpsMultiConfigType | undefined;
33
33
  report?: RecursivePartial<AdditionalReportOptionsType> | undefined;
34
+ profilers?: string[] | undefined;
35
+ networkProfiler?: {
36
+ skip_domains?: string[] | undefined;
37
+ track_on_fail?: boolean | undefined;
38
+ } | undefined;
34
39
  };
35
40
  export type FrameworkOptionsType<F extends string, O> = {
36
41
  framework?: Partial<Record<F, O>>;
@@ -0,0 +1,5 @@
1
+ export declare abstract class AbstractProfiler {
2
+ abstract enable(): void;
3
+ abstract disable(): void;
4
+ abstract restore(): void;
5
+ }
@@ -0,0 +1,6 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AbstractProfiler = void 0;
4
+ class AbstractProfiler {
5
+ }
6
+ exports.AbstractProfiler = AbstractProfiler;
@@ -0,0 +1,60 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { TestStepType } from '../models/test-step';
3
+ interface ProfilerRef {
4
+ shouldSkip(url: string): boolean;
5
+ trackOnFail: boolean;
6
+ fallbackSteps: TestStepType[];
7
+ }
8
+ /**
9
+ * FetchInterceptor subscribes to undici diagnostics_channel events to capture
10
+ * outgoing fetch() calls as REQUEST-type steps in the AsyncLocalStorage store.
11
+ *
12
+ * Works with Node.js 18+ global fetch (which uses undici internally).
13
+ * Gracefully skips subscription on Node < 18 (no global fetch).
14
+ *
15
+ * Key design decisions:
16
+ * - WeakMap keyed on undici request objects (same reference across all events)
17
+ * - ALS context captured at undici:request:create time, NOT at headers time.
18
+ * This is critical because undici connection pooling can break AsyncLocalStorage
19
+ * propagation between the create and headers events when connections are reused.
20
+ * - globalThis.fetch wrapping intercepts error response bodies (undici:request:bodyChunkReceived
21
+ * is not available in Node 22's built-in undici)
22
+ * - All handlers wrapped in try/catch (INTC-06: silent failure)
23
+ *
24
+ * Event flow:
25
+ * undici:request:create → capture method, URL, start time, ALS context → WeakMap
26
+ * undici:request:headers → build step using WeakMap data, push to ALS accumulator
27
+ * undici:request:trailers → cleanup WeakMap entry
28
+ * fetch wrapper → update response_body for error responses (>= 400)
29
+ */
30
+ export declare class FetchInterceptor {
31
+ private readonly store;
32
+ private readonly profiler;
33
+ private readonly pendingRequests;
34
+ private readonly bodyHandlers;
35
+ private readonly createHandler;
36
+ private readonly headersHandler;
37
+ private readonly trailersHandler;
38
+ private origFetch;
39
+ constructor(store: AsyncLocalStorage<TestStepType[]>, profiler: ProfilerRef);
40
+ /**
41
+ * Subscribe to undici diagnostics_channel events and wrap global fetch for
42
+ * error response body capture.
43
+ *
44
+ * No-op on Node < 18 (no global fetch) or if diagnostics_channel unavailable.
45
+ */
46
+ subscribe(): void;
47
+ /**
48
+ * Unsubscribe from diagnostics_channel events and restore original global fetch.
49
+ */
50
+ unsubscribe(): void;
51
+ private handleCreate;
52
+ private handleHeaders;
53
+ private handleTrailers;
54
+ /**
55
+ * Called by the fetch wrapper when an error response body is available.
56
+ * Finds the most recently added step for this URL/status and updates its response_body.
57
+ */
58
+ private applyResponseBody;
59
+ }
60
+ export {};
@@ -0,0 +1,277 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FetchInterceptor = void 0;
4
+ const http_interceptor_1 = require("./http-interceptor");
5
+ // ─── FetchInterceptor ─────────────────────────────────────────────────────────
6
+ /**
7
+ * FetchInterceptor subscribes to undici diagnostics_channel events to capture
8
+ * outgoing fetch() calls as REQUEST-type steps in the AsyncLocalStorage store.
9
+ *
10
+ * Works with Node.js 18+ global fetch (which uses undici internally).
11
+ * Gracefully skips subscription on Node < 18 (no global fetch).
12
+ *
13
+ * Key design decisions:
14
+ * - WeakMap keyed on undici request objects (same reference across all events)
15
+ * - ALS context captured at undici:request:create time, NOT at headers time.
16
+ * This is critical because undici connection pooling can break AsyncLocalStorage
17
+ * propagation between the create and headers events when connections are reused.
18
+ * - globalThis.fetch wrapping intercepts error response bodies (undici:request:bodyChunkReceived
19
+ * is not available in Node 22's built-in undici)
20
+ * - All handlers wrapped in try/catch (INTC-06: silent failure)
21
+ *
22
+ * Event flow:
23
+ * undici:request:create → capture method, URL, start time, ALS context → WeakMap
24
+ * undici:request:headers → build step using WeakMap data, push to ALS accumulator
25
+ * undici:request:trailers → cleanup WeakMap entry
26
+ * fetch wrapper → update response_body for error responses (>= 400)
27
+ */
28
+ class FetchInterceptor {
29
+ store;
30
+ profiler;
31
+ // WeakMap keyed on undici request objects (same reference across create/headers/trailers events)
32
+ pendingRequests = new WeakMap();
33
+ // Map from step to body-update callback (for error response body capture)
34
+ bodyHandlers = new WeakMap();
35
+ // Bound handler references (needed for subscribe/unsubscribe identity)
36
+ createHandler;
37
+ headersHandler;
38
+ trailersHandler;
39
+ // Saved original fetch for wrapping/unwrapping
40
+ origFetch = null;
41
+ constructor(store, profiler) {
42
+ this.store = store;
43
+ this.profiler = profiler;
44
+ // Bind handlers as arrow functions for subscribe/unsubscribe reference identity
45
+ this.createHandler = (msg) => {
46
+ try {
47
+ this.handleCreate(msg);
48
+ }
49
+ catch {
50
+ // INTC-06: silent failure
51
+ }
52
+ };
53
+ this.headersHandler = (msg) => {
54
+ try {
55
+ this.handleHeaders(msg);
56
+ }
57
+ catch {
58
+ // INTC-06: silent failure
59
+ }
60
+ };
61
+ this.trailersHandler = (msg) => {
62
+ try {
63
+ this.handleTrailers(msg);
64
+ }
65
+ catch {
66
+ // INTC-06: silent failure
67
+ }
68
+ };
69
+ }
70
+ /**
71
+ * Subscribe to undici diagnostics_channel events and wrap global fetch for
72
+ * error response body capture.
73
+ *
74
+ * No-op on Node < 18 (no global fetch) or if diagnostics_channel unavailable.
75
+ */
76
+ subscribe() {
77
+ // Guard: require global fetch (Node 18+)
78
+ if (typeof globalThis.fetch !== 'function') {
79
+ return;
80
+ }
81
+ let dc;
82
+ try {
83
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
84
+ dc = require('node:diagnostics_channel');
85
+ }
86
+ catch {
87
+ return; // diagnostics_channel unavailable — skip entirely
88
+ }
89
+ if (typeof dc.subscribe !== 'function') {
90
+ return;
91
+ }
92
+ dc.subscribe('undici:request:create', this.createHandler);
93
+ dc.subscribe('undici:request:headers', this.headersHandler);
94
+ dc.subscribe('undici:request:trailers', this.trailersHandler);
95
+ // Wrap global fetch to intercept error response bodies
96
+ // (undici:request:bodyChunkReceived not available in Node 22 built-in undici)
97
+ this.origFetch = globalThis.fetch;
98
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
99
+ const self = this;
100
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
101
+ globalThis.fetch = async function wrappedFetch(input, init) {
102
+ const origFetch = self.origFetch;
103
+ if (!origFetch) {
104
+ throw new Error('fetch not available');
105
+ }
106
+ const response = await origFetch.call(globalThis, input, init);
107
+ // Intercept error responses to capture body
108
+ if (response.status >= 400 && self.profiler.trackOnFail) {
109
+ try {
110
+ // Clone to read body without consuming the original response
111
+ const cloned = response.clone();
112
+ void cloned.text().then((bodyText) => {
113
+ try {
114
+ self.applyResponseBody(input, response.status, bodyText);
115
+ }
116
+ catch {
117
+ // INTC-06: silent failure
118
+ }
119
+ });
120
+ }
121
+ catch {
122
+ // INTC-06: silent failure
123
+ }
124
+ }
125
+ return response;
126
+ };
127
+ }
128
+ /**
129
+ * Unsubscribe from diagnostics_channel events and restore original global fetch.
130
+ */
131
+ unsubscribe() {
132
+ // Restore original fetch
133
+ if (this.origFetch !== null) {
134
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
135
+ globalThis.fetch = this.origFetch;
136
+ this.origFetch = null;
137
+ }
138
+ let dc;
139
+ try {
140
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
141
+ dc = require('node:diagnostics_channel');
142
+ }
143
+ catch {
144
+ return;
145
+ }
146
+ // Feature-detect unsubscribe (Pitfall 5: not available in all Node versions)
147
+ if (typeof dc.unsubscribe !== 'function') {
148
+ return;
149
+ }
150
+ try {
151
+ dc.unsubscribe('undici:request:create', this.createHandler);
152
+ }
153
+ catch {
154
+ // silent
155
+ }
156
+ try {
157
+ dc.unsubscribe('undici:request:headers', this.headersHandler);
158
+ }
159
+ catch {
160
+ // silent
161
+ }
162
+ try {
163
+ dc.unsubscribe('undici:request:trailers', this.trailersHandler);
164
+ }
165
+ catch {
166
+ // silent
167
+ }
168
+ }
169
+ // ─── Private event handlers ─────────────────────────────────────────────────
170
+ handleCreate(msg) {
171
+ const message = msg;
172
+ const req = message.request;
173
+ if (!req)
174
+ return;
175
+ // CRITICAL: Capture ALS context HERE (at create time), not in headersHandler.
176
+ // Due to undici connection pooling, store.getStore() may return undefined in
177
+ // the headers event when connections are reused across test runs.
178
+ const acc = this.store.getStore();
179
+ // acc may be null/undefined when outside a run() context — use fallback accumulator in that case
180
+ const method = typeof req['method'] === 'string' ? req['method'] : 'GET';
181
+ const origin = typeof req['origin'] === 'string' ? req['origin'] : '';
182
+ const path = typeof req['path'] === 'string' ? req['path'] : '/';
183
+ const url = `${origin}${path}`;
184
+ // Check shouldSkip
185
+ if (this.profiler.shouldSkip(url)) {
186
+ return;
187
+ }
188
+ this.pendingRequests.set(req, {
189
+ method,
190
+ url,
191
+ startTime: Date.now(),
192
+ alsContext: acc ?? null,
193
+ });
194
+ }
195
+ handleHeaders(msg) {
196
+ const message = msg;
197
+ const req = message.request;
198
+ if (!req)
199
+ return;
200
+ const pending = this.pendingRequests.get(req);
201
+ if (!pending)
202
+ return;
203
+ // Use the ALS context captured at create time — do NOT call store.getStore() here.
204
+ // Connection pooling in undici can break ALS propagation between events.
205
+ const acc = pending.alsContext;
206
+ const endTime = Date.now();
207
+ const statusCode = message.response?.statusCode ?? 0;
208
+ const step = (0, http_interceptor_1.buildRequestStep)({
209
+ method: pending.method,
210
+ url: pending.url,
211
+ statusCode,
212
+ responseBody: null, // filled in later for errors via fetch wrapper
213
+ responseHeaders: null,
214
+ startTime: pending.startTime,
215
+ endTime,
216
+ });
217
+ if (acc !== null) {
218
+ acc.push(step);
219
+ }
220
+ else {
221
+ // No ALS context — push to fallback accumulator (for Jest/Vitest/WDIO workers)
222
+ this.profiler.fallbackSteps.push(step);
223
+ }
224
+ // Register body handler for error responses
225
+ if (statusCode >= 400 && this.profiler.trackOnFail) {
226
+ this.bodyHandlers.set(step, (bodyText) => {
227
+ step.data.response_body = bodyText;
228
+ });
229
+ }
230
+ }
231
+ handleTrailers(msg) {
232
+ const message = msg;
233
+ const req = message.request;
234
+ if (!req)
235
+ return;
236
+ // Clean up pending state
237
+ this.pendingRequests.delete(req);
238
+ }
239
+ /**
240
+ * Called by the fetch wrapper when an error response body is available.
241
+ * Finds the most recently added step for this URL/status and updates its response_body.
242
+ */
243
+ applyResponseBody(input, statusCode, bodyText) {
244
+ const url = typeof input === 'string'
245
+ ? input
246
+ : input instanceof URL
247
+ ? input.toString()
248
+ : input.url;
249
+ // Get all accumulated steps — use ALS context if available, fallback accumulator otherwise
250
+ const searchArray = this.store.getStore() ?? this.profiler.fallbackSteps;
251
+ if (searchArray.length === 0)
252
+ return;
253
+ // Find the last step that matches this response and has null body
254
+ for (let i = searchArray.length - 1; i >= 0; i--) {
255
+ const step = searchArray[i];
256
+ if (!step)
257
+ continue;
258
+ const data = step.data;
259
+ if (data.status_code !== statusCode)
260
+ continue;
261
+ if (data.response_body !== null)
262
+ continue;
263
+ // Check if URL matches (handle query strings, trailing slashes)
264
+ const stepUrl = data.request_url ?? '';
265
+ const inputUrlBase = url.split('?')[0] ?? url;
266
+ if (url.includes(stepUrl) || stepUrl.includes(inputUrlBase) || stepUrl === inputUrlBase) {
267
+ const handler = this.bodyHandlers.get(step);
268
+ if (handler) {
269
+ handler(bodyText);
270
+ this.bodyHandlers.delete(step);
271
+ return;
272
+ }
273
+ }
274
+ }
275
+ }
276
+ }
277
+ exports.FetchInterceptor = FetchInterceptor;
@@ -0,0 +1,69 @@
1
+ import http from 'node:http';
2
+ import { AsyncLocalStorage } from 'node:async_hooks';
3
+ import { TestStepType } from '../models/test-step';
4
+ interface ProfilerRef {
5
+ shouldSkip(url: string): boolean;
6
+ trackOnFail: boolean;
7
+ fallbackSteps: TestStepType[];
8
+ }
9
+ export interface RequestInfo {
10
+ method: string;
11
+ url: string;
12
+ }
13
+ /**
14
+ * Handles the 3 `http.request()` call signatures:
15
+ * - (url: string, options?, callback?)
16
+ * - (url: URL, options?, callback?)
17
+ * - (options: RequestOptions, callback?)
18
+ *
19
+ * Returns `{ method, url }`. Default method is 'GET'.
20
+ */
21
+ export declare function extractRequestInfo(args: [
22
+ string | URL | http.RequestOptions,
23
+ (http.RequestOptions | ((res: http.IncomingMessage) => void))?,
24
+ ((res: http.IncomingMessage) => void)?
25
+ ]): RequestInfo;
26
+ /**
27
+ * Converts `http.IncomingHttpHeaders` (which has `string | string[] | undefined` values)
28
+ * to `Record<string, string>`. Multi-value headers joined with `', '`. Undefined values skipped.
29
+ */
30
+ export declare function headersToRecord(headers: http.IncomingHttpHeaders): Record<string, string>;
31
+ export declare function buildRequestStep(params: {
32
+ method: string;
33
+ url: string;
34
+ statusCode: number;
35
+ responseBody: string | null;
36
+ responseHeaders: Record<string, string> | null;
37
+ startTime: number;
38
+ endTime: number;
39
+ }): TestStepType;
40
+ /**
41
+ * HttpInterceptor monkey-patches Node.js http/https module functions to capture
42
+ * outgoing requests as REQUEST-type steps in the AsyncLocalStorage store.
43
+ *
44
+ * Pattern:
45
+ * 1. `install()` — saves originals, replaces with wrappers
46
+ * 2. `uninstall()` — restores originals
47
+ */
48
+ export declare class HttpInterceptor {
49
+ private readonly store;
50
+ private readonly profiler;
51
+ private origHttpRequest;
52
+ private origHttpGet;
53
+ private origHttpsRequest;
54
+ private origHttpsGet;
55
+ constructor(store: AsyncLocalStorage<TestStepType[]>, profiler: ProfilerRef);
56
+ install(): void;
57
+ uninstall(): void;
58
+ /**
59
+ * Creates a wrapper around a http.request-like function.
60
+ * The wrapper:
61
+ * 1. Calls the original function to get the ClientRequest
62
+ * 2. Checks if there's an active ALS context — if not, returns unchanged
63
+ * 3. Checks shouldSkip — if true, returns unchanged
64
+ * 4. Intercepts the 'response' event via req.emit override
65
+ * 5. Builds a TestStepType on response end and pushes to accumulator
66
+ */
67
+ private wrapRequestFn;
68
+ }
69
+ export {};
@@ -0,0 +1,245 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.HttpInterceptor = void 0;
7
+ exports.extractRequestInfo = extractRequestInfo;
8
+ exports.headersToRecord = headersToRecord;
9
+ exports.buildRequestStep = buildRequestStep;
10
+ const node_http_1 = __importDefault(require("node:http"));
11
+ const node_https_1 = __importDefault(require("node:https"));
12
+ const uuid_1 = require("uuid");
13
+ const test_step_1 = require("../models/test-step");
14
+ const step_execution_1 = require("../models/step-execution");
15
+ /**
16
+ * Handles the 3 `http.request()` call signatures:
17
+ * - (url: string, options?, callback?)
18
+ * - (url: URL, options?, callback?)
19
+ * - (options: RequestOptions, callback?)
20
+ *
21
+ * Returns `{ method, url }`. Default method is 'GET'.
22
+ */
23
+ function extractRequestInfo(args) {
24
+ const first = args[0];
25
+ if (typeof first === 'string') {
26
+ // Signature: (url: string, options?, callback?)
27
+ const options = args[1];
28
+ const method = options && typeof options === 'object' && !('emit' in options) && 'method' in options && typeof options.method === 'string'
29
+ ? options.method
30
+ : 'GET';
31
+ return { method, url: first };
32
+ }
33
+ if (first instanceof URL) {
34
+ // Signature: (url: URL, options?, callback?)
35
+ const options = args[1];
36
+ const method = options && typeof options === 'object' && !('emit' in options) && 'method' in options && typeof options.method === 'string'
37
+ ? options.method
38
+ : 'GET';
39
+ return { method, url: first.toString() };
40
+ }
41
+ // Signature: (options: RequestOptions, callback?)
42
+ const opts = first;
43
+ const method = opts.method ?? 'GET';
44
+ const protocol = opts.protocol ?? 'http:';
45
+ const hostname = opts.hostname ?? opts.host ?? 'localhost';
46
+ const port = opts.port !== undefined ? `:${String(opts.port)}` : '';
47
+ const path = opts.path ?? '/';
48
+ const url = `${protocol}//${hostname}${port}${path}`;
49
+ return { method, url };
50
+ }
51
+ // ─── Utility: headersToRecord ─────────────────────────────────────────────────
52
+ /**
53
+ * Converts `http.IncomingHttpHeaders` (which has `string | string[] | undefined` values)
54
+ * to `Record<string, string>`. Multi-value headers joined with `', '`. Undefined values skipped.
55
+ */
56
+ function headersToRecord(headers) {
57
+ const result = {};
58
+ for (const [key, value] of Object.entries(headers)) {
59
+ if (value === undefined)
60
+ continue;
61
+ if (Array.isArray(value)) {
62
+ result[key] = value.join(', ');
63
+ }
64
+ else {
65
+ result[key] = value;
66
+ }
67
+ }
68
+ return result;
69
+ }
70
+ // ─── Utility: buildRequestStep ───────────────────────────────────────────────
71
+ function buildRequestStep(params) {
72
+ const step = new test_step_1.TestStepType(test_step_1.StepType.REQUEST);
73
+ step.id = (0, uuid_1.v4)();
74
+ const data = step.data;
75
+ data.request_method = params.method;
76
+ data.request_url = params.url;
77
+ data.request_headers = null;
78
+ data.request_body = null;
79
+ data.status_code = params.statusCode;
80
+ data.response_body = params.responseBody;
81
+ data.response_headers = params.responseHeaders;
82
+ step.execution.start_time = params.startTime;
83
+ step.execution.end_time = params.endTime;
84
+ step.execution.duration = params.endTime - params.startTime;
85
+ step.execution.status = params.statusCode >= 400 ? step_execution_1.StepStatusEnum.failed : step_execution_1.StepStatusEnum.passed;
86
+ return step;
87
+ }
88
+ // ─── HttpInterceptor ─────────────────────────────────────────────────────────
89
+ /**
90
+ * HttpInterceptor monkey-patches Node.js http/https module functions to capture
91
+ * outgoing requests as REQUEST-type steps in the AsyncLocalStorage store.
92
+ *
93
+ * Pattern:
94
+ * 1. `install()` — saves originals, replaces with wrappers
95
+ * 2. `uninstall()` — restores originals
96
+ */
97
+ class HttpInterceptor {
98
+ store;
99
+ profiler;
100
+ origHttpRequest = null;
101
+ origHttpGet = null;
102
+ origHttpsRequest = null;
103
+ origHttpsGet = null;
104
+ constructor(store, profiler) {
105
+ this.store = store;
106
+ this.profiler = profiler;
107
+ }
108
+ install() {
109
+ this.origHttpRequest = node_http_1.default.request;
110
+ this.origHttpGet = node_http_1.default.get;
111
+ this.origHttpsRequest = node_https_1.default.request;
112
+ this.origHttpsGet = node_https_1.default.get;
113
+ const wrappedHttpRequest = this.wrapRequestFn(this.origHttpRequest);
114
+ const wrappedHttpGet = this.wrapRequestFn(this.origHttpGet);
115
+ const wrappedHttpsRequest = this.wrapRequestFn(this.origHttpsRequest);
116
+ const wrappedHttpsGet = this.wrapRequestFn(this.origHttpsGet);
117
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
118
+ node_http_1.default.request = wrappedHttpRequest;
119
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
120
+ node_http_1.default.get = wrappedHttpGet;
121
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
122
+ node_https_1.default.request = wrappedHttpsRequest;
123
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
124
+ node_https_1.default.get = wrappedHttpsGet;
125
+ }
126
+ uninstall() {
127
+ if (this.origHttpRequest !== null) {
128
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
129
+ node_http_1.default.request = this.origHttpRequest;
130
+ this.origHttpRequest = null;
131
+ }
132
+ if (this.origHttpGet !== null) {
133
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
134
+ node_http_1.default.get = this.origHttpGet;
135
+ this.origHttpGet = null;
136
+ }
137
+ if (this.origHttpsRequest !== null) {
138
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
139
+ node_https_1.default.request = this.origHttpsRequest;
140
+ this.origHttpsRequest = null;
141
+ }
142
+ if (this.origHttpsGet !== null) {
143
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
144
+ node_https_1.default.get = this.origHttpsGet;
145
+ this.origHttpsGet = null;
146
+ }
147
+ }
148
+ /**
149
+ * Creates a wrapper around a http.request-like function.
150
+ * The wrapper:
151
+ * 1. Calls the original function to get the ClientRequest
152
+ * 2. Checks if there's an active ALS context — if not, returns unchanged
153
+ * 3. Checks shouldSkip — if true, returns unchanged
154
+ * 4. Intercepts the 'response' event via req.emit override
155
+ * 5. Builds a TestStepType on response end and pushes to accumulator
156
+ */
157
+ wrapRequestFn(
158
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
159
+ origFn) {
160
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
161
+ const self = this;
162
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
163
+ return function (...args) {
164
+ // Call original first to get the ClientRequest
165
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
166
+ const req = origFn.apply(this, args);
167
+ // Extract request info
168
+ let reqInfo;
169
+ try {
170
+ reqInfo = extractRequestInfo(args);
171
+ }
172
+ catch {
173
+ return req;
174
+ }
175
+ // Check if this domain should be skipped
176
+ if (self.profiler.shouldSkip(reqInfo.url)) {
177
+ return req;
178
+ }
179
+ const startTime = Date.now();
180
+ const origEmit = req.emit.bind(req);
181
+ // Override req.emit to intercept the 'response' event
182
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
183
+ req.emit = function (event, ...emitArgs) {
184
+ if (event === 'response') {
185
+ const res = emitArgs[0];
186
+ try {
187
+ const chunks = [];
188
+ const isError = (res.statusCode ?? 0) >= 400;
189
+ // Register data listener BEFORE calling original emit (Pitfall 2: stream consumption race)
190
+ if (isError && self.profiler.trackOnFail) {
191
+ res.on('data', (chunk) => {
192
+ try {
193
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
194
+ }
195
+ catch {
196
+ // silent failure
197
+ }
198
+ });
199
+ }
200
+ res.on('end', () => {
201
+ try {
202
+ const endTime = Date.now();
203
+ const statusCode = res.statusCode ?? 0;
204
+ const captureBody = statusCode >= 400 && self.profiler.trackOnFail;
205
+ const responseBody = captureBody ? Buffer.concat(chunks).toString('utf8') : null;
206
+ const responseHeaders = statusCode >= 400 ? headersToRecord(res.headers) : null;
207
+ const step = buildRequestStep({
208
+ method: reqInfo.method,
209
+ url: reqInfo.url,
210
+ statusCode,
211
+ responseBody,
212
+ responseHeaders,
213
+ startTime,
214
+ endTime,
215
+ });
216
+ // Push to accumulator — re-fetch in case context is still active
217
+ const acc = self.store.getStore();
218
+ if (acc !== undefined && acc !== null) {
219
+ acc.push(step);
220
+ }
221
+ else {
222
+ // No ALS context — push to fallback accumulator (for Jest/Vitest/WDIO workers)
223
+ self.profiler.fallbackSteps.push(step);
224
+ }
225
+ }
226
+ catch {
227
+ // INTC-06: silent failure — do not propagate to test
228
+ }
229
+ });
230
+ }
231
+ catch {
232
+ // INTC-06: silent failure in setup — do not propagate
233
+ }
234
+ // CRITICAL: Call original emit LAST (after registering data listeners)
235
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
236
+ return origEmit(event, ...emitArgs);
237
+ }
238
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
239
+ return origEmit(event, ...emitArgs);
240
+ };
241
+ return req;
242
+ };
243
+ }
244
+ }
245
+ exports.HttpInterceptor = HttpInterceptor;
@@ -0,0 +1,5 @@
1
+ export { AbstractProfiler } from './abstract-profiler';
2
+ export { NetworkProfiler } from './network-profiler';
3
+ export type { NetworkProfilerOptions } from './network-profiler';
4
+ export { HttpInterceptor } from './http-interceptor';
5
+ export { FetchInterceptor } from './fetch-interceptor';
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FetchInterceptor = exports.HttpInterceptor = exports.NetworkProfiler = exports.AbstractProfiler = void 0;
4
+ var abstract_profiler_1 = require("./abstract-profiler");
5
+ Object.defineProperty(exports, "AbstractProfiler", { enumerable: true, get: function () { return abstract_profiler_1.AbstractProfiler; } });
6
+ var network_profiler_1 = require("./network-profiler");
7
+ Object.defineProperty(exports, "NetworkProfiler", { enumerable: true, get: function () { return network_profiler_1.NetworkProfiler; } });
8
+ var http_interceptor_1 = require("./http-interceptor");
9
+ Object.defineProperty(exports, "HttpInterceptor", { enumerable: true, get: function () { return http_interceptor_1.HttpInterceptor; } });
10
+ var fetch_interceptor_1 = require("./fetch-interceptor");
11
+ Object.defineProperty(exports, "FetchInterceptor", { enumerable: true, get: function () { return fetch_interceptor_1.FetchInterceptor; } });
@@ -0,0 +1,61 @@
1
+ import { AbstractProfiler } from './abstract-profiler';
2
+ import { TestStepType } from '../models';
3
+ export interface NetworkProfilerOptions {
4
+ skipDomains?: string[] | undefined;
5
+ trackOnFail?: boolean | undefined;
6
+ }
7
+ export declare class NetworkProfiler extends AbstractProfiler {
8
+ private readonly skipDomains;
9
+ private readonly _trackOnFail;
10
+ /**
11
+ * Fallback accumulator for steps captured outside any run() context.
12
+ * Used by frameworks where test bodies cannot be wrapped in run() (Jest, Vitest, WDIO).
13
+ * Public so interceptors can push to it via ProfilerRef.
14
+ */
15
+ readonly fallbackSteps: TestStepType[];
16
+ private httpInterceptor;
17
+ private fetchInterceptor;
18
+ constructor(options?: NetworkProfilerOptions);
19
+ get trackOnFail(): boolean;
20
+ shouldSkip(url: string): boolean;
21
+ /**
22
+ * Install HTTP/HTTPS monkey-patches and subscribe to undici diagnostics_channel
23
+ * so that outgoing requests inside a `run()` context are captured as REQUEST steps.
24
+ */
25
+ enable(): void;
26
+ /**
27
+ * No-op per v1.3 design decision (flag-based activation model).
28
+ * Satisfies the AbstractProfiler contract.
29
+ */
30
+ disable(): void;
31
+ /**
32
+ * Uninstall HTTP/HTTPS monkey-patches and unsubscribe from undici diagnostics_channel —
33
+ * permanently removes interception.
34
+ */
35
+ restore(): void;
36
+ /**
37
+ * Wraps `fn` in an AsyncLocalStorage context so that any HTTP requests
38
+ * made inside `fn` are attributed to the step accumulator for this call.
39
+ *
40
+ * Returns `{ result, steps }` where `result` is the return value of `fn`
41
+ * and `steps` is the array of REQUEST steps captured during execution.
42
+ */
43
+ run<T>(fn: () => Promise<T>): Promise<{
44
+ result: T;
45
+ steps: TestStepType[];
46
+ }>;
47
+ /**
48
+ * Returns the current ALS store when called inside a `run()` context.
49
+ * Returns an empty array when called outside (no active context).
50
+ */
51
+ getSteps(): TestStepType[];
52
+ /**
53
+ * Returns a copy of steps accumulated outside any run() context (fallback accumulator).
54
+ * Used by frameworks where test bodies cannot be wrapped in run() (Jest, Vitest, WDIO).
55
+ */
56
+ getAllSteps(): TestStepType[];
57
+ /**
58
+ * Clears the fallback step accumulator. Call after collecting steps per test.
59
+ */
60
+ clearSteps(): void;
61
+ }
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NetworkProfiler = void 0;
4
+ const node_async_hooks_1 = require("node:async_hooks");
5
+ const abstract_profiler_1 = require("./abstract-profiler");
6
+ const http_interceptor_1 = require("./http-interceptor");
7
+ const fetch_interceptor_1 = require("./fetch-interceptor");
8
+ const QASE_API_HOST = 'qase.io';
9
+ // Module-level ALS store — shared across all NetworkProfiler instances to
10
+ // allow nested run() calls to see the same context.
11
+ const store = new node_async_hooks_1.AsyncLocalStorage();
12
+ class NetworkProfiler extends abstract_profiler_1.AbstractProfiler {
13
+ skipDomains;
14
+ // Stored for Phase 13 use (controls whether to capture network steps on test failure)
15
+ _trackOnFail;
16
+ /**
17
+ * Fallback accumulator for steps captured outside any run() context.
18
+ * Used by frameworks where test bodies cannot be wrapped in run() (Jest, Vitest, WDIO).
19
+ * Public so interceptors can push to it via ProfilerRef.
20
+ */
21
+ fallbackSteps = [];
22
+ httpInterceptor = null;
23
+ fetchInterceptor = null;
24
+ constructor(options = {}) {
25
+ super();
26
+ this.skipDomains = options.skipDomains ?? [];
27
+ this._trackOnFail = options.trackOnFail ?? true;
28
+ }
29
+ get trackOnFail() {
30
+ return this._trackOnFail;
31
+ }
32
+ shouldSkip(url) {
33
+ if (url.includes(QASE_API_HOST)) {
34
+ return true;
35
+ }
36
+ return this.skipDomains.some((domain) => url.includes(domain));
37
+ }
38
+ /**
39
+ * Install HTTP/HTTPS monkey-patches and subscribe to undici diagnostics_channel
40
+ * so that outgoing requests inside a `run()` context are captured as REQUEST steps.
41
+ */
42
+ enable() {
43
+ this.httpInterceptor = new http_interceptor_1.HttpInterceptor(store, this);
44
+ this.httpInterceptor.install();
45
+ this.fetchInterceptor = new fetch_interceptor_1.FetchInterceptor(store, this);
46
+ this.fetchInterceptor.subscribe();
47
+ }
48
+ /**
49
+ * No-op per v1.3 design decision (flag-based activation model).
50
+ * Satisfies the AbstractProfiler contract.
51
+ */
52
+ disable() {
53
+ // Intentional no-op
54
+ }
55
+ /**
56
+ * Uninstall HTTP/HTTPS monkey-patches and unsubscribe from undici diagnostics_channel —
57
+ * permanently removes interception.
58
+ */
59
+ restore() {
60
+ this.fetchInterceptor?.unsubscribe();
61
+ this.fetchInterceptor = null;
62
+ this.httpInterceptor?.uninstall();
63
+ this.httpInterceptor = null;
64
+ }
65
+ /**
66
+ * Wraps `fn` in an AsyncLocalStorage context so that any HTTP requests
67
+ * made inside `fn` are attributed to the step accumulator for this call.
68
+ *
69
+ * Returns `{ result, steps }` where `result` is the return value of `fn`
70
+ * and `steps` is the array of REQUEST steps captured during execution.
71
+ */
72
+ async run(fn) {
73
+ const accumulator = [];
74
+ const result = await store.run(accumulator, fn);
75
+ return { result, steps: accumulator };
76
+ }
77
+ /**
78
+ * Returns the current ALS store when called inside a `run()` context.
79
+ * Returns an empty array when called outside (no active context).
80
+ */
81
+ getSteps() {
82
+ return store.getStore() ?? [];
83
+ }
84
+ /**
85
+ * Returns a copy of steps accumulated outside any run() context (fallback accumulator).
86
+ * Used by frameworks where test bodies cannot be wrapped in run() (Jest, Vitest, WDIO).
87
+ */
88
+ getAllSteps() {
89
+ return [...this.fallbackSteps];
90
+ }
91
+ /**
92
+ * Clears the fallback step accumulator. Call after collecting steps per test.
93
+ */
94
+ clearSteps() {
95
+ this.fallbackSteps.length = 0;
96
+ }
97
+ }
98
+ exports.NetworkProfiler = NetworkProfiler;
@@ -279,6 +279,10 @@ class ReportReporter extends abstract_reporter_1.AbstractReporter {
279
279
  input_data: null, // JS GherkinData has no data field
280
280
  };
281
281
  }
282
+ // For request steps, pass raw fields through (all 7 StepRequestData fields are preserved)
283
+ if (step.step_type === models_1.StepType.REQUEST && 'request_method' in data) {
284
+ return data;
285
+ }
282
286
  return data;
283
287
  }
284
288
  }
package/package.json CHANGED
@@ -1,9 +1,25 @@
1
1
  {
2
2
  "name": "qase-javascript-commons",
3
- "version": "2.5.5",
3
+ "version": "2.5.7",
4
4
  "description": "Qase JS Reporters",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ },
12
+ "./profilers": {
13
+ "types": "./dist/profilers/index.d.ts",
14
+ "default": "./dist/profilers/index.js"
15
+ },
16
+ "./package.json": "./package.json"
17
+ },
18
+ "typesVersions": {
19
+ "*": {
20
+ "profilers": ["./dist/profilers/index.d.ts"]
21
+ }
22
+ },
7
23
  "homepage": "https://github.com/qase-tms/qase-javascript",
8
24
  "bugs": {
9
25
  "url": "https://github.com/qase-tms/qase-javascript/issues"
@@ -46,7 +62,6 @@
46
62
  "@types/lodash.mergewith": "^4.6.9",
47
63
  "@types/mime-types": "^2.1.4",
48
64
  "@types/minimatch": "^6.0.0",
49
- "@types/node": "^20.19.25",
50
65
  "@types/uuid": "^9.0.8",
51
66
  "axios": "^1.13.5",
52
67
  "jest": "^29.7.0",