@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
package/dist/cjs/utils/utils.js
DELETED
|
@@ -1,1050 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
-
};
|
|
5
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.Utils = exports.ExistingFileWriteActions = void 0;
|
|
7
|
-
const child_process_1 = require("child_process");
|
|
8
|
-
const fs_1 = require("fs");
|
|
9
|
-
const path_1 = __importDefault(require("path"));
|
|
10
|
-
const entities_1 = require("entities");
|
|
11
|
-
const jsonwebtoken_1 = require("jsonwebtoken");
|
|
12
|
-
const ps_tree_1 = __importDefault(require("ps-tree"));
|
|
13
|
-
// import { Detokeniser } from "./Detokeniser"; Claude, just masking this out for now...
|
|
14
|
-
const index_1 = require("../index");
|
|
15
|
-
const index_2 = require("../index");
|
|
16
|
-
const index_3 = require("../index");
|
|
17
|
-
// ─── Module-level constants ───────────────────────────────────────────────────
|
|
18
|
-
/** Milliseconds in one second. */
|
|
19
|
-
const MS_PER_SECOND = 1000;
|
|
20
|
-
/** Milliseconds in one day. */
|
|
21
|
-
const MS_PER_DAY = 86400000;
|
|
22
|
-
/**
|
|
23
|
-
* Prefix used when storing the original value of a modified environment variable.
|
|
24
|
-
* Allows {@link Utils.resetProcessEnvs} to restore variables to their pre-test values.
|
|
25
|
-
*/
|
|
26
|
-
const ENV_VAR_ORIGINAL_PREAMBLE = "test_old_";
|
|
27
|
-
// ─── Enums ────────────────────────────────────────────────────────────────────
|
|
28
|
-
/**
|
|
29
|
-
* What action to perform if a file already exists when {@link Utils.writeTextToFile} is called.
|
|
30
|
-
*/
|
|
31
|
-
var ExistingFileWriteActions;
|
|
32
|
-
(function (ExistingFileWriteActions) {
|
|
33
|
-
/** Overwrite existing file */
|
|
34
|
-
ExistingFileWriteActions[ExistingFileWriteActions["Overwrite"] = 0] = "Overwrite";
|
|
35
|
-
/** Create a new file using an incrementing index in the file name */
|
|
36
|
-
ExistingFileWriteActions[ExistingFileWriteActions["AddIndex"] = 1] = "AddIndex";
|
|
37
|
-
/** Append data to the existing file contents */
|
|
38
|
-
ExistingFileWriteActions[ExistingFileWriteActions["Append"] = 2] = "Append";
|
|
39
|
-
/** Throw an error indicating the file already exists */
|
|
40
|
-
ExistingFileWriteActions[ExistingFileWriteActions["ThrowError"] = 3] = "ThrowError";
|
|
41
|
-
})(ExistingFileWriteActions || (exports.ExistingFileWriteActions = ExistingFileWriteActions = {}));
|
|
42
|
-
// ─── Utils ────────────────────────────────────────────────────────────────────
|
|
43
|
-
/**
|
|
44
|
-
* General testing-related utilities. All methods are static — no instantiation required.
|
|
45
|
-
*/
|
|
46
|
-
class Utils {
|
|
47
|
-
// ── Promise tracking ─────────────────────────────────────────────────────
|
|
48
|
-
/**
|
|
49
|
-
* Number of currently outstanding promises wrapped by {@link timeoutPromise}.
|
|
50
|
-
* Check this at the end of a test to verify all promises have settled.
|
|
51
|
-
* @see {@link resetPromiseCount}
|
|
52
|
-
*/
|
|
53
|
-
static get promiseCount() {
|
|
54
|
-
return Utils._promiseCount;
|
|
55
|
-
}
|
|
56
|
-
/**
|
|
57
|
-
* Resets the outstanding promise count to zero.
|
|
58
|
-
* @see {@link promiseCount}
|
|
59
|
-
*/
|
|
60
|
-
static resetPromiseCount() {
|
|
61
|
-
Utils._promiseCount = 0;
|
|
62
|
-
}
|
|
63
|
-
/**
|
|
64
|
-
* Sets the default timeout in milliseconds applied by {@link timeoutPromise}
|
|
65
|
-
* when no per-call `timeoutMS` is supplied.
|
|
66
|
-
* @param timeoutMs - Timeout in milliseconds. Must be greater than zero.
|
|
67
|
-
*/
|
|
68
|
-
static set defaultPromiseTimeout(timeoutMs) {
|
|
69
|
-
Utils._defaultPromiseTimeout = timeoutMs;
|
|
70
|
-
}
|
|
71
|
-
// ── Type checking ─────────────────────────────────────────────────────────
|
|
72
|
-
/**
|
|
73
|
-
* Asserts that a value is of the expected type, throwing a logged error if not.
|
|
74
|
-
* After a successful call, TypeScript narrows `value` to the corresponding type.
|
|
75
|
-
*
|
|
76
|
-
* @param value - Value to check.
|
|
77
|
-
* @param expectedType - Expected `typeof` string (e.g. `"string"`, `"number"`).
|
|
78
|
-
* @param funcName - Name of the calling function, used in the error message.
|
|
79
|
-
* @param paramName - Name of the parameter being checked, used in the error message.
|
|
80
|
-
* @throws {Error} If `typeof value` does not match `expectedType`.
|
|
81
|
-
*
|
|
82
|
-
* @example
|
|
83
|
-
* Utils.assertType(name, "string", "greet", "name");
|
|
84
|
-
* // name is now narrowed to string
|
|
85
|
-
*/
|
|
86
|
-
static assertType(value, expectedType, funcName, paramName) {
|
|
87
|
-
if (typeof value !== expectedType) {
|
|
88
|
-
const errorText = `Cannot ${funcName} as [${paramName}] not '${expectedType}' type. Is [${typeof value}]`;
|
|
89
|
-
index_2.Log.writeLine(index_2.LogLevels.Error, errorText);
|
|
90
|
-
throw new Error(errorText);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
/**
|
|
94
|
-
* Safely checks if a value is `null` or `undefined`.
|
|
95
|
-
*
|
|
96
|
-
* @param obj - Value to check.
|
|
97
|
-
* @returns `true` if `null` or `undefined`, otherwise `false`.
|
|
98
|
-
*/
|
|
99
|
-
static isNullOrUndefined(obj) {
|
|
100
|
-
try {
|
|
101
|
-
return obj === null || obj === undefined;
|
|
102
|
-
}
|
|
103
|
-
catch {
|
|
104
|
-
return true;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
/**
|
|
108
|
-
* Safely checks if a value is `null`.
|
|
109
|
-
*
|
|
110
|
-
* @param obj - Value to check.
|
|
111
|
-
* @returns `true` if `null`, otherwise `false`.
|
|
112
|
-
*/
|
|
113
|
-
static isNull(obj) {
|
|
114
|
-
return obj === null;
|
|
115
|
-
}
|
|
116
|
-
/**
|
|
117
|
-
* Safely checks if a value is `undefined`.
|
|
118
|
-
*
|
|
119
|
-
* @param obj - Value to check.
|
|
120
|
-
* @returns `true` if `undefined`, otherwise `false`.
|
|
121
|
-
*/
|
|
122
|
-
static isUndefined(obj) {
|
|
123
|
-
return obj === undefined;
|
|
124
|
-
}
|
|
125
|
-
/**
|
|
126
|
-
* Checks whether a value evaluates to `true` according to common conventions.
|
|
127
|
-
*
|
|
128
|
-
* Returns `true` for:
|
|
129
|
-
* - A boolean `true`
|
|
130
|
-
* - The strings `"y"`, `"1"`, `"yes"`, `"positive"`, or `"true"` (case-insensitive, trimmed)
|
|
131
|
-
* - A number greater than zero
|
|
132
|
-
*
|
|
133
|
-
* @param valueToCheck - Value to evaluate.
|
|
134
|
-
* @returns `true` if the value is considered truthy, otherwise `false`.
|
|
135
|
-
*/
|
|
136
|
-
static isTrue(valueToCheck) {
|
|
137
|
-
switch (typeof valueToCheck) {
|
|
138
|
-
case "boolean":
|
|
139
|
-
return valueToCheck;
|
|
140
|
-
case "string": {
|
|
141
|
-
const normalizedValue = valueToCheck.toLowerCase().trim();
|
|
142
|
-
return (normalizedValue === "y" ||
|
|
143
|
-
normalizedValue === "1" ||
|
|
144
|
-
normalizedValue === "yes" ||
|
|
145
|
-
normalizedValue === "positive" ||
|
|
146
|
-
normalizedValue === "true");
|
|
147
|
-
}
|
|
148
|
-
case "number":
|
|
149
|
-
return valueToCheck > 0;
|
|
150
|
-
default:
|
|
151
|
-
return false;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
/**
|
|
155
|
-
* Verifies whether a value is a valid `Date` object.
|
|
156
|
-
*
|
|
157
|
-
* @param dateToCheck - Value to validate.
|
|
158
|
-
* @returns `true` if the value is a `Date` instance with a valid time, otherwise `false`.
|
|
159
|
-
*/
|
|
160
|
-
static isValidDate(dateToCheck) {
|
|
161
|
-
try {
|
|
162
|
-
return dateToCheck instanceof Date && !isNaN(dateToCheck.getTime());
|
|
163
|
-
}
|
|
164
|
-
catch {
|
|
165
|
-
return false;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
// ── Math / string helpers ─────────────────────────────────────────────────
|
|
169
|
-
/**
|
|
170
|
-
* Pads a number or string with leading zeros to reach a required minimum length.
|
|
171
|
-
*
|
|
172
|
-
* @param num - The number or string to pad.
|
|
173
|
-
* @param requiredMinimumLength - The minimum length of the returned string.
|
|
174
|
-
* @returns The value as a string, left-padded with `"0"` to at least `requiredMinimumLength` characters.
|
|
175
|
-
* @note If the value is already longer than `requiredMinimumLength`, no truncation occurs.
|
|
176
|
-
*
|
|
177
|
-
* @example
|
|
178
|
-
* Utils.pad(7, 3); // => "007"
|
|
179
|
-
* Utils.pad("42", 5); // => "00042"
|
|
180
|
-
*/
|
|
181
|
-
static pad(num, requiredMinimumLength) {
|
|
182
|
-
let numString = typeof num === "number" ? num.toString() : num;
|
|
183
|
-
while (numString.length < requiredMinimumLength)
|
|
184
|
-
numString = "0" + numString;
|
|
185
|
-
return numString;
|
|
186
|
-
}
|
|
187
|
-
/**
|
|
188
|
-
* Converts a millisecond duration to a `HH:MM:SS.t` formatted string,
|
|
189
|
-
* where `t` is tenths of a second.
|
|
190
|
-
*
|
|
191
|
-
* @param milliSeconds - Duration in milliseconds.
|
|
192
|
-
* @returns Time string formatted as `HH:MM:SS.t`.
|
|
193
|
-
* @note Durations above 359,999,000 ms (99h 59m 59s) will give unexpected results.
|
|
194
|
-
*/
|
|
195
|
-
static msToHMS(milliSeconds) {
|
|
196
|
-
const wholeDays = Math.floor(milliSeconds / MS_PER_DAY);
|
|
197
|
-
const date = new Date(milliSeconds - wholeDays * MS_PER_DAY);
|
|
198
|
-
const hours = wholeDays * 24 + date.getUTCHours();
|
|
199
|
-
return `${this.pad(hours, ("" + hours).length > 2 ? ("" + hours).length : 2)}:${this.pad(date.getUTCMinutes(), 2)}:${this.pad(date.getUTCSeconds(), 2)}.${Math.round(date.getUTCMilliseconds() / 100)}`;
|
|
200
|
-
}
|
|
201
|
-
/**
|
|
202
|
-
* Returns a random integer between `min` and `max` inclusive.
|
|
203
|
-
* If `max` is less than `min`, the values are swapped.
|
|
204
|
-
*
|
|
205
|
-
* @param min - Lower bound.
|
|
206
|
-
* @param max - Upper bound.
|
|
207
|
-
* @returns A random integer inclusively between `min` and `max`.
|
|
208
|
-
*/
|
|
209
|
-
static getRandomInt(min, max) {
|
|
210
|
-
const minMax = min < max ? Math.ceil(min) : Math.ceil(max);
|
|
211
|
-
const maxMin = min < max ? Math.floor(max) : Math.floor(min);
|
|
212
|
-
return Math.floor(Math.random() * (maxMin - minMax + 1)) + minMax;
|
|
213
|
-
}
|
|
214
|
-
/**
|
|
215
|
-
* Returns a random float between `min` and `max`.
|
|
216
|
-
*
|
|
217
|
-
* @param min - Lower bound.
|
|
218
|
-
* @param max - Upper bound.
|
|
219
|
-
* @returns A random float between `min` and `max`.
|
|
220
|
-
*/
|
|
221
|
-
static getRandomFloat(min, max) {
|
|
222
|
-
return Math.random() * (max - min) + min;
|
|
223
|
-
}
|
|
224
|
-
// ── File operations ───────────────────────────────────────────────────────
|
|
225
|
-
/**
|
|
226
|
-
* Writes data to a file, with configurable behaviour when the file already exists.
|
|
227
|
-
*
|
|
228
|
-
* Data is serialised before writing:
|
|
229
|
-
* - JSON strings are normalised (parsed then re-stringified).
|
|
230
|
-
* - Non-JSON strings are written as-is.
|
|
231
|
-
* - Objects are written as pretty-printed JSON.
|
|
232
|
-
*
|
|
233
|
-
* @param filePath - Directory path where the file should be created.
|
|
234
|
-
* @param fileName - Name of the file to write.
|
|
235
|
-
* @param data - Content to write — a string or an object.
|
|
236
|
-
* @param ifExistsAction - Action to take if the file already exists (default: `AddIndex`).
|
|
237
|
-
* @see {@link ExistingFileWriteActions}
|
|
238
|
-
* @throws {Error} If the write fails, or if `ThrowError` is specified and the file exists.
|
|
239
|
-
*/
|
|
240
|
-
static writeTextToFile(filePath, fileName, data, ifExistsAction = ExistingFileWriteActions.AddIndex) {
|
|
241
|
-
let fullFilename = path_1.default.join(filePath, fileName);
|
|
242
|
-
try {
|
|
243
|
-
if (!(0, fs_1.existsSync)(filePath)) {
|
|
244
|
-
index_2.Log.writeLine(index_2.LogLevels.FrameworkInformation, `Folder [${filePath}] does not exist so creating`);
|
|
245
|
-
(0, fs_1.mkdirSync)(filePath, { recursive: true });
|
|
246
|
-
}
|
|
247
|
-
if ((0, fs_1.existsSync)(fullFilename)) {
|
|
248
|
-
index_2.Log.writeLine(index_2.LogLevels.FrameworkInformation, `File [${fullFilename}] exists so performing action [${ifExistsAction.toString()}]`);
|
|
249
|
-
switch (ifExistsAction) {
|
|
250
|
-
case ExistingFileWriteActions.AddIndex: {
|
|
251
|
-
const splitFileName = fileName.split(".");
|
|
252
|
-
fileName =
|
|
253
|
-
splitFileName.length === 1
|
|
254
|
-
? fileName + ".1"
|
|
255
|
-
: ((splitFileName) => {
|
|
256
|
-
// If file name has 2 parts (e.g. hello.json) then add the index (e.g. hello.1.json)
|
|
257
|
-
if (splitFileName.length === 2) {
|
|
258
|
-
if (/^\d+$/.test(splitFileName[1])) {
|
|
259
|
-
return splitFileName[0] + "." + (parseInt(splitFileName[1]) + 1);
|
|
260
|
-
}
|
|
261
|
-
else {
|
|
262
|
-
return splitFileName[0] + ".1." + splitFileName[1];
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
// If file name has an index (e.g. hello.7.json or some.other.5.json) increment it
|
|
266
|
-
if (/^\d+$/.test(splitFileName[splitFileName.length - 2])) {
|
|
267
|
-
return (splitFileName.slice(0, splitFileName.length - 2).join(".") +
|
|
268
|
-
"." +
|
|
269
|
-
(parseInt(splitFileName[splitFileName.length - 2]) + 1) +
|
|
270
|
-
"." +
|
|
271
|
-
splitFileName[splitFileName.length - 1]);
|
|
272
|
-
}
|
|
273
|
-
// 3+ part name without a numeric index (e.g. hello.addd.json) — add index (e.g. hello.addd.1.json)
|
|
274
|
-
return splitFileName.slice(0, splitFileName.length - 1).join(".") + ".1." + splitFileName[splitFileName.length - 1];
|
|
275
|
-
})(splitFileName);
|
|
276
|
-
this.writeTextToFile(filePath, fileName, data, ifExistsAction);
|
|
277
|
-
break;
|
|
278
|
-
}
|
|
279
|
-
case ExistingFileWriteActions.Append: {
|
|
280
|
-
(0, fs_1.appendFileSync)(fullFilename, "\n" + Utils.serialiseFileData(data));
|
|
281
|
-
break;
|
|
282
|
-
}
|
|
283
|
-
case ExistingFileWriteActions.Overwrite: {
|
|
284
|
-
(0, fs_1.writeFileSync)(fullFilename, Utils.serialiseFileData(data));
|
|
285
|
-
break;
|
|
286
|
-
}
|
|
287
|
-
case ExistingFileWriteActions.ThrowError: {
|
|
288
|
-
const errText = `File [${fullFilename}] exists and action is ThrowError!`;
|
|
289
|
-
index_2.Log.writeLine(index_2.LogLevels.Error, errText);
|
|
290
|
-
throw new Error(errText);
|
|
291
|
-
}
|
|
292
|
-
default: {
|
|
293
|
-
const errText = `Cannot write to file [${fullFilename}] — unknown action [${ifExistsAction}]!`;
|
|
294
|
-
index_2.Log.writeLine(index_2.LogLevels.Error, errText);
|
|
295
|
-
throw new Error(errText);
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
else {
|
|
300
|
-
const splitFileName = fileName.split(".");
|
|
301
|
-
if (splitFileName.length === 1) {
|
|
302
|
-
if (ifExistsAction === ExistingFileWriteActions.AddIndex) {
|
|
303
|
-
this.writeTextToFile(filePath, fileName + ".1", data, ifExistsAction);
|
|
304
|
-
}
|
|
305
|
-
else {
|
|
306
|
-
(0, fs_1.writeFileSync)(fullFilename, Utils.serialiseFileData(data));
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
else {
|
|
310
|
-
if (!(splitFileName.length === 2 && /^\d+$/.test(splitFileName[splitFileName.length - 1])) &&
|
|
311
|
-
ifExistsAction === ExistingFileWriteActions.AddIndex &&
|
|
312
|
-
!/^\d+$/.test(splitFileName[splitFileName.length - 2])) {
|
|
313
|
-
fileName = splitFileName.slice(0, splitFileName.length - 1).join(".") + ".1." + splitFileName[splitFileName.length - 1];
|
|
314
|
-
}
|
|
315
|
-
fullFilename = path_1.default.join(filePath, fileName);
|
|
316
|
-
if ((0, fs_1.existsSync)(fullFilename)) {
|
|
317
|
-
this.writeTextToFile(filePath, fileName, data, ifExistsAction);
|
|
318
|
-
}
|
|
319
|
-
else {
|
|
320
|
-
(0, fs_1.writeFileSync)(fullFilename, Utils.serialiseFileData(data));
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
catch (err) {
|
|
326
|
-
index_2.Log.writeLine(index_2.LogLevels.Error, `Unable to write text to file: ${err}`);
|
|
327
|
-
throw err;
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
/**
|
|
331
|
-
* Returns the entire contents of a file as a string.
|
|
332
|
-
*
|
|
333
|
-
* @param path - Path to the file.
|
|
334
|
-
* @param options - Optional settings:
|
|
335
|
-
* - `encoding` — File encoding (default: `"utf-8"`).
|
|
336
|
-
* - `detokeniseFileContents` — When `true`, passes contents through the detokeniser before returning (default: `false`).
|
|
337
|
-
* @returns Contents of the file as a string.
|
|
338
|
-
* @throws {Error} If the file cannot be read.
|
|
339
|
-
*/
|
|
340
|
-
static getFileContents(filePath, options) {
|
|
341
|
-
const detokenise = options?.detokeniseFileContents ?? false;
|
|
342
|
-
const encoding = options?.encoding ?? "utf-8";
|
|
343
|
-
try {
|
|
344
|
-
index_2.Log.writeLine(index_2.LogLevels.FrameworkInformation, `Load file [${filePath}] using encoding [${encoding}]`);
|
|
345
|
-
let contents = this.getFileContentsBuffer(filePath).toString(encoding);
|
|
346
|
-
index_2.Log.writeLine(index_2.LogLevels.FrameworkDebug, `Loaded [${contents.length}] characters`);
|
|
347
|
-
if (detokenise) {
|
|
348
|
-
// contents = Detokeniser.do(contents); Hey Claude, dont forget. Masked out for now...
|
|
349
|
-
index_2.Log.writeLine(index_2.LogLevels.FrameworkDebug, `After detokenisation [${contents.length}] characters`);
|
|
350
|
-
return contents;
|
|
351
|
-
}
|
|
352
|
-
return contents;
|
|
353
|
-
}
|
|
354
|
-
catch (err) {
|
|
355
|
-
const errText = `Utils.getFileContents - Reading file using ${encoding} (${detokenise ? "" : "not "}detokenised) threw error: [${err}]`;
|
|
356
|
-
index_2.Log.writeLine(index_2.LogLevels.Error, errText);
|
|
357
|
-
throw new Error(errText);
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
/**
|
|
361
|
-
* Returns the entire contents of a file as a `Buffer`.
|
|
362
|
-
*
|
|
363
|
-
* @param path - Path to the file.
|
|
364
|
-
* @returns Raw file contents as a `Buffer`.
|
|
365
|
-
* @throws {Error} If the file cannot be read.
|
|
366
|
-
*/
|
|
367
|
-
static getFileContentsBuffer(filePath) {
|
|
368
|
-
try {
|
|
369
|
-
index_2.Log.writeLine(index_2.LogLevels.FrameworkInformation, `Getting file contents from [${filePath}]`);
|
|
370
|
-
return (0, fs_1.readFileSync)(filePath);
|
|
371
|
-
}
|
|
372
|
-
catch (err) {
|
|
373
|
-
const errText = `Utils.getFileContentsBuffer - readFileSync for path [${filePath}] threw error: [${err}]`;
|
|
374
|
-
index_2.Log.writeLine(index_2.LogLevels.Error, errText);
|
|
375
|
-
throw new Error(errText);
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
// ── Environment variables ─────────────────────────────────────────────────
|
|
379
|
-
/**
|
|
380
|
-
* Resolves a setting value from, in priority order:
|
|
381
|
-
* 1. A process environment variable
|
|
382
|
-
* 2. An npm package config variable
|
|
383
|
-
* 3. A named property within `contextParameters`
|
|
384
|
-
* 4. A supplied default value
|
|
385
|
-
*
|
|
386
|
-
* @param logLevel - Log level used when reporting where the setting was found.
|
|
387
|
-
* @param settingName - Human-readable name for the setting, used in log messages.
|
|
388
|
-
* @param sources - Named sources to check:
|
|
389
|
-
* - `processEnvName` — Environment variable name.
|
|
390
|
-
* - `npmPackageConfigName` — npm package config key.
|
|
391
|
-
* - `profileParameterName` — JSONPath into `contextParameters`.
|
|
392
|
-
* - `defaultValue` — Fallback if no other source resolves.
|
|
393
|
-
* @param contextParameters - Optional context parameters used to resolve `profileParameterName`.
|
|
394
|
-
* @returns The resolved setting value, or `undefined` if no source resolved.
|
|
395
|
-
* @throws {Error} If `profileParameterName` is given but `contextParameters` is null.
|
|
396
|
-
*/
|
|
397
|
-
static getSetting(logLevel, settingName, sources, contextParameters) {
|
|
398
|
-
const debugString = `Got setting [${settingName}] from `;
|
|
399
|
-
// Highest priority — process environment variable
|
|
400
|
-
let returnValue = sources.processEnvName ? process.env[sources.processEnvName] : undefined;
|
|
401
|
-
if (!Utils.isUndefined(returnValue)) {
|
|
402
|
-
index_2.Log.writeLine(logLevel, debugString + `env var [${sources.processEnvName}]. Value: <${returnValue}>`);
|
|
403
|
-
return returnValue;
|
|
404
|
-
}
|
|
405
|
-
// Next priority — npm package config variable
|
|
406
|
-
returnValue = sources.npmPackageConfigName ? process.env["npm_package_config_" + sources.npmPackageConfigName] : undefined;
|
|
407
|
-
if (!Utils.isUndefined(returnValue)) {
|
|
408
|
-
index_2.Log.writeLine(logLevel, debugString + `npm package config var [${sources.npmPackageConfigName}]. Value: <${returnValue}>`);
|
|
409
|
-
return returnValue;
|
|
410
|
-
}
|
|
411
|
-
// If no profile parameter name given, or it doesn't match exactly one property, fall back to default
|
|
412
|
-
if (Utils.isUndefined(sources.profileParameterName) ||
|
|
413
|
-
(contextParameters && index_1.JsonUtils.getMatchingJSONPropertyCount(contextParameters, sources.profileParameterName)) !== 1) {
|
|
414
|
-
returnValue = sources.defaultValue;
|
|
415
|
-
if (Utils.isUndefined(returnValue)) {
|
|
416
|
-
index_2.Log.writeLine(index_2.LogLevels.Error, `Unable to determine value for setting [${settingName}]. Returning: <undefined>!`);
|
|
417
|
-
return undefined;
|
|
418
|
-
}
|
|
419
|
-
else {
|
|
420
|
-
index_2.Log.writeLine(logLevel, debugString + `default value: <${returnValue}>`);
|
|
421
|
-
return returnValue;
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
// Profile parameter name given AND exactly one match exists — use it
|
|
425
|
-
if (Utils.isNullOrUndefined(contextParameters)) {
|
|
426
|
-
const errorTxt = `Caller defined Profile parameter [${sources.profileParameterName}] but contextParameters is null!`;
|
|
427
|
-
index_2.Log.writeLine(index_2.LogLevels.Error, errorTxt);
|
|
428
|
-
throw new Error("Settings: " + errorTxt);
|
|
429
|
-
}
|
|
430
|
-
else {
|
|
431
|
-
returnValue = index_1.JsonUtils.getPropertiesMatchingPath(contextParameters, sources.profileParameterName)[0].value;
|
|
432
|
-
index_2.Log.writeLine(logLevel, debugString + `profile property [${sources.profileParameterName}] value: <${returnValue}>`);
|
|
433
|
-
}
|
|
434
|
-
return returnValue;
|
|
435
|
-
}
|
|
436
|
-
/**
|
|
437
|
-
* Sets a process environment variable and saves its original value for later restoration.
|
|
438
|
-
*
|
|
439
|
-
* The first time a variable is set, its original value is saved under a prefixed key.
|
|
440
|
-
* Subsequent sets to the same variable do not overwrite the saved original.
|
|
441
|
-
* Call {@link resetProcessEnvs} to restore all modified variables.
|
|
442
|
-
*
|
|
443
|
-
* @param varName - Name of the environment variable to set.
|
|
444
|
-
* @param requiredValue - Value to assign.
|
|
445
|
-
* @note If the variable did not previously exist, it is stored as `"_undefined"` so that
|
|
446
|
-
* {@link resetProcessEnvs} knows to delete it rather than restore a blank value.
|
|
447
|
-
*/
|
|
448
|
-
static setProcessEnv(varName, requiredValue) {
|
|
449
|
-
index_2.Log.writeLine(index_2.LogLevels.TestInformation, `Setting env var [${varName}] to '${requiredValue}'`);
|
|
450
|
-
const originalValueKeyName = ENV_VAR_ORIGINAL_PREAMBLE + varName;
|
|
451
|
-
if (originalValueKeyName in process.env) {
|
|
452
|
-
index_2.Log.writeLine(index_2.LogLevels.TestDebug, `Env var [${varName}] has already been set (original value saved) — not overwriting saved original`);
|
|
453
|
-
index_2.Log.writeLine(index_2.LogLevels.FrameworkInformation, `Original env var value is saved on first set only, to ensure the pre-test value can be restored.\nSubsequent sets do not overwrite the saved original.`);
|
|
454
|
-
}
|
|
455
|
-
else {
|
|
456
|
-
const oldValue = process.env[varName];
|
|
457
|
-
// Store "_undefined" if the var didn't previously exist, so resetProcessEnvs knows to delete it
|
|
458
|
-
process.env[ENV_VAR_ORIGINAL_PREAMBLE + varName] = Utils.isUndefined(oldValue) ? "_undefined" : oldValue;
|
|
459
|
-
}
|
|
460
|
-
process.env[varName] = String(requiredValue);
|
|
461
|
-
}
|
|
462
|
-
/**
|
|
463
|
-
* Restores all process environment variables that were modified by {@link setProcessEnv}
|
|
464
|
-
* back to their original values. Variables that did not previously exist are deleted.
|
|
465
|
-
*
|
|
466
|
-
* @see {@link setProcessEnv}
|
|
467
|
-
* @throws {Error} If an error occurs while resetting variables.
|
|
468
|
-
*/
|
|
469
|
-
static resetProcessEnvs() {
|
|
470
|
-
try {
|
|
471
|
-
Object.entries(process.env).forEach(([key, value]) => {
|
|
472
|
-
if (key.startsWith(ENV_VAR_ORIGINAL_PREAMBLE)) {
|
|
473
|
-
const varToSet = key.substring(ENV_VAR_ORIGINAL_PREAMBLE.length);
|
|
474
|
-
if (value === "_undefined") {
|
|
475
|
-
index_2.Log.writeLine(index_2.LogLevels.FrameworkDebug, `Found [${key}] (Value: ${value}) so deleting [${varToSet}] and [${key}]`);
|
|
476
|
-
delete process.env[varToSet];
|
|
477
|
-
}
|
|
478
|
-
else {
|
|
479
|
-
index_2.Log.writeLine(index_2.LogLevels.FrameworkDebug, `Found [${key}] (Value: ${value}) so restoring [${varToSet}] to [${value}] and deleting [${key}]`);
|
|
480
|
-
process.env[varToSet] = value;
|
|
481
|
-
delete process.env[key];
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
});
|
|
485
|
-
}
|
|
486
|
-
catch (err) {
|
|
487
|
-
const errMess = `Error resetting environment variables: ${err.message}`;
|
|
488
|
-
index_2.Log.writeLine(index_2.LogLevels.Error, errMess);
|
|
489
|
-
throw new Error(errMess);
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
// ── Object / JSON ─────────────────────────────────────────────────────────
|
|
493
|
-
/**
|
|
494
|
-
* Returns a deep clone of an object or JSON string.
|
|
495
|
-
* Uses JSON parse/stringify — only JSON-serialisable values are supported.
|
|
496
|
-
*
|
|
497
|
-
* @param original - The object or JSON5 string to clone.
|
|
498
|
-
* @returns A deep clone of `original`.
|
|
499
|
-
* @throws {Error} If `original` is not valid JSON (JSON5 allowed).
|
|
500
|
-
*/
|
|
501
|
-
static clone(original) {
|
|
502
|
-
if (index_1.JsonUtils.isJson(original, true)) {
|
|
503
|
-
return index_1.JsonUtils.parse(typeof original === "string" ? original : JSON.stringify(original), true);
|
|
504
|
-
}
|
|
505
|
-
else {
|
|
506
|
-
const errText = "Object passed in is not valid JSON (JSON5 allowed) so cannot be cloned using JSON";
|
|
507
|
-
index_2.Log.writeLine(index_2.LogLevels.Error, errText);
|
|
508
|
-
throw new Error(errText);
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
/**
|
|
512
|
-
* Converts a URL glob pattern to an equivalent `RegExp`.
|
|
513
|
-
*
|
|
514
|
-
* Supported glob syntax:
|
|
515
|
-
* - `*` — matches any sequence of non-`/` characters
|
|
516
|
-
* - `**` — matches any path segment sequence (including `/`)
|
|
517
|
-
* - `?` — matches any single character
|
|
518
|
-
* - `{a,b}` — matches either `a` or `b`
|
|
519
|
-
* - `[...]` — character class, passed through as-is
|
|
520
|
-
*
|
|
521
|
-
* @param glob - The glob pattern to convert.
|
|
522
|
-
* @param options - Optional anchoring flags:
|
|
523
|
-
* - `startOfLine` — Anchors the pattern to the start of the string (default: `true`).
|
|
524
|
-
* - `endOfLine` — Anchors the pattern to the end of the string (default: `true`).
|
|
525
|
-
* @returns A `RegExp` equivalent to the given glob.
|
|
526
|
-
*
|
|
527
|
-
* @example
|
|
528
|
-
* Utils.globToRegex("src/**\/*.ts").test("src/foo/bar.ts"); // true
|
|
529
|
-
*/
|
|
530
|
-
static globToRegex(glob, options) {
|
|
531
|
-
const startOfLine = options?.startOfLine ?? true;
|
|
532
|
-
const endOfLine = options?.endOfLine ?? true;
|
|
533
|
-
const charsToEscape = new Set(["$", "^", "+", ".", "*", "(", ")", "|", "\\", "?", "{", "}", "[", "]"]);
|
|
534
|
-
const regexExpression = startOfLine ? ["^"] : ["^.*"];
|
|
535
|
-
let inRegexpGroup = false;
|
|
536
|
-
for (let globCharIndex = 0; globCharIndex < glob.length; ++globCharIndex) {
|
|
537
|
-
const currentGlobChar = glob[globCharIndex];
|
|
538
|
-
if (currentGlobChar === "\\" && globCharIndex + 1 < glob.length) {
|
|
539
|
-
const nextGlobChar = glob[++globCharIndex];
|
|
540
|
-
regexExpression.push(charsToEscape.has(nextGlobChar) ? "\\" + nextGlobChar : nextGlobChar);
|
|
541
|
-
continue;
|
|
542
|
-
}
|
|
543
|
-
if (currentGlobChar === "*") {
|
|
544
|
-
const previousGlobChar = glob[globCharIndex - 1];
|
|
545
|
-
let starCount = 1;
|
|
546
|
-
while (glob[globCharIndex + 1] === "*") {
|
|
547
|
-
starCount++;
|
|
548
|
-
globCharIndex++;
|
|
549
|
-
}
|
|
550
|
-
const nextGlobChar = glob[globCharIndex + 1];
|
|
551
|
-
if (starCount > 1 && (previousGlobChar === "/" || previousGlobChar === undefined) && (nextGlobChar === "/" || nextGlobChar === undefined)) {
|
|
552
|
-
// eslint-disable-next-line no-useless-escape
|
|
553
|
-
regexExpression.push("((?:[^/]*(?:/|$))*)");
|
|
554
|
-
globCharIndex++;
|
|
555
|
-
}
|
|
556
|
-
else {
|
|
557
|
-
regexExpression.push("([^/]*)");
|
|
558
|
-
}
|
|
559
|
-
continue;
|
|
560
|
-
}
|
|
561
|
-
switch (currentGlobChar) {
|
|
562
|
-
case "?":
|
|
563
|
-
regexExpression.push(".");
|
|
564
|
-
break;
|
|
565
|
-
case "[":
|
|
566
|
-
regexExpression.push("[");
|
|
567
|
-
break;
|
|
568
|
-
case "]":
|
|
569
|
-
regexExpression.push("]");
|
|
570
|
-
break;
|
|
571
|
-
case "{":
|
|
572
|
-
inRegexpGroup = true;
|
|
573
|
-
regexExpression.push("(");
|
|
574
|
-
break;
|
|
575
|
-
case "}":
|
|
576
|
-
inRegexpGroup = false;
|
|
577
|
-
regexExpression.push(")");
|
|
578
|
-
break;
|
|
579
|
-
case ",":
|
|
580
|
-
regexExpression.push(inRegexpGroup ? "|" : "\\" + currentGlobChar);
|
|
581
|
-
break;
|
|
582
|
-
default:
|
|
583
|
-
regexExpression.push(charsToEscape.has(currentGlobChar) ? "\\" + currentGlobChar : currentGlobChar);
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
regexExpression.push(endOfLine ? "$" : ".*$");
|
|
587
|
-
return new RegExp(regexExpression.join(""));
|
|
588
|
-
}
|
|
589
|
-
// ── HTML ──────────────────────────────────────────────────────────────────
|
|
590
|
-
/**
|
|
591
|
-
* Decodes HTML entities in a string back to their corresponding characters.
|
|
592
|
-
*
|
|
593
|
-
* - Named entities (e.g. `&`, `©`) and numeric entities (`©`, `👍`) are decoded.
|
|
594
|
-
* - Non-breaking space characters (U+00A0) are replaced with regular spaces.
|
|
595
|
-
* - Literal apostrophes (`'`) are replaced with `'` before decoding for consistent handling.
|
|
596
|
-
*
|
|
597
|
-
* @param str - The HTML-encoded string to decode.
|
|
598
|
-
* @returns The decoded string.
|
|
599
|
-
* @throws {Error} If `str` is not a string.
|
|
600
|
-
*/
|
|
601
|
-
static unescapeHTML(str) {
|
|
602
|
-
Utils.assertType(str, "string", "unescapeHTML", "str");
|
|
603
|
-
const preProcessed = str.replace(/'/g, "'");
|
|
604
|
-
return (0, entities_1.decodeHTML)(preProcessed).replace(/\u00A0/g, " ");
|
|
605
|
-
}
|
|
606
|
-
// ── JWT ───────────────────────────────────────────────────────────────────
|
|
607
|
-
/**
|
|
608
|
-
* Creates a signed JWT token.
|
|
609
|
-
*
|
|
610
|
-
* @param payloadData - The JWT payload as an object or JSON string.
|
|
611
|
-
* @param signature - The signing secret or private key.
|
|
612
|
-
* @param options - Signing options: either a partial options object with an optional
|
|
613
|
-
* `algorithm` (default `"HS256"`), or a raw JSON string for the JWT header.
|
|
614
|
-
* @returns A signed Base64 JWT token string.
|
|
615
|
-
* @throws {Error} If signing fails.
|
|
616
|
-
*/
|
|
617
|
-
static createJWT(payloadData, signature, options) {
|
|
618
|
-
let payload;
|
|
619
|
-
let optionsJWT;
|
|
620
|
-
if (typeof options === "string") {
|
|
621
|
-
index_2.Log.writeLine(index_2.LogLevels.FrameworkDebug, `Options is a string: [${options}]`);
|
|
622
|
-
optionsJWT = index_3.StringUtils.trimQuotes(options);
|
|
623
|
-
}
|
|
624
|
-
else {
|
|
625
|
-
index_2.Log.writeLine(index_2.LogLevels.FrameworkDebug, `Options not a string:\n${JSON.stringify(options, null, 2)}`);
|
|
626
|
-
optionsJWT = { algorithm: options?.algorithm ?? "HS256" };
|
|
627
|
-
}
|
|
628
|
-
if (index_1.JsonUtils.isJson(payloadData, true)) {
|
|
629
|
-
index_2.Log.writeLine(index_2.LogLevels.FrameworkDebug, `Payload is JSON [${typeof payloadData === "string" ? "string" : "object"}]`);
|
|
630
|
-
payload = typeof payloadData === "string" ? index_1.JsonUtils.parse(payloadData, true) : payloadData;
|
|
631
|
-
}
|
|
632
|
-
else {
|
|
633
|
-
index_2.Log.writeLine(index_2.LogLevels.Error, `Payload is NOT JSON (may be intended by test): [${payloadData}]`);
|
|
634
|
-
payload = payloadData;
|
|
635
|
-
}
|
|
636
|
-
const normalizedSignature = index_3.StringUtils.replaceAll(signature, '\\\\n', '\n');
|
|
637
|
-
index_2.Log.writeLine(index_2.LogLevels.FrameworkDebug, `Signature: ${normalizedSignature}`);
|
|
638
|
-
const jwtHeader = typeof options === "string" ? { header: index_1.JsonUtils.parse(optionsJWT, true) } : optionsJWT;
|
|
639
|
-
index_2.Log.writeLine(index_2.LogLevels.FrameworkDebug, `JWT Sign options:\n${JSON.stringify(jwtHeader, null, 2)}`);
|
|
640
|
-
try {
|
|
641
|
-
return (0, jsonwebtoken_1.sign)(payload, normalizedSignature, jwtHeader);
|
|
642
|
-
}
|
|
643
|
-
catch (err) {
|
|
644
|
-
const errText = `Error creating [${typeof options === 'string' ? options : JSON.stringify(options)}] JWT token from [${payloadData}] (signature: [${index_3.StringUtils.replaceAll(signature, '\\\\n', '<NEWLINE>')}]): ${err.message}`;
|
|
645
|
-
index_2.Log.writeLine(index_2.LogLevels.Error, errText);
|
|
646
|
-
throw new Error(errText);
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
/**
|
|
650
|
-
* Checks whether a string is a structurally valid JWT token.
|
|
651
|
-
*
|
|
652
|
-
* @param jwtToken - The token string to validate.
|
|
653
|
-
* @returns `true` if the token can be decoded, `false` otherwise.
|
|
654
|
-
*/
|
|
655
|
-
static isValidJWT(jwtToken) {
|
|
656
|
-
try {
|
|
657
|
-
return !Utils.isUndefined((0, jsonwebtoken_1.decode)(jwtToken));
|
|
658
|
-
}
|
|
659
|
-
catch {
|
|
660
|
-
return false;
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
/**
|
|
664
|
-
* Decodes and returns the payload of a JWT token as an object.
|
|
665
|
-
*
|
|
666
|
-
* @param jwtToken - A valid JWT token string.
|
|
667
|
-
* @returns The decoded payload as an object.
|
|
668
|
-
* @throws {Error} If the token cannot be decoded or the payload is not an object.
|
|
669
|
-
*/
|
|
670
|
-
static getJWTPayload(jwtToken) {
|
|
671
|
-
try {
|
|
672
|
-
let payload = (0, jsonwebtoken_1.decode)(jwtToken, { json: true });
|
|
673
|
-
payload = Utils.isNull(payload) ? {} : payload;
|
|
674
|
-
if (typeof payload !== "object") {
|
|
675
|
-
throw new Error(`Not an object. Is [${typeof payload}]. Expected JSON object.`);
|
|
676
|
-
}
|
|
677
|
-
return payload;
|
|
678
|
-
}
|
|
679
|
-
catch (err) {
|
|
680
|
-
const errText = `Error getting payload from JWT [${jwtToken ?? "<Undefined>"}]: ${err.message}`;
|
|
681
|
-
index_2.Log.writeLine(index_2.LogLevels.Error, errText);
|
|
682
|
-
throw new Error(errText);
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
// ── Process management ────────────────────────────────────────────────────
|
|
686
|
-
/**
|
|
687
|
-
* Spawns a command as a background process.
|
|
688
|
-
*
|
|
689
|
-
* @param command - The command to execute.
|
|
690
|
-
* @param args - Arguments to pass to the command.
|
|
691
|
-
* @param options - Optional settings:
|
|
692
|
-
* - `logStdout` — Logs stdout at `TestInformation` level (default: `false`).
|
|
693
|
-
* - `logStderr` — Logs stderr and process errors at `Error` level (default: `false`).
|
|
694
|
-
* - `spawnOptions` — Additional options passed to `child_process.spawn`.
|
|
695
|
-
* @returns The spawned `ChildProcess`.
|
|
696
|
-
* @throws {Error} If the process fails to start (PID is `undefined`).
|
|
697
|
-
*/
|
|
698
|
-
static spawnBackgroundProcess(command, args, { logStdout = false, logStderr = false, spawnOptions = undefined } = {}) {
|
|
699
|
-
index_2.Log.writeLine(index_2.LogLevels.TestInformation, `Executing: ${command} ${args.join(' ')}`);
|
|
700
|
-
const childProcess = (0, child_process_1.spawn)(command, args, spawnOptions);
|
|
701
|
-
if (childProcess?.pid === undefined) {
|
|
702
|
-
const errText = `Unable to spawn [${command}] with args [${args.join(', ')}] and options [${spawnOptions === undefined ? '' : JSON.stringify(spawnOptions)}] — spawn returned undefined PID`;
|
|
703
|
-
index_2.Log.writeLine(index_2.LogLevels.Error, errText);
|
|
704
|
-
throw new Error(errText);
|
|
705
|
-
}
|
|
706
|
-
index_2.Log.writeLine(index_2.LogLevels.TestInformation, `Started process: PID ${childProcess.pid}`);
|
|
707
|
-
if (logStdout) {
|
|
708
|
-
childProcess.stdout.on('data', (data) => {
|
|
709
|
-
index_2.Log.writeLine(index_2.LogLevels.TestInformation, `Background(stdout): ${data.toString()}`, { suppressAllPreamble: true });
|
|
710
|
-
});
|
|
711
|
-
}
|
|
712
|
-
if (logStderr) {
|
|
713
|
-
childProcess.stderr.on('data', (data) => {
|
|
714
|
-
index_2.Log.writeLine(index_2.LogLevels.TestInformation, `Background(stderr): ${data.toString()}`, { suppressAllPreamble: true });
|
|
715
|
-
});
|
|
716
|
-
childProcess.on('error', (err) => {
|
|
717
|
-
index_2.Log.writeLine(index_2.LogLevels.Error, `Background process error: ${err.message}`, { suppressAllPreamble: true });
|
|
718
|
-
});
|
|
719
|
-
}
|
|
720
|
-
return childProcess;
|
|
721
|
-
}
|
|
722
|
-
/**
|
|
723
|
-
* Spawns a command as a background process and waits for it to exit, with a timeout.
|
|
724
|
-
*
|
|
725
|
-
* @param command - The command to execute.
|
|
726
|
-
* @param args - Arguments to pass to the command.
|
|
727
|
-
* @param timeoutSeconds - Maximum time in seconds to wait before forcibly terminating the process.
|
|
728
|
-
* @param options - Optional settings (same as {@link spawnBackgroundProcess}).
|
|
729
|
-
* @returns A promise resolving to the process exit code, or `-1` on timeout or error.
|
|
730
|
-
*/
|
|
731
|
-
static async spawnBackgroundProcessWithTimeout(command, args, timeoutSeconds, { logStdout = false, logStderr = false, spawnOptions = undefined } = {}) {
|
|
732
|
-
return new Promise((resolve) => {
|
|
733
|
-
const child = Utils.spawnBackgroundProcess(command, args, { logStdout, logStderr, spawnOptions });
|
|
734
|
-
let exited = false;
|
|
735
|
-
child.on('exit', (code) => {
|
|
736
|
-
if (!exited) {
|
|
737
|
-
index_2.Log.writeLine(index_2.LogLevels.TestInformation, `Process [${child.pid ?? 'unknown'}] exited. Code: ${code ?? 'undefined!'}`);
|
|
738
|
-
exited = true;
|
|
739
|
-
clearTimeout(timeout);
|
|
740
|
-
resolve(code ?? -1);
|
|
741
|
-
}
|
|
742
|
-
});
|
|
743
|
-
child.on('error', (err) => {
|
|
744
|
-
if (!exited) {
|
|
745
|
-
index_2.Log.writeLine(index_2.LogLevels.Error, `Process [${child.pid ?? 'unknown'}] errored:\n${err ?? 'unknown error'}`);
|
|
746
|
-
exited = true;
|
|
747
|
-
clearTimeout(timeout);
|
|
748
|
-
resolve(-1);
|
|
749
|
-
}
|
|
750
|
-
});
|
|
751
|
-
process.on('SIGINT', () => {
|
|
752
|
-
index_2.Log.writeLine(index_2.LogLevels.Error, `Caught SIGINT (Ctrl+C) — terminating child process [${child.pid ?? 'unknown'}]...`);
|
|
753
|
-
Utils.terminateBackgroundProcess(child, { signal: 'SIGINT' });
|
|
754
|
-
});
|
|
755
|
-
const timeout = setTimeout(() => {
|
|
756
|
-
if (!exited) {
|
|
757
|
-
const errMessage = `Timeout — child process [${child.pid ?? 'unknown'}] did not exit within ${timeoutSeconds} seconds`;
|
|
758
|
-
index_2.Log.writeLine(index_2.LogLevels.Error, errMessage);
|
|
759
|
-
exited = true;
|
|
760
|
-
Utils.terminateBackgroundProcess(child);
|
|
761
|
-
resolve(-1);
|
|
762
|
-
}
|
|
763
|
-
}, timeoutSeconds * MS_PER_SECOND);
|
|
764
|
-
});
|
|
765
|
-
}
|
|
766
|
-
/**
|
|
767
|
-
* Executes a shell command and returns its stdout output.
|
|
768
|
-
*
|
|
769
|
-
* @param command - The shell command to run.
|
|
770
|
-
* @returns A promise resolving to the stdout string.
|
|
771
|
-
* @throws The error or stderr string if the command fails.
|
|
772
|
-
*/
|
|
773
|
-
static async execCommand(command) {
|
|
774
|
-
return new Promise((resolve, reject) => {
|
|
775
|
-
index_2.Log.writeLine(index_2.LogLevels.FrameworkInformation, `Exec command: >> ${command} <<`);
|
|
776
|
-
(0, child_process_1.exec)(command, (error, stdout, stderr) => {
|
|
777
|
-
if (error || stderr) {
|
|
778
|
-
index_2.Log.writeLine(index_2.LogLevels.FrameworkDebug, `Error thrown so rejecting (${stderr ?? ''}): \n${error?.message ?? 'No error detail!'}`);
|
|
779
|
-
reject(error || stderr);
|
|
780
|
-
}
|
|
781
|
-
else {
|
|
782
|
-
index_2.Log.writeLine(index_2.LogLevels.FrameworkDebug, `Resolved: >> ${stdout ?? ''} <<`);
|
|
783
|
-
resolve(stdout);
|
|
784
|
-
}
|
|
785
|
-
});
|
|
786
|
-
});
|
|
787
|
-
}
|
|
788
|
-
/**
|
|
789
|
-
* Checks whether a process with the given PID is currently running.
|
|
790
|
-
*
|
|
791
|
-
* @param pid - The process ID to check.
|
|
792
|
-
* @returns `true` if the process is running (or exists but cannot be signalled), `false` if it does not exist.
|
|
793
|
-
* @throws Any unexpected error from `process.kill`.
|
|
794
|
-
*/
|
|
795
|
-
static isProcessRunning(pid) {
|
|
796
|
-
try {
|
|
797
|
-
process.kill(pid, 0);
|
|
798
|
-
return true;
|
|
799
|
-
}
|
|
800
|
-
catch (err) {
|
|
801
|
-
if (err instanceof Error && typeof err.code === 'string') {
|
|
802
|
-
const error = err;
|
|
803
|
-
if (error.code === 'ESRCH')
|
|
804
|
-
return false;
|
|
805
|
-
if (error.code === 'EPERM')
|
|
806
|
-
return true;
|
|
807
|
-
}
|
|
808
|
-
throw err;
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
/**
|
|
812
|
-
* Sends a signal to a process and all of its descendants, leaf-first (post-order traversal).
|
|
813
|
-
*
|
|
814
|
-
* @param rootPid - PID of the root process to terminate.
|
|
815
|
-
* @param signal - Signal to send (default: `"SIGKILL"`).
|
|
816
|
-
*/
|
|
817
|
-
static async killProcessAndDescendants(rootPid, signal = 'SIGKILL') {
|
|
818
|
-
const children = await new Promise((resolve, reject) => {
|
|
819
|
-
(0, ps_tree_1.default)(rootPid, (err, result) => {
|
|
820
|
-
if (err)
|
|
821
|
-
return reject(err);
|
|
822
|
-
resolve([...result]);
|
|
823
|
-
});
|
|
824
|
-
});
|
|
825
|
-
const tree = new Map();
|
|
826
|
-
for (const proc of children) {
|
|
827
|
-
const pid = Number(proc.PID);
|
|
828
|
-
const ppid = Number(proc.PPID);
|
|
829
|
-
if (!tree.has(ppid))
|
|
830
|
-
tree.set(ppid, []);
|
|
831
|
-
tree.get(ppid).push(pid);
|
|
832
|
-
}
|
|
833
|
-
const killRecursively = (pid) => {
|
|
834
|
-
const childPids = tree.get(pid) ?? [];
|
|
835
|
-
for (const childPid of childPids)
|
|
836
|
-
killRecursively(childPid);
|
|
837
|
-
try {
|
|
838
|
-
process.kill(pid, signal);
|
|
839
|
-
}
|
|
840
|
-
catch (err) {
|
|
841
|
-
index_2.Log.writeLine(index_2.LogLevels.Error, `Killing process [${pid}] with [${signal}] threw error (ignoring): ${err.message}`);
|
|
842
|
-
}
|
|
843
|
-
};
|
|
844
|
-
killRecursively(rootPid);
|
|
845
|
-
}
|
|
846
|
-
/**
|
|
847
|
-
* Terminates a background process and all its descendants, waiting for the process to close.
|
|
848
|
-
*
|
|
849
|
-
* @param backgroundProcess - The process to terminate.
|
|
850
|
-
* @param options - Optional settings:
|
|
851
|
-
* - `signal` — Signal to use (default: `"SIGKILL"`).
|
|
852
|
-
* @returns A promise resolving to `true` once the process closes, or `false` if no valid process was provided.
|
|
853
|
-
*/
|
|
854
|
-
static async terminateBackgroundProcess(backgroundProcess, options = {}) {
|
|
855
|
-
const signal = (options.signal ?? 'SIGKILL');
|
|
856
|
-
if (Utils.isNullOrUndefined(backgroundProcess)) {
|
|
857
|
-
index_2.Log.writeLine(index_2.LogLevels.TestInformation, `No background process executing — nothing to terminate`);
|
|
858
|
-
return false;
|
|
859
|
-
}
|
|
860
|
-
if (Utils.isNullOrUndefined(backgroundProcess.pid)) {
|
|
861
|
-
index_2.Log.writeLine(index_2.LogLevels.Error, `Background process has no PID — cannot terminate`);
|
|
862
|
-
return false;
|
|
863
|
-
}
|
|
864
|
-
const processPid = backgroundProcess.pid;
|
|
865
|
-
const promise = new Promise((resolve) => {
|
|
866
|
-
backgroundProcess.on('close', (code, signal) => {
|
|
867
|
-
index_2.Log.writeLine(index_2.LogLevels.TestInformation, `Process [${processPid}] closed [Code: ${code ?? '<No Code>'}], Signal: ${signal ?? 'None'}`);
|
|
868
|
-
resolve(true);
|
|
869
|
-
});
|
|
870
|
-
});
|
|
871
|
-
await this.killProcessAndDescendants(processPid, signal);
|
|
872
|
-
return promise;
|
|
873
|
-
}
|
|
874
|
-
// ── Promise utilities ─────────────────────────────────────────────────────
|
|
875
|
-
/**
|
|
876
|
-
* Pauses execution for the given number of milliseconds.
|
|
877
|
-
*
|
|
878
|
-
* @param periodMS - Duration to wait in milliseconds.
|
|
879
|
-
* @param logIt - When `true`, logs the sleep duration at `FrameworkDebug` level (default: `false`).
|
|
880
|
-
* @returns A promise that resolves after the given duration.
|
|
881
|
-
*/
|
|
882
|
-
static async sleep(periodMS, logIt) {
|
|
883
|
-
periodMS = Number(periodMS);
|
|
884
|
-
if (logIt === true)
|
|
885
|
-
index_2.Log.writeLine(index_2.LogLevels.FrameworkDebug, `Sleeping for [${periodMS}] milliseconds`);
|
|
886
|
-
return new Promise((resolve) => {
|
|
887
|
-
setTimeout(resolve, periodMS);
|
|
888
|
-
});
|
|
889
|
-
}
|
|
890
|
-
/**
|
|
891
|
-
* Pauses Node.js execution until a keyboard key is pressed, while keeping the event loop running.
|
|
892
|
-
*
|
|
893
|
-
* @param logOutput - Optional message to write to the log before pausing.
|
|
894
|
-
* @returns A promise that resolves when a key is pressed.
|
|
895
|
-
* @warning This hangs indefinitely in non-TTY environments (e.g. CI pipelines).
|
|
896
|
-
* In such cases the call is logged and returns immediately.
|
|
897
|
-
*/
|
|
898
|
-
static async pause(logOutput) {
|
|
899
|
-
if (logOutput) {
|
|
900
|
-
index_2.Log.writeLine(index_2.LogLevels.TestInformation, logOutput);
|
|
901
|
-
}
|
|
902
|
-
const stdin = process.stdin;
|
|
903
|
-
if (!stdin.isTTY) {
|
|
904
|
-
index_2.Log.writeLine(index_2.LogLevels.Error, "Stdin is not a TTY — cannot pause for key press. Ignoring.");
|
|
905
|
-
return;
|
|
906
|
-
}
|
|
907
|
-
return new Promise((resolve) => {
|
|
908
|
-
const cleanup = () => {
|
|
909
|
-
stdin.setRawMode(false);
|
|
910
|
-
stdin.pause();
|
|
911
|
-
stdin.removeListener("data", onData);
|
|
912
|
-
};
|
|
913
|
-
const onData = (chunk) => {
|
|
914
|
-
index_2.Log.writeLine(index_2.LogLevels.TestDebug, `Input received: ${JSON.stringify(chunk.toString(), null, 2)}`);
|
|
915
|
-
cleanup();
|
|
916
|
-
resolve();
|
|
917
|
-
};
|
|
918
|
-
// Drain any buffered input before attaching the listener
|
|
919
|
-
stdin.resume();
|
|
920
|
-
while (stdin.read() !== null) { /* draining */ }
|
|
921
|
-
stdin.pause();
|
|
922
|
-
stdin.once("data", onData);
|
|
923
|
-
stdin.setRawMode(true);
|
|
924
|
-
stdin.resume();
|
|
925
|
-
});
|
|
926
|
-
}
|
|
927
|
-
/**
|
|
928
|
-
* Wraps a promise with a timeout and tracks it in the outstanding promise count.
|
|
929
|
-
*
|
|
930
|
-
* The count is accessible via {@link promiseCount} and can be checked at the end of a
|
|
931
|
-
* test to verify all promises have settled. Use {@link resetPromiseCount} to clear
|
|
932
|
-
* the count between tests.
|
|
933
|
-
*
|
|
934
|
-
* @param promise - The promise to wrap.
|
|
935
|
-
* @param options - Optional settings:
|
|
936
|
-
* - `timeoutMS` — Timeout in milliseconds. Falls back to {@link defaultPromiseTimeout} if not given.
|
|
937
|
-
* - `friendlyName` — Name shown in the timeout error message (defaults to the caller's function name).
|
|
938
|
-
* @returns A promise that resolves/rejects with the original result, or rejects with a timeout error.
|
|
939
|
-
* @throws {Error} If no timeout is configured (neither `timeoutMS` nor `defaultPromiseTimeout` is set).
|
|
940
|
-
* @throws {Error} If `timeoutMS` is negative.
|
|
941
|
-
*/
|
|
942
|
-
static async timeoutPromise(promise, options = {}) {
|
|
943
|
-
Utils._promiseCount++;
|
|
944
|
-
const operationName = options?.friendlyName ?? (this.inferCallerFunctionName() || "Unknown operation");
|
|
945
|
-
try {
|
|
946
|
-
if (Utils.isNullOrUndefined(options?.timeoutMS) && Utils._defaultPromiseTimeout === 0) {
|
|
947
|
-
const errText = 'Utils.timeoutPromise: No timeout given and default not set (have you initialised the default timeout?)';
|
|
948
|
-
index_2.Log.writeLine(index_2.LogLevels.Error, errText);
|
|
949
|
-
throw new Error(errText);
|
|
950
|
-
}
|
|
951
|
-
const actualTimeout = (Utils.isNullOrUndefined(options?.timeoutMS) ? Utils._defaultPromiseTimeout : options?.timeoutMS);
|
|
952
|
-
if (actualTimeout < 0) {
|
|
953
|
-
const errText = `Utils.timeoutPromise: Timeout cannot be negative. Was [${actualTimeout}]`;
|
|
954
|
-
index_2.Log.writeLine(index_2.LogLevels.Error, errText);
|
|
955
|
-
throw new Error(errText);
|
|
956
|
-
}
|
|
957
|
-
return this.withTimeout(promise, { timeoutMS: actualTimeout, friendlyName: operationName });
|
|
958
|
-
}
|
|
959
|
-
finally {
|
|
960
|
-
Utils._promiseCount--;
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
|
-
/**
|
|
964
|
-
* @deprecated Use {@link timeoutPromise} instead.
|
|
965
|
-
*/
|
|
966
|
-
static async withTimeoutTracked(promise, timeoutMs) {
|
|
967
|
-
return this.withTimeout(promise, { timeoutMS: timeoutMs, friendlyName: this.inferCallerFunctionName() ?? undefined });
|
|
968
|
-
}
|
|
969
|
-
// ── Action parsing ────────────────────────────────────────────────────────
|
|
970
|
-
/**
|
|
971
|
-
* Parses a raw action string into a verb and a parameter map.
|
|
972
|
-
*
|
|
973
|
-
* The expected format is `actionName(param1: value1, param2: value2)` where the
|
|
974
|
-
* parameter block is valid JSON5 without the outer braces. If no parentheses are
|
|
975
|
-
* present, `parameters` will be an empty map.
|
|
976
|
-
*
|
|
977
|
-
* @param rawAction - The raw action string to parse.
|
|
978
|
-
* @returns A {@link Utils.ActionAndParams} object with `action`, `normalizedAction`, and `parameters`.
|
|
979
|
-
* @throws {Error} If the parameter block is not valid JSON5.
|
|
980
|
-
*
|
|
981
|
-
* @example
|
|
982
|
-
* Utils.splitActionAndParameters("click(target: '#btn', force: true)");
|
|
983
|
-
* // => { action: "click", normalizedAction: "click", parameters: Map { "target" => "#btn", "force" => true } }
|
|
984
|
-
*/
|
|
985
|
-
static splitActionAndParameters(rawAction) {
|
|
986
|
-
const actionAndParameters = index_3.StringUtils.splitVerbAndParameters(rawAction);
|
|
987
|
-
const normalizedAction = actionAndParameters.verb.toLowerCase().trim();
|
|
988
|
-
if (index_3.StringUtils.isBlank(actionAndParameters.parameters)) {
|
|
989
|
-
return { action: actionAndParameters.verb, normalizedAction, parameters: new Map() };
|
|
990
|
-
}
|
|
991
|
-
else {
|
|
992
|
-
const paramsJSON = "{" + actionAndParameters.parameters + "}";
|
|
993
|
-
if (index_1.JsonUtils.isJson(paramsJSON, true)) {
|
|
994
|
-
const paramsMap = new Map(Object.entries(index_1.JsonUtils.parse(paramsJSON, true)));
|
|
995
|
-
return { action: actionAndParameters.verb, normalizedAction, parameters: paramsMap };
|
|
996
|
-
}
|
|
997
|
-
else {
|
|
998
|
-
const errText = `Invalid action [${actionAndParameters.verb}] parameter syntax. Expected (param1: <value>, param2: <value2>, ...). Got: (${actionAndParameters.parameters})`;
|
|
999
|
-
index_2.Log.writeLine(index_2.LogLevels.Error, errText);
|
|
1000
|
-
throw new Error(errText);
|
|
1001
|
-
}
|
|
1002
|
-
}
|
|
1003
|
-
}
|
|
1004
|
-
// ── Enum helpers ──────────────────────────────────────────────────────────
|
|
1005
|
-
/**
|
|
1006
|
-
* Checks whether a value exists as a member of the given enum.
|
|
1007
|
-
*
|
|
1008
|
-
* @param enumType - The enum object to check against.
|
|
1009
|
-
* @param valueToCheck - The value to look for.
|
|
1010
|
-
* @returns `true` if the value is a member of the enum, `false` otherwise.
|
|
1011
|
-
*/
|
|
1012
|
-
static checkENUMValueExists(enumType, valueToCheck) {
|
|
1013
|
-
try {
|
|
1014
|
-
return Object.values(enumType).includes(valueToCheck);
|
|
1015
|
-
}
|
|
1016
|
-
catch {
|
|
1017
|
-
return false;
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1020
|
-
// ── Private helpers ───────────────────────────────────────────────────────
|
|
1021
|
-
/**
|
|
1022
|
-
* Converts data to a string suitable for writing to a file:
|
|
1023
|
-
* - JSON strings are normalised (parsed then re-stringified)
|
|
1024
|
-
* - Non-JSON strings are used as-is
|
|
1025
|
-
* - Objects are pretty-printed as JSON
|
|
1026
|
-
*/
|
|
1027
|
-
static serialiseFileData(data) {
|
|
1028
|
-
if (typeof data === "string") {
|
|
1029
|
-
return index_1.JsonUtils.isJson(data) ? JSON.stringify(JSON.parse(data)) : data;
|
|
1030
|
-
}
|
|
1031
|
-
return JSON.stringify(data, null, 2);
|
|
1032
|
-
}
|
|
1033
|
-
static async withTimeout(promise, options = {}) {
|
|
1034
|
-
return Promise.race([
|
|
1035
|
-
promise,
|
|
1036
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error(`${Utils.isNullOrUndefined(options?.friendlyName) ? 'T' : `${options?.friendlyName}: T`}imeout after ${options?.timeoutMS ?? '0'} ms`)), options?.timeoutMS ?? 0)),
|
|
1037
|
-
]);
|
|
1038
|
-
}
|
|
1039
|
-
static inferCallerFunctionName() {
|
|
1040
|
-
const error = new Error();
|
|
1041
|
-
const stackLines = error.stack?.split("\n") || [];
|
|
1042
|
-
const callerLine = stackLines[3] || "";
|
|
1043
|
-
const match = callerLine.match(/at (.+?) \(/);
|
|
1044
|
-
return match ? match[1] : null;
|
|
1045
|
-
}
|
|
1046
|
-
}
|
|
1047
|
-
exports.Utils = Utils;
|
|
1048
|
-
// ── Private state ────────────────────────────────────────────────────────
|
|
1049
|
-
Utils._promiseCount = 0;
|
|
1050
|
-
Utils._defaultPromiseTimeout = 0;
|