qase-javascript-commons 2.5.4 → 2.5.6
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 +22 -0
- package/dist/client/clientV2.d.ts +1 -0
- package/dist/client/clientV2.js +11 -1
- package/dist/config/config-validation-schema.d.ts +24 -0
- package/dist/config/config-validation-schema.js +22 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/models/index.d.ts +1 -0
- package/dist/models/step-data.d.ts +9 -0
- package/dist/models/test-step.d.ts +4 -3
- package/dist/models/test-step.js +13 -1
- package/dist/options/options-type.d.ts +5 -0
- package/dist/profilers/abstract-profiler.d.ts +5 -0
- package/dist/profilers/abstract-profiler.js +6 -0
- package/dist/profilers/fetch-interceptor.d.ts +60 -0
- package/dist/profilers/fetch-interceptor.js +277 -0
- package/dist/profilers/http-interceptor.d.ts +69 -0
- package/dist/profilers/http-interceptor.js +245 -0
- package/dist/profilers/index.d.ts +5 -0
- package/dist/profilers/index.js +11 -0
- package/dist/profilers/network-profiler.d.ts +61 -0
- package/dist/profilers/network-profiler.js +98 -0
- package/dist/qase.js +2 -8
- package/dist/reporters/report-reporter.d.ts +4 -1
- package/dist/reporters/report-reporter.js +9 -2
- package/dist/utils/hostData.d.ts +0 -6
- package/dist/utils/hostData.js +19 -107
- package/package.json +1 -1
package/changelog.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
# qase-javascript-commons@2.5.6
|
|
2
|
+
|
|
3
|
+
## What's new
|
|
4
|
+
|
|
5
|
+
- Added Network Profiler framework for automatic HTTP/HTTPS request interception during test execution.
|
|
6
|
+
- Added `StepType.REQUEST` step type for network request data in test results.
|
|
7
|
+
- Added configurable profiler options in `qase.config.json` (`profilers` and `networkProfiler` fields).
|
|
8
|
+
|
|
9
|
+
# qase-javascript-commons@2.5.5
|
|
10
|
+
|
|
11
|
+
## What's new
|
|
12
|
+
|
|
13
|
+
Significantly improved startup performance by optimizing host data collection:
|
|
14
|
+
|
|
15
|
+
- Replaced slow `npm list --depth=10 --json` fallback with fast `require.resolve()`-based package version lookup.
|
|
16
|
+
- Replaced `execSync('node --version')` with `process.version`.
|
|
17
|
+
- Replaced `execSync('npm --version')` with `process.env.npm_config_user_agent` parsing (with execSync fallback).
|
|
18
|
+
- Eliminated duplicate `getHostInfo()` call in `ReportReporter.complete()` by passing pre-collected host data from `QaseReporter`.
|
|
19
|
+
- Included `report` mode in the `needsHostData` guard so host data is collected once during init.
|
|
20
|
+
|
|
21
|
+
Worst-case startup time reduced from ~10-25 seconds to ~55 ms.
|
|
22
|
+
|
|
1
23
|
# qase-javascript-commons@2.5.4
|
|
2
24
|
|
|
3
25
|
## What's new
|
package/dist/client/clientV2.js
CHANGED
|
@@ -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
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export * from './models';
|
|
|
6
6
|
export * from './options';
|
|
7
7
|
export * from './reporters';
|
|
8
8
|
export * from './writer';
|
|
9
|
+
export * from './profilers';
|
|
9
10
|
export * from './utils/get-package-version';
|
|
10
11
|
export * from './utils/mimeTypes';
|
|
11
12
|
export * from './utils/project-mapping-utils';
|
package/dist/index.js
CHANGED
|
@@ -22,6 +22,7 @@ __exportStar(require("./models"), exports);
|
|
|
22
22
|
__exportStar(require("./options"), exports);
|
|
23
23
|
__exportStar(require("./reporters"), exports);
|
|
24
24
|
__exportStar(require("./writer"), exports);
|
|
25
|
+
__exportStar(require("./profilers"), exports);
|
|
25
26
|
__exportStar(require("./utils/get-package-version"), exports);
|
|
26
27
|
__exportStar(require("./utils/mimeTypes"), exports);
|
|
27
28
|
__exportStar(require("./utils/project-mapping-utils"), exports);
|
package/dist/models/index.d.ts
CHANGED
|
@@ -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[];
|
package/dist/models/test-step.js
CHANGED
|
@@ -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,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 {};
|