@controlium/utils 1.0.2-alpha.2 → 1.0.2-alpha.4
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/index.js +1 -0
- package/dist/esm/mock/mock.js +45 -31
- package/dist/esm/utils/utils.js +3 -2
- package/dist/types/apiUtils/APIUtils.d.ts +90 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/mock/mock.d.ts +13 -11
- package/dist/types/utils/utils.d.ts +4 -4
- package/package.json +11 -52
- package/dist/cjs/detokeniser/detokeniser.js +0 -1015
- package/dist/cjs/index.js +0 -16
- package/dist/cjs/jsonUtils/jsonUtils.js +0 -460
- package/dist/cjs/logger/logger.js +0 -865
- package/dist/cjs/logger/types.js +0 -2
- package/dist/cjs/mock/mock.js +0 -509
- package/dist/cjs/stringUtils/stringUtils.js +0 -294
- package/dist/cjs/utils/utils.js +0 -1050
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { Agent, Headers, ProxyAgent, fetch } from 'undici';
|
|
2
|
+
import { Log, LogLevels, Utils } from '../index';
|
|
3
|
+
export class APIUtils {
|
|
4
|
+
/**
|
|
5
|
+
* Verify if HTTP server listening
|
|
6
|
+
* @param url
|
|
7
|
+
* Protocol and domain of HTTP Server (IE. http://localhost:4200)
|
|
8
|
+
* @param timeoutMS
|
|
9
|
+
* Maximum time (in Milliseconds to wait for response)
|
|
10
|
+
* @returns boolean
|
|
11
|
+
* true if Server alive and responding
|
|
12
|
+
* false if no response with timeout
|
|
13
|
+
* @abstract
|
|
14
|
+
* A fetch 'HEAD' request is used to obtain a header from the server. If no
|
|
15
|
+
* response then it is assumed nothing listening
|
|
16
|
+
*/
|
|
17
|
+
static async isWebServerListening(url, timeoutMS) {
|
|
18
|
+
try {
|
|
19
|
+
await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(timeoutMS) });
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
24
|
+
if (err && typeof err === 'object' && 'errors' in err && Array.isArray(err.errors)) {
|
|
25
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
26
|
+
const agg = err;
|
|
27
|
+
for (const e of agg.errors) {
|
|
28
|
+
Log.writeLine(LogLevels.FrameworkInformation, `Error:\n${e.code} (${e.message})`);
|
|
29
|
+
if (e.message.includes('ECONNREFUSED')) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
36
|
+
Log.writeLine(LogLevels.FrameworkInformation, `Error:\n${err['code'] ?? 'no code'} (${err.message})`);
|
|
37
|
+
if (err.message.includes('ECONNREFUSED')) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// We are only interested in if ECONNREFUSED. All else means there be _something_ listening...
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Waits for coherent response from given HTTP url
|
|
47
|
+
* @param url
|
|
48
|
+
* Protocol and domain of HTTP Server (IE. http://localhost:4200)
|
|
49
|
+
* @param maxSecondsToWait
|
|
50
|
+
* Maximum time to wait (in seconds) for a coherent response from given url
|
|
51
|
+
* @param maxResponseTimeMS (optional, default 1000)
|
|
52
|
+
* Maximum time for a response (in milliseconds) to a http HEAD request
|
|
53
|
+
* @returns
|
|
54
|
+
* Promise of boolean
|
|
55
|
+
* true - Webserver on given url is alive and responding
|
|
56
|
+
* false - No response from given url within timeout.
|
|
57
|
+
* @abstract
|
|
58
|
+
* URL is polled
|
|
59
|
+
*/
|
|
60
|
+
static async waitForWebServerListening(url, maxSecondsToWait, { maxResponseTimeMS = 1000 } = {}) {
|
|
61
|
+
const pollIntervalMs = 500;
|
|
62
|
+
Log.writeLine(LogLevels.TestInformation, `Waiting for AUT at ${url} to become available (overall timeout: ${maxSecondsToWait} seconds)...`);
|
|
63
|
+
const startTime = Date.now();
|
|
64
|
+
let elapsed = 0;
|
|
65
|
+
while (!await this.isWebServerListening(url, maxResponseTimeMS)) {
|
|
66
|
+
const oldElapsed = elapsed;
|
|
67
|
+
elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
68
|
+
if (elapsed >= maxSecondsToWait) {
|
|
69
|
+
Log.writeLine(LogLevels.Error, `Timeout reached: Server did not respond within ${maxSecondsToWait} seconds.`);
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
// Stick a confidence message out every 4 seconds
|
|
73
|
+
if ((oldElapsed != elapsed) && (elapsed % 4 == 0)) {
|
|
74
|
+
Log.writeLine(LogLevels.TestInformation, `Waiting for AUT: Waited ${elapsed} seconds so far (Max wait ${maxSecondsToWait} seconds)`);
|
|
75
|
+
}
|
|
76
|
+
await Utils.sleep(pollIntervalMs, false);
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Perform a single HTTP/HTTPS operation based on the details of the Request envelope
|
|
82
|
+
* @param httpRequest - Details of Request to be performed
|
|
83
|
+
* @returns Full response
|
|
84
|
+
* @throws Error if there is any fail that results in a Response not being received.
|
|
85
|
+
* The caller-supplied timeout (or the default 10s) is enforced as a hard failsafe via AbortSignal.
|
|
86
|
+
*/
|
|
87
|
+
static async performHTTPOperation(httpRequest) {
|
|
88
|
+
const API_DEFAULT_TIMEOUT = 10000;
|
|
89
|
+
let dispatcher;
|
|
90
|
+
try {
|
|
91
|
+
if (Utils.isNullOrUndefined(httpRequest.timeout)) {
|
|
92
|
+
Log.writeLine(LogLevels.FrameworkDebug, `No API Timeout defined. Setting to ${Utils.msToHMS(API_DEFAULT_TIMEOUT)}`);
|
|
93
|
+
httpRequest.timeout = API_DEFAULT_TIMEOUT;
|
|
94
|
+
}
|
|
95
|
+
const builtUrl = this.buildURL(httpRequest);
|
|
96
|
+
Log.writeLine(LogLevels.FrameworkInformation, `Built URL: [${builtUrl}]`);
|
|
97
|
+
dispatcher = this.buildDispatcher(httpRequest);
|
|
98
|
+
const headers = this.buildHeaders(httpRequest.headers);
|
|
99
|
+
this.doRequestLogging(httpRequest.method ?? 'GET', builtUrl, headers, httpRequest.body);
|
|
100
|
+
const body = Utils.isNullOrUndefined(httpRequest.body)
|
|
101
|
+
? undefined
|
|
102
|
+
: typeof httpRequest.body === 'string'
|
|
103
|
+
? httpRequest.body
|
|
104
|
+
: JSON.stringify(httpRequest.body);
|
|
105
|
+
const fetchResponse = await fetch(builtUrl, {
|
|
106
|
+
method: httpRequest.method ?? 'GET',
|
|
107
|
+
headers,
|
|
108
|
+
body,
|
|
109
|
+
redirect: 'follow',
|
|
110
|
+
signal: AbortSignal.timeout(httpRequest.timeout),
|
|
111
|
+
dispatcher,
|
|
112
|
+
});
|
|
113
|
+
const responseBody = await fetchResponse.text();
|
|
114
|
+
this.doResponseLogging(fetchResponse.status, fetchResponse.statusText, responseBody);
|
|
115
|
+
return {
|
|
116
|
+
status: fetchResponse.status,
|
|
117
|
+
statusMessage: fetchResponse.statusText,
|
|
118
|
+
body: responseBody,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
Log.writeLine(LogLevels.Error, `HTTP OPERATION ERROR: ${err}`);
|
|
123
|
+
throw err;
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
await dispatcher?.close();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
static buildURL(httpRequest) {
|
|
130
|
+
let builtUrl = httpRequest.protocol;
|
|
131
|
+
builtUrl += '://';
|
|
132
|
+
builtUrl += httpRequest.host.endsWith('/')
|
|
133
|
+
? httpRequest.host.substring(0, httpRequest.host.length - 1)
|
|
134
|
+
: httpRequest.host;
|
|
135
|
+
builtUrl += '/';
|
|
136
|
+
builtUrl += httpRequest.resourcePath.startsWith('/')
|
|
137
|
+
? httpRequest.resourcePath.substring(1, httpRequest.resourcePath.length)
|
|
138
|
+
: httpRequest.resourcePath;
|
|
139
|
+
if (!Utils.isNullOrUndefined(httpRequest.queryString)) {
|
|
140
|
+
const queryString = httpRequest.queryString;
|
|
141
|
+
builtUrl += '?';
|
|
142
|
+
builtUrl += queryString.startsWith('?')
|
|
143
|
+
? queryString.substring(1, queryString.length)
|
|
144
|
+
: queryString;
|
|
145
|
+
}
|
|
146
|
+
return builtUrl;
|
|
147
|
+
}
|
|
148
|
+
static buildDispatcher(httpRequest) {
|
|
149
|
+
if (!Utils.isNullOrUndefined(httpRequest.proxy)) {
|
|
150
|
+
const proxyAgent = new ProxyAgent({
|
|
151
|
+
uri: httpRequest.proxy,
|
|
152
|
+
connect: { timeout: httpRequest.timeout, rejectUnauthorized: false },
|
|
153
|
+
});
|
|
154
|
+
Log.writeLine(LogLevels.FrameworkInformation, `Proxy configured: [${httpRequest.proxy}]`);
|
|
155
|
+
return proxyAgent;
|
|
156
|
+
}
|
|
157
|
+
return new Agent({ connect: { timeout: httpRequest.timeout } });
|
|
158
|
+
}
|
|
159
|
+
static buildHeaders(httpHeaders) {
|
|
160
|
+
const headers = new Headers();
|
|
161
|
+
for (const [key, value] of Object.entries(httpHeaders)) {
|
|
162
|
+
if (!Utils.isNull(value)) {
|
|
163
|
+
headers.append(key, String(value));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return headers;
|
|
167
|
+
}
|
|
168
|
+
static doRequestLogging(method, url, headers, body) {
|
|
169
|
+
Log.writeLine(LogLevels.FrameworkInformation, `HTTP [${method}] to [${url}]:-`);
|
|
170
|
+
Log.writeLine(LogLevels.FrameworkInformation, ' Headers;');
|
|
171
|
+
let headersStr = '';
|
|
172
|
+
headers.forEach((value, key) => {
|
|
173
|
+
headersStr += `${headersStr === '' ? '' : '\n'} "${key}": "${value}"`;
|
|
174
|
+
});
|
|
175
|
+
Log.writeLine(LogLevels.FrameworkInformation, headersStr === '' ? ' <No headers!>' : headersStr);
|
|
176
|
+
if (Log.loggingLevel >= LogLevels.FrameworkDebug) {
|
|
177
|
+
Log.writeLine(LogLevels.FrameworkDebug, ' Request (full body);');
|
|
178
|
+
const bodyStr = Utils.isNullOrUndefined(body)
|
|
179
|
+
? ' <No body!>'
|
|
180
|
+
: typeof body === 'string' ? body : JSON.stringify(body, null, 2);
|
|
181
|
+
Log.writeLine(LogLevels.FrameworkDebug, bodyStr, { maxLines: 1024, suppressMultilinePreamble: true });
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
Log.writeLine(LogLevels.FrameworkInformation, ' Body;');
|
|
185
|
+
const bodyStr = Utils.isNullOrUndefined(body)
|
|
186
|
+
? ''
|
|
187
|
+
: typeof body === 'string' ? body : JSON.stringify(body);
|
|
188
|
+
if (!bodyStr) {
|
|
189
|
+
Log.writeLine(LogLevels.FrameworkInformation, ' <No body!>');
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
let indented = '';
|
|
193
|
+
bodyStr.split(/\r?\n/).forEach((line) => {
|
|
194
|
+
indented += `${indented === '' ? '' : '\n'} ${line}`;
|
|
195
|
+
});
|
|
196
|
+
Log.writeLine(LogLevels.FrameworkInformation, indented);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
static doResponseLogging(status, statusText, body) {
|
|
201
|
+
Log.writeLine(LogLevels.FrameworkInformation, 'HTTP Response:-');
|
|
202
|
+
Log.writeLine(LogLevels.FrameworkInformation, ` Status [${status}] - [${statusText}]`);
|
|
203
|
+
Log.writeLine(LogLevels.FrameworkInformation, ' Body;');
|
|
204
|
+
let indented = '';
|
|
205
|
+
if (body) {
|
|
206
|
+
body.split(/\r?\n/).forEach((line) => {
|
|
207
|
+
indented += `${indented === '' ? '' : '\n'} ${line}`;
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
Log.writeLine(LogLevels.FrameworkInformation, indented === '' ? ' <No body!>' : indented);
|
|
211
|
+
Log.writeLine(LogLevels.FrameworkInformation, 'HTTP Response end');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
215
|
+
(function (APIUtils) {
|
|
216
|
+
/**
|
|
217
|
+
* Generic Http call methods
|
|
218
|
+
*/
|
|
219
|
+
let HttpMethods;
|
|
220
|
+
(function (HttpMethods) {
|
|
221
|
+
HttpMethods["POST"] = "POST";
|
|
222
|
+
HttpMethods["GET"] = "GET";
|
|
223
|
+
HttpMethods["PUT"] = "PUT";
|
|
224
|
+
})(HttpMethods = APIUtils.HttpMethods || (APIUtils.HttpMethods = {}));
|
|
225
|
+
APIUtils.APPLICATION_JSON = 'application/json';
|
|
226
|
+
})(APIUtils || (APIUtils = {}));
|
package/dist/esm/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Logger } from "./logger/logger";
|
|
2
2
|
export { Logger, Logger as Log };
|
|
3
3
|
export const LogLevels = Logger.Levels;
|
|
4
|
+
export { APIUtils } from "./apiUtils/APIUtils";
|
|
4
5
|
export { JsonUtils } from "./jsonUtils/jsonUtils";
|
|
5
6
|
export { StringUtils } from "./stringUtils/stringUtils";
|
|
6
7
|
export { Utils, ExistingFileWriteActions } from "./utils/utils";
|
package/dist/esm/mock/mock.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
+
import { STATUS_CODES } from 'node:http';
|
|
1
2
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { JSONPath } from 'jsonpath-plus';
|
|
3
3
|
import { Utils } from '../utils/utils';
|
|
4
|
-
import { Log, LogLevels } from '..';
|
|
4
|
+
import { JsonUtils, Log, Logger, LogLevels } from '..';
|
|
5
5
|
/**
|
|
6
6
|
* Static HTTP request interception and mocking utility for test suites.
|
|
7
7
|
*
|
|
8
8
|
* `Mock` is designed to sit between a test framework's network interceptor
|
|
9
9
|
* (e.g. Playwright's `page.route`) and the application under test. Every
|
|
10
|
-
* outgoing request from the AUT
|
|
10
|
+
* outgoing request from the AUT must be forwarded to {@link Mock.processInterceptedRequest}, which
|
|
11
11
|
* decides how to handle it based on the registered listeners.
|
|
12
12
|
*
|
|
13
13
|
* **Default-deny**: any request that does not match a listener is blocked and
|
|
@@ -26,7 +26,7 @@ import { Log, LogLevels } from '..';
|
|
|
26
26
|
*
|
|
27
27
|
* `Mock` runs in the same process as the test framework. When a `passthrough`
|
|
28
28
|
* listener matches, `Mock` fetches the real response itself and returns it
|
|
29
|
-
* directly. {@link Mock.
|
|
29
|
+
* directly. {@link Mock.processInterceptedRequest} always resolves to a {@link Mock.InterceptResult}
|
|
30
30
|
* with `action: 'fulfill'` or `action: 'block'`.
|
|
31
31
|
*
|
|
32
32
|
* ### Delegate mode
|
|
@@ -34,7 +34,7 @@ import { Log, LogLevels } from '..';
|
|
|
34
34
|
* Enable with `Mock.delegateMode = true` when `Mock` is hosted in a remote
|
|
35
35
|
* server (e.g. a mock proxy) and the caller — not `Mock` — is better placed
|
|
36
36
|
* to perform the real network fetch. In this mode, when a `passthrough`
|
|
37
|
-
* listener matches, {@link Mock.
|
|
37
|
+
* listener matches, {@link Mock.processInterceptedRequest} returns `action: 'passthrough'`
|
|
38
38
|
* plus a `correlationId`. The caller fetches the real response and reports it
|
|
39
39
|
* back via {@link Mock.completePassthrough}, allowing `Mock` to record the
|
|
40
40
|
* full transaction. Use {@link Mock.getPendingTransactions} to detect
|
|
@@ -81,7 +81,7 @@ export class Mock {
|
|
|
81
81
|
// ── Configuration ──────────────────────────────────────────────────────────
|
|
82
82
|
/**
|
|
83
83
|
* When `true`, passthrough requests are delegated to the caller rather than
|
|
84
|
-
* fetched by `Mock` itself. {@link Mock.
|
|
84
|
+
* fetched by `Mock` itself. {@link Mock.processInterceptedRequest} returns
|
|
85
85
|
* `action: 'passthrough'` with a `correlationId` for the caller to use when
|
|
86
86
|
* reporting the real response back via {@link Mock.completePassthrough}.
|
|
87
87
|
*
|
|
@@ -163,10 +163,10 @@ export class Mock {
|
|
|
163
163
|
Mock.throwError('addListener', `[matchers[${index}]] must not be empty`);
|
|
164
164
|
}
|
|
165
165
|
try {
|
|
166
|
-
|
|
166
|
+
JsonUtils.getMatchingJSONPropertyCount({}, matcher);
|
|
167
167
|
}
|
|
168
168
|
catch (e) {
|
|
169
|
-
Mock.throwError('addListener', `[
|
|
169
|
+
Mock.throwError('addListener', `Matcher item [${index}] is not valid JSONPath syntax: "${matcher}". ${e.message}`);
|
|
170
170
|
}
|
|
171
171
|
});
|
|
172
172
|
if (action !== 'block' && action !== 'passthrough') {
|
|
@@ -180,6 +180,10 @@ export class Mock {
|
|
|
180
180
|
if (response.headers !== undefined && (typeof response.headers !== 'object' || response.headers === null || Array.isArray(response.headers))) {
|
|
181
181
|
Mock.throwError('addListener', `[action.headers] must be a plain object if provided`);
|
|
182
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;
|
|
183
187
|
}
|
|
184
188
|
Utils.assertType(delayMs, 'number', 'addListener', 'delayMs');
|
|
185
189
|
if (!Number.isFinite(delayMs) || delayMs < 0) {
|
|
@@ -250,7 +254,7 @@ export class Mock {
|
|
|
250
254
|
* | Matched → block | `'block'` | `'blocked'` |
|
|
251
255
|
* | No listener matched | `'block'` | `'unmatched'` |
|
|
252
256
|
*
|
|
253
|
-
* @param
|
|
257
|
+
* @param interceptedRequest - The intercepted request. Must be a non-null object with
|
|
254
258
|
* non-empty `url` and `method` string properties.
|
|
255
259
|
*
|
|
256
260
|
* @returns A promise resolving to a {@link Mock.InterceptResult}.
|
|
@@ -270,24 +274,24 @@ export class Mock {
|
|
|
270
274
|
* abortRequest();
|
|
271
275
|
* }
|
|
272
276
|
*/
|
|
273
|
-
static async
|
|
274
|
-
if (
|
|
275
|
-
Mock.throwError('intercept', `[request] must be a non-null object, got [${
|
|
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}]`);
|
|
276
280
|
}
|
|
277
|
-
if (typeof
|
|
278
|
-
Mock.throwError('intercept', `[request.url] must be a non-empty string, got [${typeof
|
|
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}]`);
|
|
279
283
|
}
|
|
280
|
-
if (typeof
|
|
281
|
-
Mock.throwError('intercept', `[request.method] must be a non-empty string, got [${typeof
|
|
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}]`);
|
|
282
286
|
}
|
|
283
|
-
if (
|
|
287
|
+
if (interceptedRequest.headers !== undefined && interceptedRequest.headers !== null && (typeof interceptedRequest.headers !== 'object' || Array.isArray(interceptedRequest.headers))) {
|
|
284
288
|
Mock.throwError('intercept', `[request.headers] must be a plain object if provided`);
|
|
285
289
|
}
|
|
286
|
-
Log.writeLine(LogLevels.FrameworkDebug, `Mock.intercept: ${
|
|
287
|
-
const listener = Mock.findMatch(
|
|
290
|
+
Log.writeLine(LogLevels.FrameworkDebug, `Mock.intercept: ${interceptedRequest.method} ${interceptedRequest.url}`);
|
|
291
|
+
const listener = Mock.findMatch(interceptedRequest);
|
|
288
292
|
if (!listener) {
|
|
289
293
|
Log.writeLine(LogLevels.FrameworkDebug, `Mock.intercept: no listener matched — blocking`);
|
|
290
|
-
Mock.record({ type: 'unmatched', request });
|
|
294
|
+
Mock.record({ type: 'unmatched', request: interceptedRequest });
|
|
291
295
|
return { action: 'block' };
|
|
292
296
|
}
|
|
293
297
|
Log.writeLine(LogLevels.FrameworkDebug, `Mock.intercept: matched listener <${listener.name}>`);
|
|
@@ -297,7 +301,7 @@ export class Mock {
|
|
|
297
301
|
}
|
|
298
302
|
if (listener.action === 'block') {
|
|
299
303
|
Log.writeLine(LogLevels.FrameworkDebug, `Mock.intercept: blocking request`);
|
|
300
|
-
Mock.record({ type: 'blocked', listenerName: listener.name, request });
|
|
304
|
+
Mock.record({ type: 'blocked', listenerName: listener.name, request: interceptedRequest });
|
|
301
305
|
return { action: 'block' };
|
|
302
306
|
}
|
|
303
307
|
if (listener.action === 'passthrough') {
|
|
@@ -308,18 +312,18 @@ export class Mock {
|
|
|
308
312
|
correlationId,
|
|
309
313
|
timestamp: new Date(),
|
|
310
314
|
listenerName: listener.name,
|
|
311
|
-
request,
|
|
315
|
+
request: interceptedRequest,
|
|
312
316
|
});
|
|
313
317
|
return { action: 'passthrough', correlationId };
|
|
314
318
|
}
|
|
315
319
|
Log.writeLine(LogLevels.FrameworkDebug, `Mock.intercept: passing through to real endpoint`);
|
|
316
|
-
const response = await Mock.fetchReal(
|
|
317
|
-
Mock.record({ type: 'passthrough', listenerName: listener.name, request, response });
|
|
320
|
+
const response = await Mock.fetchReal(interceptedRequest);
|
|
321
|
+
Mock.record({ type: 'passthrough', listenerName: listener.name, request: interceptedRequest, response });
|
|
318
322
|
return { action: 'fulfill', response };
|
|
319
323
|
}
|
|
320
324
|
const response = listener.action;
|
|
321
325
|
Log.writeLine(LogLevels.FrameworkDebug, `Mock.intercept: returning mocked response (status ${response.status})`);
|
|
322
|
-
Mock.record({ type: 'mocked', listenerName: listener.name, request, response });
|
|
326
|
+
Mock.record({ type: 'mocked', listenerName: listener.name, request: interceptedRequest, response });
|
|
323
327
|
return { action: 'fulfill', response };
|
|
324
328
|
}
|
|
325
329
|
/**
|
|
@@ -327,10 +331,10 @@ export class Mock {
|
|
|
327
331
|
* fetched. Records the full transaction and removes the pending entry.
|
|
328
332
|
*
|
|
329
333
|
* Only relevant when {@link Mock.delegateMode} is `true`. The `correlationId`
|
|
330
|
-
* must match one previously returned by {@link Mock.
|
|
334
|
+
* must match one previously returned by {@link Mock.processInterceptedRequest}.
|
|
331
335
|
*
|
|
332
336
|
* @param correlationId - The UUID returned in the `action: 'passthrough'`
|
|
333
|
-
* result from {@link Mock.
|
|
337
|
+
* result from {@link Mock.processInterceptedRequest}. Must be a non-empty string that
|
|
334
338
|
* matches a pending transaction.
|
|
335
339
|
*
|
|
336
340
|
* @param response - The real response the caller received from the endpoint.
|
|
@@ -442,20 +446,30 @@ export class Mock {
|
|
|
442
446
|
throw new Error(errorText);
|
|
443
447
|
}
|
|
444
448
|
static findMatch(request) {
|
|
449
|
+
Logger.writeLine(LogLevels.FrameworkInformation, `Checking if any listeners for [${request.url} (${request.method})]`);
|
|
445
450
|
for (const listener of Mock.listeners.values()) {
|
|
446
451
|
try {
|
|
447
|
-
const allMatch = listener.matchers.every((
|
|
452
|
+
const allMatch = listener.matchers.every((matcher) => {
|
|
448
453
|
try {
|
|
449
|
-
const
|
|
450
|
-
|
|
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;
|
|
451
462
|
}
|
|
452
463
|
catch (e) {
|
|
453
|
-
Log.writeLine(LogLevels.Warning, `Mock: JSONPath evaluation error in listener <${listener.name}> for path "${
|
|
464
|
+
Log.writeLine(LogLevels.Warning, `Mock: JSONPath evaluation error in listener <${listener.name}> for path "${matcher}": ${e.message} — treating as non-match`);
|
|
454
465
|
return false;
|
|
455
466
|
}
|
|
456
467
|
});
|
|
457
468
|
if (allMatch)
|
|
458
469
|
return listener;
|
|
470
|
+
else {
|
|
471
|
+
Logger.writeLine(LogLevels.FrameworkDebug, 'No matches');
|
|
472
|
+
}
|
|
459
473
|
}
|
|
460
474
|
catch (e) {
|
|
461
475
|
Log.writeLine(LogLevels.Warning, `Mock: Unexpected error evaluating listener <${listener.name}>: ${e.message} — skipping`);
|
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 APIUtils {
|
|
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: APIUtils.HTTPRequest): Promise<APIUtils.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 APIUtils {
|
|
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
|
+
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export declare const LogLevels: {
|
|
|
12
12
|
readonly NoOutput: 0;
|
|
13
13
|
};
|
|
14
14
|
export type { LogLevel, VideoOptions, WriteLineOptions, LogOutputCallbackSignature, } from "./logger/types";
|
|
15
|
+
export { APIUtils } from "./apiUtils/APIUtils";
|
|
15
16
|
export { JsonUtils } from "./jsonUtils/jsonUtils";
|
|
16
17
|
export { StringUtils } from "./stringUtils/stringUtils";
|
|
17
18
|
export { Utils, ExistingFileWriteActions } from "./utils/utils";
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `Mock` is designed to sit between a test framework's network interceptor
|
|
5
5
|
* (e.g. Playwright's `page.route`) and the application under test. Every
|
|
6
|
-
* outgoing request from the AUT
|
|
6
|
+
* outgoing request from the AUT must be forwarded to {@link Mock.processInterceptedRequest}, which
|
|
7
7
|
* decides how to handle it based on the registered listeners.
|
|
8
8
|
*
|
|
9
9
|
* **Default-deny**: any request that does not match a listener is blocked and
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*
|
|
23
23
|
* `Mock` runs in the same process as the test framework. When a `passthrough`
|
|
24
24
|
* listener matches, `Mock` fetches the real response itself and returns it
|
|
25
|
-
* directly. {@link Mock.
|
|
25
|
+
* directly. {@link Mock.processInterceptedRequest} always resolves to a {@link Mock.InterceptResult}
|
|
26
26
|
* with `action: 'fulfill'` or `action: 'block'`.
|
|
27
27
|
*
|
|
28
28
|
* ### Delegate mode
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* Enable with `Mock.delegateMode = true` when `Mock` is hosted in a remote
|
|
31
31
|
* server (e.g. a mock proxy) and the caller — not `Mock` — is better placed
|
|
32
32
|
* to perform the real network fetch. In this mode, when a `passthrough`
|
|
33
|
-
* listener matches, {@link Mock.
|
|
33
|
+
* listener matches, {@link Mock.processInterceptedRequest} returns `action: 'passthrough'`
|
|
34
34
|
* plus a `correlationId`. The caller fetches the real response and reports it
|
|
35
35
|
* back via {@link Mock.completePassthrough}, allowing `Mock` to record the
|
|
36
36
|
* full transaction. Use {@link Mock.getPendingTransactions} to detect
|
|
@@ -81,7 +81,7 @@ export declare class Mock {
|
|
|
81
81
|
private static _delegateMode;
|
|
82
82
|
/**
|
|
83
83
|
* When `true`, passthrough requests are delegated to the caller rather than
|
|
84
|
-
* fetched by `Mock` itself. {@link Mock.
|
|
84
|
+
* fetched by `Mock` itself. {@link Mock.processInterceptedRequest} returns
|
|
85
85
|
* `action: 'passthrough'` with a `correlationId` for the caller to use when
|
|
86
86
|
* reporting the real response back via {@link Mock.completePassthrough}.
|
|
87
87
|
*
|
|
@@ -176,7 +176,7 @@ export declare class Mock {
|
|
|
176
176
|
* | Matched → block | `'block'` | `'blocked'` |
|
|
177
177
|
* | No listener matched | `'block'` | `'unmatched'` |
|
|
178
178
|
*
|
|
179
|
-
* @param
|
|
179
|
+
* @param interceptedRequest - The intercepted request. Must be a non-null object with
|
|
180
180
|
* non-empty `url` and `method` string properties.
|
|
181
181
|
*
|
|
182
182
|
* @returns A promise resolving to a {@link Mock.InterceptResult}.
|
|
@@ -196,16 +196,16 @@ export declare class Mock {
|
|
|
196
196
|
* abortRequest();
|
|
197
197
|
* }
|
|
198
198
|
*/
|
|
199
|
-
static
|
|
199
|
+
static processInterceptedRequest(interceptedRequest: Mock.Request): Promise<Mock.InterceptResult>;
|
|
200
200
|
/**
|
|
201
201
|
* Completes a delegated passthrough by supplying the real response the caller
|
|
202
202
|
* fetched. Records the full transaction and removes the pending entry.
|
|
203
203
|
*
|
|
204
204
|
* Only relevant when {@link Mock.delegateMode} is `true`. The `correlationId`
|
|
205
|
-
* must match one previously returned by {@link Mock.
|
|
205
|
+
* must match one previously returned by {@link Mock.processInterceptedRequest}.
|
|
206
206
|
*
|
|
207
207
|
* @param correlationId - The UUID returned in the `action: 'passthrough'`
|
|
208
|
-
* result from {@link Mock.
|
|
208
|
+
* result from {@link Mock.processInterceptedRequest}. Must be a non-empty string that
|
|
209
209
|
* matches a pending transaction.
|
|
210
210
|
*
|
|
211
211
|
* @param response - The real response the caller received from the endpoint.
|
|
@@ -303,6 +303,8 @@ export declare namespace Mock {
|
|
|
303
303
|
interface Response {
|
|
304
304
|
/** HTTP status code, e.g. `200`, `404`. Must be 100–599. */
|
|
305
305
|
status: number;
|
|
306
|
+
/** HTTP status text, eg. 'ok' or 'Internal Server Error' etc */
|
|
307
|
+
statusText?: string;
|
|
306
308
|
/** Response headers as a plain key/value object. */
|
|
307
309
|
headers?: Record<string, string>;
|
|
308
310
|
/** Response body. Objects are serialised to JSON by the framework adapter. */
|
|
@@ -318,7 +320,7 @@ export declare namespace Mock {
|
|
|
318
320
|
*/
|
|
319
321
|
type ListenerAction = 'block' | 'passthrough' | Response;
|
|
320
322
|
/**
|
|
321
|
-
* The result returned by {@link Mock.
|
|
323
|
+
* The result returned by {@link Mock.processInterceptedRequest} to the framework adapter.
|
|
322
324
|
*
|
|
323
325
|
* - `action: 'fulfill'` — return `response` to the AUT.
|
|
324
326
|
* - `action: 'block'` — abort the request; the AUT receives nothing.
|
|
@@ -366,13 +368,13 @@ export declare namespace Mock {
|
|
|
366
368
|
response?: Response;
|
|
367
369
|
}
|
|
368
370
|
/**
|
|
369
|
-
* A delegated passthrough that has been issued by {@link Mock.
|
|
371
|
+
* A delegated passthrough that has been issued by {@link Mock.processInterceptedRequest} but
|
|
370
372
|
* not yet completed via {@link Mock.completePassthrough}.
|
|
371
373
|
*
|
|
372
374
|
* Only created when {@link Mock.delegateMode} is `true`.
|
|
373
375
|
*/
|
|
374
376
|
interface PendingTransaction {
|
|
375
|
-
/** The UUID issued by {@link Mock.
|
|
377
|
+
/** The UUID issued by {@link Mock.processInterceptedRequest} to identify this passthrough. */
|
|
376
378
|
correlationId: string;
|
|
377
379
|
/** Wall-clock time at which the passthrough was issued. */
|
|
378
380
|
timestamp: Date;
|