@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.
@@ -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;
@@ -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 { decode as jwtDecode, sign as jwtSign } from "jsonwebtoken";
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
+ }