@controlium/utils 1.0.2-alpha.1 → 1.0.2-alpha.3
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/dist/esm/apiUtils/APIUtils.js +226 -0
- package/dist/esm/detokeniser/detokeniser.js +50 -170
- package/dist/esm/index.js +1 -0
- package/dist/esm/logger/logger.js +5 -3
- package/dist/esm/mock/mock.js +519 -0
- package/dist/esm/utils/utils.js +3 -2
- package/dist/types/apiUtils/APIUtils.d.ts +90 -0
- package/dist/types/detokeniser/detokeniser.d.ts +46 -128
- package/dist/types/index.d.ts +1 -0
- package/dist/types/logger/types.d.ts +3 -1
- package/dist/types/mock/mock.d.ts +393 -0
- package/dist/types/utils/utils.d.ts +4 -4
- package/package.json +19 -55
- package/dist/cjs/detokeniser/detokeniser.js +0 -1135
- package/dist/cjs/index.js +0 -14
- package/dist/cjs/jsonUtils/jsonUtils.js +0 -460
- package/dist/cjs/logger/logger.js +0 -863
- package/dist/cjs/logger/logger.spec.js +0 -875
- package/dist/cjs/logger/types.js +0 -2
- package/dist/cjs/stringUtils/stringUtils.js +0 -294
- package/dist/cjs/utils/utils.js +0 -1050
- package/dist/esm/logger/logger.spec.js +0 -873
- package/dist/types/logger/logger.spec.d.ts +0 -1
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
import { STATUS_CODES } from 'node:http';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { Utils } from '../utils/utils';
|
|
4
|
+
import { JsonUtils, Log, Logger, LogLevels } from '..';
|
|
5
|
+
/**
|
|
6
|
+
* Static HTTP request interception and mocking utility for test suites.
|
|
7
|
+
*
|
|
8
|
+
* `Mock` is designed to sit between a test framework's network interceptor
|
|
9
|
+
* (e.g. Playwright's `page.route`) and the application under test. Every
|
|
10
|
+
* outgoing request from the AUT must be forwarded to {@link Mock.processInterceptedRequest}, which
|
|
11
|
+
* decides how to handle it based on the registered listeners.
|
|
12
|
+
*
|
|
13
|
+
* **Default-deny**: any request that does not match a listener is blocked and
|
|
14
|
+
* recorded as an `unmatched` transaction. The test suite has full control —
|
|
15
|
+
* nothing reaches the network unless explicitly permitted.
|
|
16
|
+
*
|
|
17
|
+
* **All transactions are stored** regardless of outcome, so test steps can
|
|
18
|
+
* later inspect real and mocked traffic via {@link Mock.getTransactions}.
|
|
19
|
+
*
|
|
20
|
+
* All methods are static — no instantiation is required. Call {@link Mock.reset}
|
|
21
|
+
* in `beforeEach` / `afterEach` hooks to start each test with a clean slate.
|
|
22
|
+
*
|
|
23
|
+
* ---
|
|
24
|
+
*
|
|
25
|
+
* ### Local mode (default)
|
|
26
|
+
*
|
|
27
|
+
* `Mock` runs in the same process as the test framework. When a `passthrough`
|
|
28
|
+
* listener matches, `Mock` fetches the real response itself and returns it
|
|
29
|
+
* directly. {@link Mock.processInterceptedRequest} always resolves to a {@link Mock.InterceptResult}
|
|
30
|
+
* with `action: 'fulfill'` or `action: 'block'`.
|
|
31
|
+
*
|
|
32
|
+
* ### Delegate mode
|
|
33
|
+
*
|
|
34
|
+
* Enable with `Mock.delegateMode = true` when `Mock` is hosted in a remote
|
|
35
|
+
* server (e.g. a mock proxy) and the caller — not `Mock` — is better placed
|
|
36
|
+
* to perform the real network fetch. In this mode, when a `passthrough`
|
|
37
|
+
* listener matches, {@link Mock.processInterceptedRequest} returns `action: 'passthrough'`
|
|
38
|
+
* plus a `correlationId`. The caller fetches the real response and reports it
|
|
39
|
+
* back via {@link Mock.completePassthrough}, allowing `Mock` to record the
|
|
40
|
+
* full transaction. Use {@link Mock.getPendingTransactions} to detect
|
|
41
|
+
* passthrough calls that were never completed.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```typescript
|
|
45
|
+
* // Local mode — Playwright adapter
|
|
46
|
+
* Mock.reset();
|
|
47
|
+
* Mock.addListener('get-users',
|
|
48
|
+
* ['$[?(@.url =~ /api\\/users/)]', '$[?(@.method == "GET")]'],
|
|
49
|
+
* { status: 200, body: [{ id: 1, name: 'Alice' }] }
|
|
50
|
+
* );
|
|
51
|
+
*
|
|
52
|
+
* await page.route('**\/*', async (route) => {
|
|
53
|
+
* const result = await Mock.intercept({
|
|
54
|
+
* url: route.request().url(),
|
|
55
|
+
* method: route.request().method(),
|
|
56
|
+
* headers: await route.request().allHeaders(),
|
|
57
|
+
* });
|
|
58
|
+
* if (result.action === 'fulfill') {
|
|
59
|
+
* await route.fulfill({ status: result.response.status });
|
|
60
|
+
* } else {
|
|
61
|
+
* await route.abort();
|
|
62
|
+
* }
|
|
63
|
+
* });
|
|
64
|
+
* ```
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```typescript
|
|
68
|
+
* // Delegate mode — remote mock server
|
|
69
|
+
* Mock.delegateMode = true;
|
|
70
|
+
*
|
|
71
|
+
* // In the interceptor callback:
|
|
72
|
+
* const result = await Mock.intercept(request);
|
|
73
|
+
* if (result.action === 'passthrough') {
|
|
74
|
+
* const real = await myFetch(request);
|
|
75
|
+
* Mock.completePassthrough(result.correlationId, real);
|
|
76
|
+
* return real;
|
|
77
|
+
* }
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
export class Mock {
|
|
81
|
+
// ── Configuration ──────────────────────────────────────────────────────────
|
|
82
|
+
/**
|
|
83
|
+
* When `true`, passthrough requests are delegated to the caller rather than
|
|
84
|
+
* fetched by `Mock` itself. {@link Mock.processInterceptedRequest} returns
|
|
85
|
+
* `action: 'passthrough'` with a `correlationId` for the caller to use when
|
|
86
|
+
* reporting the real response back via {@link Mock.completePassthrough}.
|
|
87
|
+
*
|
|
88
|
+
* Defaults to `false` (local mode — `Mock` fetches passthroughs itself).
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* Mock.delegateMode = true;
|
|
92
|
+
*/
|
|
93
|
+
static set delegateMode(value) {
|
|
94
|
+
Utils.assertType(value, 'boolean', 'delegateMode', 'value');
|
|
95
|
+
Log.writeLine(LogLevels.FrameworkInformation, `Mock: delegate mode ${value ? 'enabled' : 'disabled'}`);
|
|
96
|
+
Mock._delegateMode = value;
|
|
97
|
+
}
|
|
98
|
+
static get delegateMode() {
|
|
99
|
+
return Mock._delegateMode;
|
|
100
|
+
}
|
|
101
|
+
// ── Listener management ────────────────────────────────────────────────────
|
|
102
|
+
/**
|
|
103
|
+
* Registers a named listener that matches intercepted requests and defines
|
|
104
|
+
* how `Mock` should respond to them.
|
|
105
|
+
*
|
|
106
|
+
* Listeners are evaluated in registration order. The first listener whose
|
|
107
|
+
* every matcher returns a result wins. If a listener with the same `name`
|
|
108
|
+
* already exists it is replaced and a warning is logged.
|
|
109
|
+
*
|
|
110
|
+
* @param name - Unique name for this listener. Used as the key in the
|
|
111
|
+
* listener store and appears in transaction records and log output.
|
|
112
|
+
* Must be a non-empty string.
|
|
113
|
+
*
|
|
114
|
+
* @param matchers - One or more JSONPath expressions evaluated against the
|
|
115
|
+
* {@link Mock.Request} object. All expressions must return at least one
|
|
116
|
+
* result for the listener to match (AND logic). Each expression is
|
|
117
|
+
* validated for syntactic correctness at registration time — an invalid
|
|
118
|
+
* expression throws immediately rather than silently failing at intercept
|
|
119
|
+
* time. Must be a non-empty array of non-empty strings.
|
|
120
|
+
*
|
|
121
|
+
* @param action - What to do when this listener matches:
|
|
122
|
+
* - `'block'` — abort the request and return `action: 'block'` to the caller.
|
|
123
|
+
* - `'passthrough'` — forward the request to the real endpoint (or delegate
|
|
124
|
+
* to the caller in {@link Mock.delegateMode}) and return the real response.
|
|
125
|
+
* - {@link Mock.Response} — return the supplied response object directly
|
|
126
|
+
* without touching the network. `status` must be a valid HTTP status
|
|
127
|
+
* code (100–599).
|
|
128
|
+
*
|
|
129
|
+
* @param delayMs - Optional delay in milliseconds applied before the
|
|
130
|
+
* response is returned, whether mocked, real, or blocked. Useful for
|
|
131
|
+
* simulating slow networks. Must be a non-negative finite number.
|
|
132
|
+
* Defaults to `0` (no delay).
|
|
133
|
+
*
|
|
134
|
+
* @throws {Error} If any argument fails validation.
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* // Block all requests to an analytics endpoint
|
|
138
|
+
* Mock.addListener('block-analytics', ['$[?(@.url =~ /analytics/)]'], 'block');
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* // Return a mock response with a 2-second simulated delay
|
|
142
|
+
* Mock.addListener('slow-login',
|
|
143
|
+
* ['$[?(@.url =~ /api\\/login/)]'],
|
|
144
|
+
* { status: 200, headers: { 'content-type': 'application/json' }, body: { token: 'abc' } },
|
|
145
|
+
* 2000
|
|
146
|
+
* );
|
|
147
|
+
*/
|
|
148
|
+
static addListener(name, matchers, action, delayMs = 0) {
|
|
149
|
+
Utils.assertType(name, 'string', 'addListener', 'name');
|
|
150
|
+
if (name.trim().length === 0)
|
|
151
|
+
Mock.throwError('addListener', '[name] must not be empty');
|
|
152
|
+
if (!Array.isArray(matchers)) {
|
|
153
|
+
Mock.throwError('addListener', `[matchers] must be a string array, got [${typeof matchers}]`);
|
|
154
|
+
}
|
|
155
|
+
if (matchers.length === 0) {
|
|
156
|
+
Mock.throwError('addListener', '[matchers] array must not be empty');
|
|
157
|
+
}
|
|
158
|
+
matchers.forEach((matcher, index) => {
|
|
159
|
+
if (typeof matcher !== 'string') {
|
|
160
|
+
Mock.throwError('addListener', `[matchers[${index}]] must be a string, got [${typeof matcher}]`);
|
|
161
|
+
}
|
|
162
|
+
if (matcher.trim().length === 0) {
|
|
163
|
+
Mock.throwError('addListener', `[matchers[${index}]] must not be empty`);
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
JsonUtils.getMatchingJSONPropertyCount({}, matcher);
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
Mock.throwError('addListener', `Matcher item [${index}] is not valid JSONPath syntax: "${matcher}". ${e.message}`);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
if (action !== 'block' && action !== 'passthrough') {
|
|
173
|
+
if (action === null || typeof action !== 'object' || Array.isArray(action)) {
|
|
174
|
+
Mock.throwError('addListener', `[action] must be 'block', 'passthrough', or a Mock.Response object, got [${action === null ? 'null' : typeof action}]`);
|
|
175
|
+
}
|
|
176
|
+
const response = action;
|
|
177
|
+
if (typeof response.status !== 'number' || !Number.isInteger(response.status) || response.status < 100 || response.status > 599) {
|
|
178
|
+
Mock.throwError('addListener', `[action.status] must be a valid HTTP status code (100-599), got [${response.status}]`);
|
|
179
|
+
}
|
|
180
|
+
if (response.headers !== undefined && (typeof response.headers !== 'object' || response.headers === null || Array.isArray(response.headers))) {
|
|
181
|
+
Mock.throwError('addListener', `[action.headers] must be a plain object if provided`);
|
|
182
|
+
}
|
|
183
|
+
if (Utils.isUndefined(action.statusText))
|
|
184
|
+
action.statusText = STATUS_CODES[action.status] ?? undefined;
|
|
185
|
+
else if (action.statusText === '_undefined')
|
|
186
|
+
action.statusText = undefined;
|
|
187
|
+
}
|
|
188
|
+
Utils.assertType(delayMs, 'number', 'addListener', 'delayMs');
|
|
189
|
+
if (!Number.isFinite(delayMs) || delayMs < 0) {
|
|
190
|
+
Mock.throwError('addListener', `[delayMs] must be a non-negative finite number, got [${delayMs}]`);
|
|
191
|
+
}
|
|
192
|
+
if (Mock.listeners.has(name)) {
|
|
193
|
+
Log.writeLine(LogLevels.Warning, `Mock.addListener: replacing existing listener <${name}>`);
|
|
194
|
+
}
|
|
195
|
+
const isBlockOrPassthrough = action === 'block' || action === 'passthrough';
|
|
196
|
+
const actionText = (() => {
|
|
197
|
+
if (action === 'block')
|
|
198
|
+
return 'Block';
|
|
199
|
+
if (action === 'passthrough')
|
|
200
|
+
return 'Passthrough';
|
|
201
|
+
return JSON.stringify(action, null, 2);
|
|
202
|
+
})();
|
|
203
|
+
Log.writeLine(LogLevels.FrameworkInformation, `Add mock listener <${name}${delayMs > 0 ? ` (Delay by ${delayMs}mS)` : ''}>:\nMatcher: ${matchers.join('\nMatcher: ')}\nAction:${isBlockOrPassthrough ? ` ${actionText}` : `\n${actionText}`}`, { suppressMultilinePreamble: true });
|
|
204
|
+
Mock.listeners.set(name, { name, matchers, action, delayMs });
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Removes a previously registered listener by name.
|
|
208
|
+
*
|
|
209
|
+
* If no listener with the given name exists, a warning is logged and the
|
|
210
|
+
* call is a no-op.
|
|
211
|
+
*
|
|
212
|
+
* @param name - Name of the listener to remove. Must be a non-empty string.
|
|
213
|
+
* @throws {Error} If `name` is not a non-empty string.
|
|
214
|
+
*
|
|
215
|
+
* @example
|
|
216
|
+
* Mock.removeListener('get-users');
|
|
217
|
+
*/
|
|
218
|
+
static removeListener(name) {
|
|
219
|
+
Utils.assertType(name, 'string', 'removeListener', 'name');
|
|
220
|
+
if (name.trim().length === 0)
|
|
221
|
+
Mock.throwError('removeListener', '[name] must not be empty');
|
|
222
|
+
if (!Mock.listeners.has(name)) {
|
|
223
|
+
Log.writeLine(LogLevels.Warning, `Mock.removeListener: no listener named <${name}> exists — nothing removed`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
Log.writeLine(LogLevels.FrameworkInformation, `Mock: Remove listener <${name}>`);
|
|
227
|
+
Mock.listeners.delete(name);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Removes all registered listeners.
|
|
231
|
+
*
|
|
232
|
+
* Transaction history and pending transactions are unaffected. Use
|
|
233
|
+
* {@link Mock.reset} to clear everything in a single call.
|
|
234
|
+
*/
|
|
235
|
+
static clearListeners() {
|
|
236
|
+
Log.writeLine(LogLevels.FrameworkInformation, `Mock: Clear all listeners (${Mock.listeners.size} removed)`);
|
|
237
|
+
Mock.listeners.clear();
|
|
238
|
+
}
|
|
239
|
+
// ── Intercept ──────────────────────────────────────────────────────────────
|
|
240
|
+
/**
|
|
241
|
+
* Processes an intercepted HTTP request and returns a {@link Mock.InterceptResult}
|
|
242
|
+
* describing what the caller should do.
|
|
243
|
+
*
|
|
244
|
+
* Listeners are evaluated in registration order; the first full match wins.
|
|
245
|
+
* Every request — matched or not — is recorded in the transaction history.
|
|
246
|
+
* If the matching listener has a `delayMs`, the delay is applied before the
|
|
247
|
+
* result is returned.
|
|
248
|
+
*
|
|
249
|
+
* | Situation | `action` returned | Transaction type |
|
|
250
|
+
* |---|---|---|
|
|
251
|
+
* | Matched → mock response | `'fulfill'` | `'mocked'` |
|
|
252
|
+
* | Matched → passthrough, local mode | `'fulfill'` | `'passthrough'` |
|
|
253
|
+
* | Matched → passthrough, delegate mode | `'passthrough'` | pending until {@link Mock.completePassthrough} |
|
|
254
|
+
* | Matched → block | `'block'` | `'blocked'` |
|
|
255
|
+
* | No listener matched | `'block'` | `'unmatched'` |
|
|
256
|
+
*
|
|
257
|
+
* @param interceptedRequest - The intercepted request. Must be a non-null object with
|
|
258
|
+
* non-empty `url` and `method` string properties.
|
|
259
|
+
*
|
|
260
|
+
* @returns A promise resolving to a {@link Mock.InterceptResult}.
|
|
261
|
+
*
|
|
262
|
+
* @throws {Error} If `request` fails validation.
|
|
263
|
+
*
|
|
264
|
+
* @example
|
|
265
|
+
* const result = await Mock.intercept(request);
|
|
266
|
+
* if (result.action === 'fulfill') {
|
|
267
|
+
* respondWith(result.response);
|
|
268
|
+
* } else if (result.action === 'passthrough') {
|
|
269
|
+
* // delegate mode only
|
|
270
|
+
* const real = await fetch(request.url);
|
|
271
|
+
* Mock.completePassthrough(result.correlationId, real);
|
|
272
|
+
* respondWith(real);
|
|
273
|
+
* } else {
|
|
274
|
+
* abortRequest();
|
|
275
|
+
* }
|
|
276
|
+
*/
|
|
277
|
+
static async processInterceptedRequest(interceptedRequest) {
|
|
278
|
+
if (interceptedRequest === null || interceptedRequest === undefined || typeof interceptedRequest !== 'object' || Array.isArray(interceptedRequest)) {
|
|
279
|
+
Mock.throwError('intercept', `[request] must be a non-null object, got [${interceptedRequest === null ? 'null' : typeof interceptedRequest}]`);
|
|
280
|
+
}
|
|
281
|
+
if (typeof interceptedRequest.url !== 'string' || interceptedRequest.url.trim().length === 0) {
|
|
282
|
+
Mock.throwError('intercept', `[request.url] must be a non-empty string, got [${typeof interceptedRequest.url}]`);
|
|
283
|
+
}
|
|
284
|
+
if (typeof interceptedRequest.method !== 'string' || interceptedRequest.method.trim().length === 0) {
|
|
285
|
+
Mock.throwError('intercept', `[request.method] must be a non-empty string, got [${typeof interceptedRequest.method}]`);
|
|
286
|
+
}
|
|
287
|
+
if (interceptedRequest.headers !== undefined && interceptedRequest.headers !== null && (typeof interceptedRequest.headers !== 'object' || Array.isArray(interceptedRequest.headers))) {
|
|
288
|
+
Mock.throwError('intercept', `[request.headers] must be a plain object if provided`);
|
|
289
|
+
}
|
|
290
|
+
Log.writeLine(LogLevels.FrameworkDebug, `Mock.intercept: ${interceptedRequest.method} ${interceptedRequest.url}`);
|
|
291
|
+
const listener = Mock.findMatch(interceptedRequest);
|
|
292
|
+
if (!listener) {
|
|
293
|
+
Log.writeLine(LogLevels.FrameworkDebug, `Mock.intercept: no listener matched — blocking`);
|
|
294
|
+
Mock.record({ type: 'unmatched', request: interceptedRequest });
|
|
295
|
+
return { action: 'block' };
|
|
296
|
+
}
|
|
297
|
+
Log.writeLine(LogLevels.FrameworkDebug, `Mock.intercept: matched listener <${listener.name}>`);
|
|
298
|
+
if (listener.delayMs > 0) {
|
|
299
|
+
Log.writeLine(LogLevels.FrameworkDebug, `Mock.intercept: delaying ${listener.delayMs}ms`);
|
|
300
|
+
await Utils.sleep(listener.delayMs);
|
|
301
|
+
}
|
|
302
|
+
if (listener.action === 'block') {
|
|
303
|
+
Log.writeLine(LogLevels.FrameworkDebug, `Mock.intercept: blocking request`);
|
|
304
|
+
Mock.record({ type: 'blocked', listenerName: listener.name, request: interceptedRequest });
|
|
305
|
+
return { action: 'block' };
|
|
306
|
+
}
|
|
307
|
+
if (listener.action === 'passthrough') {
|
|
308
|
+
if (Mock._delegateMode) {
|
|
309
|
+
const correlationId = randomUUID();
|
|
310
|
+
Log.writeLine(LogLevels.FrameworkDebug, `Mock.intercept: delegating passthrough (correlationId: ${correlationId})`);
|
|
311
|
+
Mock.pendingTransactions.set(correlationId, {
|
|
312
|
+
correlationId,
|
|
313
|
+
timestamp: new Date(),
|
|
314
|
+
listenerName: listener.name,
|
|
315
|
+
request: interceptedRequest,
|
|
316
|
+
});
|
|
317
|
+
return { action: 'passthrough', correlationId };
|
|
318
|
+
}
|
|
319
|
+
Log.writeLine(LogLevels.FrameworkDebug, `Mock.intercept: passing through to real endpoint`);
|
|
320
|
+
const response = await Mock.fetchReal(interceptedRequest);
|
|
321
|
+
Mock.record({ type: 'passthrough', listenerName: listener.name, request: interceptedRequest, response });
|
|
322
|
+
return { action: 'fulfill', response };
|
|
323
|
+
}
|
|
324
|
+
const response = listener.action;
|
|
325
|
+
Log.writeLine(LogLevels.FrameworkDebug, `Mock.intercept: returning mocked response (status ${response.status})`);
|
|
326
|
+
Mock.record({ type: 'mocked', listenerName: listener.name, request: interceptedRequest, response });
|
|
327
|
+
return { action: 'fulfill', response };
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Completes a delegated passthrough by supplying the real response the caller
|
|
331
|
+
* fetched. Records the full transaction and removes the pending entry.
|
|
332
|
+
*
|
|
333
|
+
* Only relevant when {@link Mock.delegateMode} is `true`. The `correlationId`
|
|
334
|
+
* must match one previously returned by {@link Mock.processInterceptedRequest}.
|
|
335
|
+
*
|
|
336
|
+
* @param correlationId - The UUID returned in the `action: 'passthrough'`
|
|
337
|
+
* result from {@link Mock.processInterceptedRequest}. Must be a non-empty string that
|
|
338
|
+
* matches a pending transaction.
|
|
339
|
+
*
|
|
340
|
+
* @param response - The real response the caller received from the endpoint.
|
|
341
|
+
* Must be a valid {@link Mock.Response} with a status code of 100–599.
|
|
342
|
+
*
|
|
343
|
+
* @throws {Error} If `correlationId` is invalid, unknown, or already completed.
|
|
344
|
+
* @throws {Error} If `response` fails validation.
|
|
345
|
+
*
|
|
346
|
+
* @example
|
|
347
|
+
* const result = await Mock.intercept(request);
|
|
348
|
+
* if (result.action === 'passthrough') {
|
|
349
|
+
* const real = await myFetch(request);
|
|
350
|
+
* Mock.completePassthrough(result.correlationId, real);
|
|
351
|
+
* }
|
|
352
|
+
*/
|
|
353
|
+
static completePassthrough(correlationId, response) {
|
|
354
|
+
Utils.assertType(correlationId, 'string', 'completePassthrough', 'correlationId');
|
|
355
|
+
if (correlationId.trim().length === 0) {
|
|
356
|
+
Mock.throwError('completePassthrough', '[correlationId] must not be empty');
|
|
357
|
+
}
|
|
358
|
+
if (!Mock.pendingTransactions.has(correlationId)) {
|
|
359
|
+
Mock.throwError('completePassthrough', `[correlationId] "${correlationId}" does not match any pending passthrough — it may be unknown, already completed, or cleared by reset()`);
|
|
360
|
+
}
|
|
361
|
+
if (response === null || response === undefined || typeof response !== 'object' || Array.isArray(response)) {
|
|
362
|
+
Mock.throwError('completePassthrough', `[response] must be a non-null object, got [${response === null ? 'null' : typeof response}]`);
|
|
363
|
+
}
|
|
364
|
+
if (typeof response.status !== 'number' || !Number.isInteger(response.status) || response.status < 100 || response.status > 599) {
|
|
365
|
+
Mock.throwError('completePassthrough', `[response.status] must be a valid HTTP status code (100-599), got [${response.status}]`);
|
|
366
|
+
}
|
|
367
|
+
if (response.headers !== undefined && (typeof response.headers !== 'object' || response.headers === null || Array.isArray(response.headers))) {
|
|
368
|
+
Mock.throwError('completePassthrough', `[response.headers] must be a plain object if provided`);
|
|
369
|
+
}
|
|
370
|
+
const pending = Mock.pendingTransactions.get(correlationId);
|
|
371
|
+
Log.writeLine(LogLevels.FrameworkDebug, `Mock.completePassthrough: completing passthrough for correlationId ${correlationId} (status ${response.status})`);
|
|
372
|
+
Mock.pendingTransactions.delete(correlationId);
|
|
373
|
+
Mock.record({ type: 'passthrough', listenerName: pending.listenerName, request: pending.request, response });
|
|
374
|
+
}
|
|
375
|
+
// ── Transaction history ────────────────────────────────────────────────────
|
|
376
|
+
/**
|
|
377
|
+
* Returns all completed transactions in chronological order.
|
|
378
|
+
*
|
|
379
|
+
* Includes mocked, passthrough (completed), blocked, and unmatched entries.
|
|
380
|
+
* Delegated passthroughs awaiting {@link Mock.completePassthrough} appear in
|
|
381
|
+
* {@link Mock.getPendingTransactions} instead.
|
|
382
|
+
*
|
|
383
|
+
* @returns A read-only array of {@link Mock.Transaction} objects.
|
|
384
|
+
*
|
|
385
|
+
* @example
|
|
386
|
+
* const blocked = Mock.getTransactions().filter(t => t.type === 'blocked');
|
|
387
|
+
*/
|
|
388
|
+
static getTransactions() {
|
|
389
|
+
return Mock.transactions;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Clears all completed transactions and resets the transaction counter.
|
|
393
|
+
*
|
|
394
|
+
* Pending passthrough transactions and registered listeners are unaffected.
|
|
395
|
+
* Use {@link Mock.reset} to clear everything in a single call.
|
|
396
|
+
*/
|
|
397
|
+
static clearTransactions() {
|
|
398
|
+
Log.writeLine(LogLevels.FrameworkInformation, `Mock: Clear transaction history (${Mock.transactions.length} removed)`);
|
|
399
|
+
Mock.transactions = [];
|
|
400
|
+
Mock.transactionCounter = 0;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Returns all delegated passthrough transactions that have not yet been
|
|
404
|
+
* completed via {@link Mock.completePassthrough}.
|
|
405
|
+
*
|
|
406
|
+
* Use this in test assertions to verify no passthrough calls were abandoned,
|
|
407
|
+
* or to diagnose remote fetch failures.
|
|
408
|
+
*
|
|
409
|
+
* Only populated when {@link Mock.delegateMode} is `true`.
|
|
410
|
+
*
|
|
411
|
+
* @returns A read-only array of {@link Mock.PendingTransaction} objects.
|
|
412
|
+
*
|
|
413
|
+
* @example
|
|
414
|
+
* // Assert no orphaned passthroughs after a test
|
|
415
|
+
* expect(Mock.getPendingTransactions()).toHaveLength(0);
|
|
416
|
+
*/
|
|
417
|
+
static getPendingTransactions() {
|
|
418
|
+
return [...Mock.pendingTransactions.values()];
|
|
419
|
+
}
|
|
420
|
+
// ── Reset ──────────────────────────────────────────────────────────────────
|
|
421
|
+
/**
|
|
422
|
+
* Clears all listeners, completed transactions, pending transactions, and
|
|
423
|
+
* resets delegate mode to `false`.
|
|
424
|
+
*
|
|
425
|
+
* Call this in `beforeEach` or `afterEach` hooks to guarantee a clean slate
|
|
426
|
+
* between tests.
|
|
427
|
+
*
|
|
428
|
+
* @example
|
|
429
|
+
* beforeEach(() => {
|
|
430
|
+
* Mock.reset();
|
|
431
|
+
* Mock.addListener('static-assets', [...], 'passthrough');
|
|
432
|
+
* });
|
|
433
|
+
*/
|
|
434
|
+
static reset() {
|
|
435
|
+
Log.writeLine(LogLevels.FrameworkInformation, `Mock: Reset (${Mock.listeners.size} listeners, ${Mock.transactions.length} transactions, ${Mock.pendingTransactions.size} pending cleared)`);
|
|
436
|
+
Mock.listeners.clear();
|
|
437
|
+
Mock.transactions = [];
|
|
438
|
+
Mock.transactionCounter = 0;
|
|
439
|
+
Mock.pendingTransactions.clear();
|
|
440
|
+
Mock._delegateMode = false;
|
|
441
|
+
}
|
|
442
|
+
// ── Private helpers ────────────────────────────────────────────────────────
|
|
443
|
+
static throwError(funcName, message) {
|
|
444
|
+
const errorText = `Mock.${funcName}: ${message}`;
|
|
445
|
+
Log.writeLine(LogLevels.Error, errorText, { stackOffset: 1 });
|
|
446
|
+
throw new Error(errorText);
|
|
447
|
+
}
|
|
448
|
+
static findMatch(request) {
|
|
449
|
+
Logger.writeLine(LogLevels.FrameworkInformation, `Checking if any listeners for [${request.url} (${request.method})]`);
|
|
450
|
+
for (const listener of Mock.listeners.values()) {
|
|
451
|
+
try {
|
|
452
|
+
const allMatch = listener.matchers.every((matcher) => {
|
|
453
|
+
try {
|
|
454
|
+
const x = JSON.stringify(request, null, 2);
|
|
455
|
+
Logger.writeLine(LogLevels.TestInformation, x);
|
|
456
|
+
const matched = JsonUtils.getMatchingJSONPropertyCount([request], matcher) > 0;
|
|
457
|
+
if (matched) {
|
|
458
|
+
Logger.writeLine(LogLevels.FrameworkDebug, `Matched on [${matcher}]`);
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
catch (e) {
|
|
464
|
+
Log.writeLine(LogLevels.Warning, `Mock: JSONPath evaluation error in listener <${listener.name}> for path "${matcher}": ${e.message} — treating as non-match`);
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
if (allMatch)
|
|
469
|
+
return listener;
|
|
470
|
+
else {
|
|
471
|
+
Logger.writeLine(LogLevels.FrameworkDebug, 'No matches');
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
catch (e) {
|
|
475
|
+
Log.writeLine(LogLevels.Warning, `Mock: Unexpected error evaluating listener <${listener.name}>: ${e.message} — skipping`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return undefined;
|
|
479
|
+
}
|
|
480
|
+
static async fetchReal(request) {
|
|
481
|
+
Log.writeLine(LogLevels.FrameworkDebug, `Mock.fetchReal: ${request.method} ${request.url}`);
|
|
482
|
+
try {
|
|
483
|
+
const response = await fetch(request.url, {
|
|
484
|
+
method: request.method,
|
|
485
|
+
headers: request.headers,
|
|
486
|
+
body: request.body != null ? JSON.stringify(request.body) : undefined,
|
|
487
|
+
});
|
|
488
|
+
const body = await response.text().then((text) => {
|
|
489
|
+
try {
|
|
490
|
+
return JSON.parse(text);
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
return text;
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
const headers = {};
|
|
497
|
+
response.headers.forEach((value, key) => { headers[key] = value; });
|
|
498
|
+
Log.writeLine(LogLevels.FrameworkDebug, `Mock.fetchReal: received ${response.status} from ${request.url}`);
|
|
499
|
+
return { status: response.status, headers, body };
|
|
500
|
+
}
|
|
501
|
+
catch (e) {
|
|
502
|
+
const message = e.message;
|
|
503
|
+
Log.writeLine(LogLevels.Error, `Mock.fetchReal: network error fetching ${request.url}: ${message}`);
|
|
504
|
+
return { status: 502, headers: {}, body: `Mock passthrough network error: ${message}` };
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
static record(entry) {
|
|
508
|
+
Mock.transactions.push({
|
|
509
|
+
id: `txn-${++Mock.transactionCounter}`,
|
|
510
|
+
timestamp: new Date(),
|
|
511
|
+
...entry,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
Mock.listeners = new Map();
|
|
516
|
+
Mock.transactions = [];
|
|
517
|
+
Mock.transactionCounter = 0;
|
|
518
|
+
Mock.pendingTransactions = new Map();
|
|
519
|
+
Mock._delegateMode = false;
|
package/dist/esm/utils/utils.js
CHANGED
|
@@ -2,7 +2,8 @@ import { exec, spawn } from 'child_process';
|
|
|
2
2
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { decodeHTML } from "entities";
|
|
5
|
-
import {
|
|
5
|
+
import { createRequire } from 'module';
|
|
6
|
+
const { sign: jwtSign, decode: jwtDecode } = createRequire(import.meta.url)('jsonwebtoken');
|
|
6
7
|
import psTree from "ps-tree";
|
|
7
8
|
// import { Detokeniser } from "./Detokeniser"; Claude, just masking this out for now...
|
|
8
9
|
import { JsonUtils } from "../index";
|
|
@@ -98,7 +99,7 @@ export class Utils {
|
|
|
98
99
|
return true;
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
|
-
/**
|
|
102
|
+
/**2
|
|
102
103
|
* Safely checks if a value is `null`.
|
|
103
104
|
*
|
|
104
105
|
* @param obj - Value to check.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
export declare class EAAPIUtils {
|
|
2
|
+
/**
|
|
3
|
+
* Verify if HTTP server listening
|
|
4
|
+
* @param url
|
|
5
|
+
* Protocol and domain of HTTP Server (IE. http://localhost:4200)
|
|
6
|
+
* @param timeoutMS
|
|
7
|
+
* Maximum time (in Milliseconds to wait for response)
|
|
8
|
+
* @returns boolean
|
|
9
|
+
* true if Server alive and responding
|
|
10
|
+
* false if no response with timeout
|
|
11
|
+
* @abstract
|
|
12
|
+
* A fetch 'HEAD' request is used to obtain a header from the server. If no
|
|
13
|
+
* response then it is assumed nothing listening
|
|
14
|
+
*/
|
|
15
|
+
static isWebServerListening(url: string, timeoutMS: number): Promise<boolean>;
|
|
16
|
+
/**
|
|
17
|
+
* Waits for coherent response from given HTTP url
|
|
18
|
+
* @param url
|
|
19
|
+
* Protocol and domain of HTTP Server (IE. http://localhost:4200)
|
|
20
|
+
* @param maxSecondsToWait
|
|
21
|
+
* Maximum time to wait (in seconds) for a coherent response from given url
|
|
22
|
+
* @param maxResponseTimeMS (optional, default 1000)
|
|
23
|
+
* Maximum time for a response (in milliseconds) to a http HEAD request
|
|
24
|
+
* @returns
|
|
25
|
+
* Promise of boolean
|
|
26
|
+
* true - Webserver on given url is alive and responding
|
|
27
|
+
* false - No response from given url within timeout.
|
|
28
|
+
* @abstract
|
|
29
|
+
* URL is polled
|
|
30
|
+
*/
|
|
31
|
+
static waitForWebServerListening(url: string, maxSecondsToWait: number, { maxResponseTimeMS }?: {
|
|
32
|
+
maxResponseTimeMS?: number;
|
|
33
|
+
}): Promise<boolean>;
|
|
34
|
+
/**
|
|
35
|
+
* Perform a single HTTP/HTTPS operation based on the details of the Request envelope
|
|
36
|
+
* @param httpRequest - Details of Request to be performed
|
|
37
|
+
* @returns Full response
|
|
38
|
+
* @throws Error if there is any fail that results in a Response not being received.
|
|
39
|
+
* The caller-supplied timeout (or the default 10s) is enforced as a hard failsafe via AbortSignal.
|
|
40
|
+
*/
|
|
41
|
+
static performHTTPOperation(httpRequest: EAAPIUtils.HTTPRequest): Promise<EAAPIUtils.HTTPResponse>;
|
|
42
|
+
private static buildURL;
|
|
43
|
+
private static buildDispatcher;
|
|
44
|
+
private static buildHeaders;
|
|
45
|
+
private static doRequestLogging;
|
|
46
|
+
private static doResponseLogging;
|
|
47
|
+
}
|
|
48
|
+
export declare namespace EAAPIUtils {
|
|
49
|
+
/**
|
|
50
|
+
* Generic HTTP call Header items
|
|
51
|
+
*/
|
|
52
|
+
type HTTPHeaders = {
|
|
53
|
+
[key: string]: string | string[] | number | boolean | null;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Generic HTTP call Request envelope
|
|
57
|
+
*/
|
|
58
|
+
type HTTPRequest = {
|
|
59
|
+
proxy?: string;
|
|
60
|
+
method?: string;
|
|
61
|
+
protocol: 'http' | 'https';
|
|
62
|
+
host: string;
|
|
63
|
+
resourcePath: string;
|
|
64
|
+
queryString?: string;
|
|
65
|
+
headers: HTTPHeaders;
|
|
66
|
+
body?: string | object;
|
|
67
|
+
timeout?: number;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Generic Http call methods
|
|
71
|
+
*/
|
|
72
|
+
enum HttpMethods {
|
|
73
|
+
POST = "POST",
|
|
74
|
+
GET = "GET",
|
|
75
|
+
PUT = "PUT"
|
|
76
|
+
}
|
|
77
|
+
const APPLICATION_JSON = "application/json";
|
|
78
|
+
/**
|
|
79
|
+
* Generic HTTP call Response envelope
|
|
80
|
+
*/
|
|
81
|
+
type HTTPResponse = {
|
|
82
|
+
status: number;
|
|
83
|
+
statusMessage: string;
|
|
84
|
+
body: string;
|
|
85
|
+
};
|
|
86
|
+
type HTTPInteraction = {
|
|
87
|
+
request: HTTPRequest;
|
|
88
|
+
response: HTTPResponse;
|
|
89
|
+
};
|
|
90
|
+
}
|