@controlium/utils 0.0.0

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,859 @@
1
+ import { readFileSync } from "fs";
2
+ import { format } from "date-fns";
3
+ // ----------------------------
4
+ // Module-level defaults
5
+ // ----------------------------
6
+ const DEFAULT_VIDEO_CODEC = "webm";
7
+ const DEFAULT_VIDEO_WIDTH = 320;
8
+ const DEFAULT_VIDEO_HEIGHT = 180;
9
+ const DEFAULT_WRITELINE_MAX_LINES = 55;
10
+ const DEFAULT_WRITELINE_SUPPRESS_ELAPSED = false;
11
+ const DEFAULT_WRITELINE_SUPPRESS_TIME = false;
12
+ const DEFAULT_WRITELINE_SUPPRESS_ALL = false;
13
+ const DEFAULT_WRITELINE_SUPPRESS_MULTI = false;
14
+ const DEFAULT_WRITELINE_FORMAT_TIME = "HH:mm:ss";
15
+ const DEFAULT_WRITELINE_FORMAT_ELAPSED = "mm:ss.SSS";
16
+ const DEFAULT_LOG_LEVEL = 6; // Framework debug
17
+ const DEFAULT_LOG_TO_CONSOLE = false;
18
+ const DEFAULT_THROW_ERROR_LOG_FAIL = false;
19
+ const DEFAULT_PANIC_MODE = false;
20
+ const DEFAULT_PANIC_CODE = "P";
21
+ const DEFAULT_PANIC_DESCRIPTOR = "P: ";
22
+ // ----------------------------
23
+ // Internal constants
24
+ // ----------------------------
25
+ /** Maximum length of a data/mediaType string shown in error messages before truncation. */
26
+ const ERROR_DISPLAY_STRING_MAX_LENGTH = 30;
27
+ /** Number of characters kept from the start of a truncated error-display string. */
28
+ const ERROR_DISPLAY_STRING_HEAD_LENGTH = 25;
29
+ /** Number of characters kept from the end of a truncated error-display string. */
30
+ const ERROR_DISPLAY_STRING_TAIL_LENGTH = 3;
31
+ /** Minimum character-width used when zero-padding the write-type label. */
32
+ const WRITE_TYPE_PAD_WIDTH = 5;
33
+ /** Stack-frame substring used to identify internal Logger frames and skip them. */
34
+ const LOGGER_STACK_FRAME_MARKER = "logger.ts";
35
+ export class Logger {
36
+ /**
37
+ * Resets the logger to its default configuration.
38
+ *
39
+ * Clears the output callback and restores all options to their default values.
40
+ * Optionally resets the elapsed-time start point to the current moment.
41
+ *
42
+ * @param resetStartTime - When `true`, the start time used for elapsed-time
43
+ * calculation is reset to now. When `false` (the default), the existing
44
+ * start time is preserved so elapsed time continues from the original baseline.
45
+ *
46
+ * @example
47
+ * Logger.reset(); // Reset options only, keep elapsed-time baseline
48
+ * Logger.reset(true); // Reset options and restart the elapsed timer
49
+ */
50
+ static reset(resetStartTime = false) {
51
+ if (resetStartTime) {
52
+ this.startTime = Date.now();
53
+ }
54
+ this.clearOutputCallback();
55
+ this.options = Logger.buildDefaultOptions();
56
+ }
57
+ // ----------------------------
58
+ // Public Getters/Setters
59
+ // ----------------------------
60
+ /**
61
+ * Controls whether log output is written to the console.
62
+ *
63
+ * When `true`, every log line is written to `console.log()` in addition
64
+ * to any configured {@link logOutputCallback}.
65
+ * When `false`, output is sent only to the callback (if defined).
66
+ *
67
+ * @example
68
+ * Logger.logToConsole = true;
69
+ */
70
+ static set logToConsole(value) {
71
+ this.options.logToConsole = value;
72
+ }
73
+ /**
74
+ * Returns whether log output is currently being written to the console.
75
+ *
76
+ * @returns `true` if console logging is enabled; otherwise `false`.
77
+ */
78
+ static get logToConsole() {
79
+ return this.options.logToConsole;
80
+ }
81
+ /**
82
+ * Enables or disables Panic Mode.
83
+ *
84
+ * When `true`, the current log level is ignored and all log calls produce
85
+ * output — equivalent to setting the log level to `Verbose`.
86
+ * When `false`, normal log-level filtering applies.
87
+ *
88
+ * @example
89
+ * Logger.panicMode = true; // Force all output regardless of log level
90
+ */
91
+ static set panicMode(value) {
92
+ this.options.panicMode = value;
93
+ }
94
+ /**
95
+ * Returns whether Panic Mode is currently active.
96
+ *
97
+ * When `true`, all log output is written regardless of the configured log level.
98
+ *
99
+ * @returns `true` if Panic Mode is enabled; otherwise `false`.
100
+ */
101
+ static get panicMode() {
102
+ return this.options.panicMode;
103
+ }
104
+ /**
105
+ * Controls whether an exception is thrown when a log-output operation fails.
106
+ *
107
+ * When `true`, any error raised while executing the configured
108
+ * {@link logOutputCallback} is re-thrown to the caller.
109
+ * When `false`, such errors are suppressed and logged internally instead.
110
+ *
111
+ * @example
112
+ * Logger.throwErrorIfLogOutputFails = true;
113
+ */
114
+ static set throwErrorIfLogOutputFails(value) {
115
+ this.options.throwErrorIfLogOutputFails = value;
116
+ }
117
+ /**
118
+ * Returns whether log-output failures are re-thrown as exceptions.
119
+ *
120
+ * @returns `true` if logging errors are propagated to the caller;
121
+ * `false` if they are suppressed and logged internally.
122
+ */
123
+ static get throwErrorIfLogOutputFails() {
124
+ return this.options.throwErrorIfLogOutputFails;
125
+ }
126
+ /**
127
+ * Returns the current global logging level.
128
+ *
129
+ * Only messages with a level less than or equal to this value
130
+ * (or within the configured {@link loggingFilter} range) will be output.
131
+ *
132
+ * @returns The current numeric log level.
133
+ *
134
+ * @see {@link Levels} for named level constants.
135
+ */
136
+ static get loggingLevel() {
137
+ return this.options.loggingCurrentLevel;
138
+ }
139
+ /**
140
+ * Sets the global logging level.
141
+ *
142
+ * Accepts a named level string (case-insensitive, spaces ignored),
143
+ * or a non-negative integer. Unknown strings and invalid numbers
144
+ * fall back to `FrameworkDebug` and emit a warning.
145
+ *
146
+ * @param requiredLevel - The desired level as a `Levels` constant, number, or string.
147
+ *
148
+ * @example
149
+ * Logger.loggingLevel = Logger.Levels.Warning;
150
+ * Logger.loggingLevel = 3;
151
+ * Logger.loggingLevel = "TestInformation";
152
+ */
153
+ static set loggingLevel(requiredLevel) {
154
+ if (typeof requiredLevel === "string") {
155
+ this.options.loggingCurrentLevel = this.levelFromText(requiredLevel);
156
+ }
157
+ else {
158
+ if (Number.isInteger(requiredLevel) && requiredLevel >= 0) {
159
+ this.options.loggingCurrentLevel = requiredLevel;
160
+ }
161
+ else {
162
+ this.options.loggingCurrentLevel = this.Levels.FrameworkDebug;
163
+ Logger.writeLine(this.Levels.Warning, `Invalid Log Level [${requiredLevel}] (Must be integer greater than zero). Defaulting to Framework Debug!`);
164
+ }
165
+ }
166
+ }
167
+ /**
168
+ * Returns the current logging level as a human-readable string.
169
+ *
170
+ * @returns A descriptive string for the current log level,
171
+ * e.g. `"Test information (TSINF)"`.
172
+ */
173
+ static get loggingLevelText() {
174
+ return this.levelToText(this.loggingLevel);
175
+ }
176
+ /**
177
+ * Builds a human-readable description of the active logging configuration,
178
+ * combining the current level with any active filter range.
179
+ *
180
+ * Includes a Panic Mode preamble prefix when {@link panicMode} is active.
181
+ *
182
+ * @param level - The current logging level.
183
+ * @param minLevel - The minimum level of the active filter range (`NoOutput` if no filter).
184
+ * @param maxLevel - The maximum level of the active filter range (`NoOutput` if no filter).
185
+ * @returns A descriptive string. Returns the plain level name when no valid
186
+ * filter range is active.
187
+ *
188
+ * @example
189
+ * Logger.loggingLevelDescription(Logger.Levels.FrameworkDebug, Logger.Levels.NoOutput, Logger.Levels.NoOutput);
190
+ * // => "Framework debug (FKDBG)"
191
+ */
192
+ static loggingLevelDescription(level, minLevel, maxLevel) {
193
+ const currentLevelText = this.levelToText(level);
194
+ const preamble = this.options.panicMode
195
+ ? this.options.panicDescriptorPreamble
196
+ : "";
197
+ // If a valid min/max range is set (neither is NoOutput AND min <= max)
198
+ if (minLevel !== this.Levels.NoOutput && maxLevel !== this.Levels.NoOutput) {
199
+ if (minLevel === maxLevel) {
200
+ return (preamble +
201
+ `Levels [${this.levelToText(minLevel)}] and [${currentLevelText}]`);
202
+ }
203
+ else if (minLevel <= maxLevel) {
204
+ return (preamble +
205
+ `Between levels [${this.levelToText(minLevel)} and ${this.levelToText(maxLevel)}] and level ${currentLevelText}`);
206
+ }
207
+ }
208
+ // Fallback: no filter range active, or inverted range — return plain level name
209
+ return preamble + currentLevelText;
210
+ }
211
+ /**
212
+ * Returns the current log-level filter range.
213
+ *
214
+ * When both `min` and `max` are `NoOutput` (i.e. `0`), no filter is active
215
+ * and only {@link loggingLevel} controls output.
216
+ * When a valid range is set, messages whose level falls within
217
+ * `[min, max]` are also output regardless of the current logging level.
218
+ *
219
+ * @returns An object with `min` and `max` numeric level values.
220
+ */
221
+ static get loggingFilter() {
222
+ return {
223
+ min: this.options.filterMinCurrentLevel,
224
+ max: this.options.filterMaxCurrentLevel
225
+ };
226
+ }
227
+ /**
228
+ * Sets a log-level filter range for additional output inclusion.
229
+ *
230
+ * Messages whose level falls within `[min, max]` will be output even if
231
+ * they fall outside the current {@link loggingLevel}. Pass `NoOutput` (or
232
+ * omit) for either bound to disable that side of the filter.
233
+ *
234
+ * Both bounds accept a named level string or a non-negative integer.
235
+ * Invalid values fall back to `NoOutput` and emit a warning.
236
+ *
237
+ * @param min - Lower bound of the filter range (inclusive).
238
+ * @param max - Upper bound of the filter range (inclusive).
239
+ *
240
+ * @example
241
+ * Logger.loggingFilter = { min: Logger.Levels.Error, max: Logger.Levels.Warning };
242
+ */
243
+ static set loggingFilter({ min, max }) {
244
+ if (typeof min === "string") {
245
+ this.options.filterMinCurrentLevel = this.levelFromText(min);
246
+ }
247
+ else {
248
+ if (Number.isInteger(min) && Number(min) >= 0) {
249
+ this.options.filterMinCurrentLevel = min;
250
+ }
251
+ else {
252
+ this.options.filterMinCurrentLevel = this.Levels.NoOutput;
253
+ Logger.writeLine(this.Levels.Warning, `Invalid Log Level [${min}] (Must be integer greater than zero). Defaulting to NoOutput!`);
254
+ }
255
+ }
256
+ if (typeof max === "string") {
257
+ this.options.filterMaxCurrentLevel = this.levelFromText(max);
258
+ }
259
+ else {
260
+ if (Number.isInteger(max) && Number(max) >= 0) {
261
+ this.options.filterMaxCurrentLevel = max;
262
+ }
263
+ else {
264
+ this.options.filterMaxCurrentLevel = this.Levels.NoOutput;
265
+ Logger.writeLine(this.Levels.Warning, `Invalid Log Level [${max}] (Must be integer greater than zero). Defaulting to NoOutput!`);
266
+ }
267
+ }
268
+ }
269
+ /**
270
+ * Returns the current `writeLine` formatting options.
271
+ *
272
+ * These control preamble suppression, max lines, timestamp format,
273
+ * and elapsed time format for log lines written via {@link writeLine}.
274
+ *
275
+ * @returns The active {@link WriteLineOptions} configuration object.
276
+ */
277
+ static get writeLineOptions() {
278
+ return this.options.writeLine;
279
+ }
280
+ /**
281
+ * Returns the current video attachment options.
282
+ *
283
+ * These defaults are used by {@link attachVideo} and {@link attachVideoFile}
284
+ * when no explicit options are provided.
285
+ *
286
+ * @returns The active {@link VideoOptions} configuration object.
287
+ */
288
+ static get videoOptions() {
289
+ return this.options.video;
290
+ }
291
+ /**
292
+ * Sets the video attachment options used when attaching video to log output.
293
+ *
294
+ * Provided values are merged with the current options. If `height` or `width`
295
+ * fall outside the valid resolution limits, both dimensions are ignored and
296
+ * the existing values are retained.
297
+ *
298
+ * @param options - Partial or full {@link VideoOptions} to apply.
299
+ *
300
+ * @example
301
+ * Logger.videoOptions = { videoCodec: "mp4", width: 1280, height: 720 };
302
+ */
303
+ static set videoOptions(options) {
304
+ this.options.video = {
305
+ videoCodec: options.videoCodec ?? this.options.video.videoCodec,
306
+ ...this.checkAndGetVideoResolution(options)
307
+ };
308
+ }
309
+ /**
310
+ * Clears the {@link logOutputCallback}, disabling callback-based log output.
311
+ *
312
+ * After calling this, log output will only go to the console
313
+ * (if {@link logToConsole} is `true`), or be silently dropped.
314
+ *
315
+ * @example
316
+ * Logger.clearOutputCallback();
317
+ */
318
+ static clearOutputCallback() {
319
+ this.logOutputCallback = undefined;
320
+ }
321
+ // ----------------------------
322
+ // Public Attach Methods
323
+ // ----------------------------
324
+ /**
325
+ * Attaches a screenshot to the log output at the specified log level.
326
+ *
327
+ * The screenshot is base64-encoded and passed to {@link logOutputCallback}
328
+ * with a media type of `"base64:image/png"`. Accepts either a raw `Buffer`
329
+ * or an existing base64 string.
330
+ *
331
+ * Has no effect if the given `logLevel` does not pass the current level filter.
332
+ *
333
+ * @param logLevel - The log level at which to attach the screenshot.
334
+ * @param screenshot - A `Buffer` containing raw PNG data, or a base64-encoded string.
335
+ *
336
+ * @example
337
+ * Logger.attachScreenshot(Logger.Levels.TestDebug, screenshotBuffer);
338
+ */
339
+ static attachScreenshot(logLevel, screenshot) {
340
+ if (this.logLevelOk(logLevel)) {
341
+ if (typeof screenshot === "string") {
342
+ screenshot = Buffer.from(screenshot).toString("base64");
343
+ }
344
+ else {
345
+ screenshot = screenshot.toString("base64");
346
+ }
347
+ this.attach(logLevel, screenshot, "base64:image/png");
348
+ }
349
+ }
350
+ /**
351
+ * Attaches an HTML string to the log output at the specified log level.
352
+ *
353
+ * The HTML is passed to {@link logOutputCallback} with a media type of `"text/html"`.
354
+ * Useful for attaching rich content such as tables or styled reports.
355
+ *
356
+ * Has no effect if the given `logLevel` does not pass the current level filter.
357
+ *
358
+ * @param logLevel - The log level at which to attach the HTML.
359
+ * @param htmlString - A valid HTML string to attach.
360
+ *
361
+ * @example
362
+ * Logger.attachHTML(Logger.Levels.TestInformation, "<b>Test passed</b>");
363
+ */
364
+ static attachHTML(logLevel, htmlString) {
365
+ this.attach(logLevel, htmlString, "text/html");
366
+ }
367
+ /**
368
+ * Reads a video file from disk and attaches it to the log output.
369
+ *
370
+ * The file at `videoFilePath` is read as a `Buffer` and passed to
371
+ * {@link attachVideo}. If the file cannot be read, an error is processed
372
+ * via {@link throwErrorIfLogOutputFails}.
373
+ *
374
+ * Has no effect if the given `logLevel` does not pass the current level filter.
375
+ *
376
+ * @param logLevel - The log level at which to attach the video.
377
+ * @param videoFilePath - Absolute or relative path to the video file.
378
+ * @param options - Optional {@link VideoOptions} overriding the current defaults.
379
+ *
380
+ * @example
381
+ * Logger.attachVideoFile(Logger.Levels.TestDebug, "./recordings/run.webm");
382
+ */
383
+ static attachVideoFile(logLevel, videoFilePath, options = this.videoOptions) {
384
+ if (this.logLevelOk(logLevel)) {
385
+ let videoBuffer;
386
+ try {
387
+ videoBuffer = readFileSync(videoFilePath);
388
+ }
389
+ catch (err) {
390
+ const errText = `Error thrown reading video data from given file path:-\n${err.message}`;
391
+ this.processError(errText);
392
+ return;
393
+ }
394
+ this.attachVideo(logLevel, videoBuffer, options);
395
+ }
396
+ }
397
+ /**
398
+ * Attaches a video buffer to the log output at the specified log level.
399
+ *
400
+ * The video is base64-encoded and wrapped in an HTML `<video>` element,
401
+ * then passed to {@link logOutputCallback} with a media type of `"text/html"`.
402
+ * When {@link panicMode} is active, the video element is given a
403
+ * `title="PANIC_MODE"` attribute.
404
+ *
405
+ * Has no effect if the given `logLevel` does not pass the current level filter.
406
+ *
407
+ * @param logLevel - The log level at which to attach the video.
408
+ * @param video - A `Buffer` containing the raw video data.
409
+ * @param options - Optional {@link VideoOptions} overriding the current defaults.
410
+ *
411
+ * @example
412
+ * Logger.attachVideo(Logger.Levels.TestDebug, videoBuffer, { width: 640, height: 360 });
413
+ */
414
+ static attachVideo(logLevel, video, options) {
415
+ const actualOptions = options == null
416
+ ? this.options.video
417
+ : {
418
+ videoCodec: options.videoCodec ?? this.options.video.videoCodec,
419
+ ...this.checkAndGetVideoResolution(options)
420
+ };
421
+ const videoSourceString = `data:video/${actualOptions.videoCodec};base64,` +
422
+ video.toString("base64");
423
+ const videoStringNoData = `<video controls width="${actualOptions.width}" height="${actualOptions.height}"${this.panicMode ? ` title="PANIC_MODE"` : ""}><source src="<Video Data>" type="video/${actualOptions.videoCodec}">Video (Codec ${actualOptions.videoCodec}) not supported by browser</video>`;
424
+ const videoString = videoStringNoData.replace("<Video Data>", videoSourceString);
425
+ this.attach(logLevel, videoString, "text/html");
426
+ }
427
+ /**
428
+ * Passes arbitrary data to the {@link logOutputCallback} at the specified log level.
429
+ *
430
+ * If the callback is not set, an error is logged instead. If the callback throws,
431
+ * the error is processed via {@link throwErrorIfLogOutputFails}.
432
+ *
433
+ * Has no effect if the given `logLevel` does not pass the current level filter.
434
+ *
435
+ * @param logLevel - The log level at which to attach the data.
436
+ * @param dataString - The data payload to pass to the callback.
437
+ * @param mediaType - A MIME-type-style string describing the data
438
+ * (e.g. `"text/html"`, `"base64:image/png"`).
439
+ *
440
+ * @example
441
+ * Logger.attach(Logger.Levels.TestDebug, myPayload, "text/plain");
442
+ */
443
+ static attach(logLevel, dataString, mediaType) {
444
+ if (this.logLevelOk(logLevel)) {
445
+ if (typeof this.logOutputCallback === "function") {
446
+ try {
447
+ this.logOutputCallback(dataString, mediaType);
448
+ }
449
+ catch (err) {
450
+ const errText = `Error thrown from Log Output Callback:-\n${err.message}\nwhen called with data string:-\n${this.truncateForDisplay(dataString)}\nand mediaType:-\n${this.truncateForDisplay(mediaType)}`;
451
+ this.processError(errText);
452
+ }
453
+ }
454
+ else {
455
+ Logger.writeLine(this.Levels.Error, `Log Output callback is type [${typeof this.logOutputCallback}]. Must be type [function]. No attach performed.`);
456
+ }
457
+ }
458
+ }
459
+ // ----------------------------
460
+ // Public writeLine
461
+ // ----------------------------
462
+ /**
463
+ * Writes a log line (or multiple lines) at the specified log level.
464
+ *
465
+ * Multi-line strings are split and each line is written individually.
466
+ * If the total number of lines exceeds `maxLines`, intermediate lines
467
+ * are replaced with a single truncation notice.
468
+ *
469
+ * Each line is prefixed with a preamble (timestamp, elapsed time, calling method,
470
+ * and level label) unless suppressed via `options` or the global
471
+ * {@link writeLineOptions} configuration.
472
+ *
473
+ * Has no effect if the given `logLevel` does not pass the current level filter
474
+ * (unless {@link panicMode} is active).
475
+ *
476
+ * @param logLevel - The log level at which to write the message.
477
+ * @param textString - The message to log. May contain newlines.
478
+ * @param options - Optional per-call overrides for {@link WriteLineOptions}.
479
+ *
480
+ * @example
481
+ * Logger.writeLine(Logger.Levels.TestInformation, "Test started");
482
+ * Logger.writeLine(Logger.Levels.Warning, "Something looks off", { suppressAllPreamble: true });
483
+ */
484
+ static writeLine(logLevel, textString, options) {
485
+ const stackObj = {};
486
+ Error.captureStackTrace(stackObj, this.writeLine);
487
+ const stack = stackObj?.stack ?? "[Unknown]";
488
+ const callingMethodDetails = this.callingMethodDetails(stack);
489
+ const maxLines = options?.maxLines ?? this.options.writeLine.maxLines;
490
+ const suppressAllPreamble = options?.suppressAllPreamble ??
491
+ this.options.writeLine.suppressAllPreamble;
492
+ const suppressMultilinePreamble = options?.suppressMultilinePreamble ??
493
+ this.options.writeLine.suppressMultilinePreamble;
494
+ const suppressTimeStamp = options?.suppressTimeStamp ?? this.options.writeLine.suppressTimeStamp;
495
+ const suppressElapsed = options?.suppressElapsed ?? this.options.writeLine.suppressElapsed;
496
+ const timeFormat = suppressTimeStamp
497
+ ? undefined
498
+ : (options?.timeFormat ?? this.options.writeLine.timeFormat);
499
+ const elapsedFormat = suppressElapsed
500
+ ? undefined
501
+ : (options?.elapsedFormat ?? this.options.writeLine.elapsedFormat);
502
+ if (!stack.includes(".doWriteLine")) {
503
+ const normalizedMaxLines = maxLines < 1 ? 1 : maxLines;
504
+ const textArray = textString.split(/\r?\n/);
505
+ let isFirstLine = true;
506
+ if (normalizedMaxLines < 3) {
507
+ const errorMessage = `maxLines must be 3 or greater! Number given was <${maxLines}>`;
508
+ Logger.writeLine(this.Levels.Error, errorMessage);
509
+ throw new Error(errorMessage);
510
+ }
511
+ textArray.forEach((line, index) => {
512
+ if (textArray.length <= normalizedMaxLines ||
513
+ index < normalizedMaxLines - 2 ||
514
+ index === textArray.length - 1) {
515
+ this.doWriteLine(!(suppressAllPreamble || (suppressMultilinePreamble && !isFirstLine)), callingMethodDetails, logLevel, line, { time: timeFormat, elapsed: elapsedFormat });
516
+ }
517
+ else if (index === normalizedMaxLines - 2) {
518
+ this.doWriteLine(!(suppressAllPreamble || (suppressMultilinePreamble && !isFirstLine)), callingMethodDetails, logLevel, `... (Skipping some lines as total length (${textArray.length}) > ${normalizedMaxLines}!!)`, { time: timeFormat, elapsed: elapsedFormat });
519
+ }
520
+ isFirstLine = false;
521
+ });
522
+ }
523
+ }
524
+ // ----------------------------
525
+ // Private Utilities
526
+ // ----------------------------
527
+ /** Builds a fresh default options object. Extracted so both the field initialiser and reset() share one source of truth. */
528
+ static buildDefaultOptions() {
529
+ return {
530
+ loggingCurrentLevel: DEFAULT_LOG_LEVEL,
531
+ filterMinCurrentLevel: Logger.Levels.NoOutput,
532
+ filterMaxCurrentLevel: Logger.Levels.NoOutput,
533
+ logToConsole: DEFAULT_LOG_TO_CONSOLE,
534
+ throwErrorIfLogOutputFails: DEFAULT_THROW_ERROR_LOG_FAIL,
535
+ panicMode: DEFAULT_PANIC_MODE,
536
+ panicCodePreamble: DEFAULT_PANIC_CODE,
537
+ panicDescriptorPreamble: DEFAULT_PANIC_DESCRIPTOR,
538
+ writeLine: {
539
+ maxLines: DEFAULT_WRITELINE_MAX_LINES,
540
+ suppressTimeStamp: DEFAULT_WRITELINE_SUPPRESS_TIME,
541
+ suppressElapsed: DEFAULT_WRITELINE_SUPPRESS_ELAPSED,
542
+ suppressAllPreamble: DEFAULT_WRITELINE_SUPPRESS_ALL,
543
+ suppressMultilinePreamble: DEFAULT_WRITELINE_SUPPRESS_MULTI,
544
+ timeFormat: DEFAULT_WRITELINE_FORMAT_TIME,
545
+ elapsedFormat: DEFAULT_WRITELINE_FORMAT_ELAPSED
546
+ },
547
+ video: {
548
+ videoCodec: DEFAULT_VIDEO_CODEC,
549
+ width: DEFAULT_VIDEO_WIDTH,
550
+ height: DEFAULT_VIDEO_HEIGHT
551
+ }
552
+ };
553
+ }
554
+ static levelToText(level) {
555
+ switch (level) {
556
+ case this.Levels.FrameworkDebug:
557
+ return "Framework debug (FKDBG)";
558
+ case this.Levels.FrameworkInformation:
559
+ return "Framework information (FKINF)";
560
+ case this.Levels.TestDebug:
561
+ return "Test debug (TSDBG)";
562
+ case this.Levels.TestInformation:
563
+ return "Test information (TSINF)";
564
+ case this.Levels.Warning:
565
+ return "Errors (ERROR) & Warnings (WARNING) only";
566
+ case this.Levels.Error:
567
+ return "Errors only (ERROR)";
568
+ case this.Levels.NoOutput:
569
+ return "No output from Log (NOOUT)";
570
+ default:
571
+ return level < 0
572
+ ? "Unknown!"
573
+ : `Special Level - (${this.options.loggingCurrentLevel})`;
574
+ }
575
+ }
576
+ static levelFromText(text) {
577
+ // Strip all whitespace so e.g. "Framework Debug", "frameworkdebug", "framework debug" all match
578
+ switch (text.toLowerCase().replace(/\s+/g, "")) {
579
+ case "special":
580
+ case "verbose":
581
+ case "maximum":
582
+ case "max":
583
+ return Number.MAX_SAFE_INTEGER;
584
+ case "frameworkdebug":
585
+ case "fkdbg":
586
+ return this.Levels.FrameworkDebug;
587
+ case "frameworkinformation":
588
+ case "fkinf":
589
+ return this.Levels.FrameworkInformation;
590
+ case "testdebug":
591
+ case "tsdbg":
592
+ return this.Levels.TestDebug;
593
+ case "testinformation":
594
+ case "tsinf":
595
+ return this.Levels.TestInformation;
596
+ case "warn":
597
+ case "warng":
598
+ case "warning":
599
+ return this.Levels.Warning;
600
+ case "error":
601
+ return this.Levels.Error;
602
+ case "nooutput":
603
+ case "noout":
604
+ return this.Levels.NoOutput;
605
+ default: {
606
+ const actualLevel = this.options.loggingCurrentLevel;
607
+ this.options.loggingCurrentLevel = this.Levels.FrameworkDebug;
608
+ Logger.writeLine(this.Levels.Warning, `Unknown Log Level [${text}]. Defaulting to Framework Debug!`);
609
+ this.options.loggingCurrentLevel = actualLevel;
610
+ return this.Levels.FrameworkDebug;
611
+ }
612
+ }
613
+ }
614
+ static checkAndGetVideoResolution(resolution) {
615
+ const heightValid = resolution.height == null ||
616
+ this.isValidVideoResolutionNumber(resolution.height, this.videoResolutionLimits.minHeight, this.videoResolutionLimits.maxHeight, `Invalid video window height [${resolution.height}]: must be number equal or between ${this.videoResolutionLimits.minHeight} and ${this.videoResolutionLimits.maxHeight}. Height (and width if set) ignored`);
617
+ const widthValid = resolution.width == null ||
618
+ this.isValidVideoResolutionNumber(resolution.width, this.videoResolutionLimits.minWidth, this.videoResolutionLimits.maxWidth, `Invalid video window width [${resolution.width}]: must be number equal or between ${this.videoResolutionLimits.minWidth} and ${this.videoResolutionLimits.maxWidth}. Width (and height if set) ignored`);
619
+ const height = (resolution.height == null
620
+ ? this.options.video.height
621
+ : heightValid && widthValid
622
+ ? resolution.height
623
+ : this.options.video.height);
624
+ const width = (resolution.width == null
625
+ ? this.options.video.width
626
+ : heightValid && widthValid
627
+ ? resolution.width
628
+ : this.options.video.width);
629
+ return { height, width };
630
+ }
631
+ static doWriteLine(doPreamble, callingMethodDetails, logLevel, textString, formatOptions) {
632
+ if (this.logLevelOk(logLevel)) {
633
+ const callBackGood = typeof this.logOutputCallback === "function";
634
+ const preAmble = doPreamble
635
+ ? this.getPreAmble(callingMethodDetails, logLevel, formatOptions)
636
+ : "";
637
+ const textToWrite = preAmble + textString;
638
+ let doneConsoleWrite = false;
639
+ let doneCallbackWrite = false;
640
+ if (this.options.logToConsole) {
641
+ console.log(textToWrite);
642
+ doneConsoleWrite = true;
643
+ }
644
+ if (callBackGood) {
645
+ try {
646
+ this.logOutputCallback(textToWrite);
647
+ doneCallbackWrite = true;
648
+ }
649
+ catch (err) {
650
+ const errText = `Error thrown from Log Output Callback during writeLine:-\n${err.message}`;
651
+ // Avoid infinite recursion: log directly to console rather than calling processError -> writeLine
652
+ console.error(errText);
653
+ if (this.options.throwErrorIfLogOutputFails) {
654
+ throw new Error(errText);
655
+ }
656
+ }
657
+ }
658
+ if (!doneConsoleWrite && !doneCallbackWrite) {
659
+ console.log(textToWrite);
660
+ }
661
+ }
662
+ }
663
+ static getPreAmble(methodBase, typeOfWrite, timeFormat) {
664
+ const writeType = (this.options.panicMode ? this.options.panicCodePreamble : "") +
665
+ this.getWriteTypeString(typeOfWrite);
666
+ // timeFormat.time is undefined when suppressTimeStamp is active
667
+ const timeStamp = timeFormat.time === undefined
668
+ ? ""
669
+ : `[${format(Date.now(), timeFormat.time)}]`;
670
+ // timeFormat.elapsed is undefined when suppressElapsed is active
671
+ const diff = Date.now() - this.startTime;
672
+ const utcDate = new Date(diff);
673
+ utcDate.setMinutes(utcDate.getMinutes() + utcDate.getTimezoneOffset());
674
+ const elapsedTime = timeFormat.elapsed === undefined
675
+ ? ""
676
+ : `[${format(utcDate, timeFormat.elapsed)}]`;
677
+ return `${writeType} - ${timeStamp}${elapsedTime} [${methodBase}]: `;
678
+ }
679
+ /**
680
+ * Left-pads a number (or numeric string) with zeroes to reach the required minimum length.
681
+ * Kept private and self-contained so `logger.ts` has no external utility dependencies.
682
+ */
683
+ static pad(num, requiredMinimumLength) {
684
+ let numString = num.toString();
685
+ while (numString.length < requiredMinimumLength) {
686
+ numString = "0" + numString;
687
+ }
688
+ return numString;
689
+ }
690
+ static getWriteTypeString(levelOfWrite) {
691
+ switch (levelOfWrite) {
692
+ case this.Levels.Error:
693
+ return "ERROR";
694
+ case this.Levels.Warning:
695
+ return "WARNG";
696
+ case this.Levels.FrameworkDebug:
697
+ return "FKDBG";
698
+ case this.Levels.FrameworkInformation:
699
+ return "FKINF";
700
+ case this.Levels.TestDebug:
701
+ return "TSDBG";
702
+ case this.Levels.TestInformation:
703
+ return "TSINF";
704
+ default:
705
+ return this.pad(levelOfWrite, WRITE_TYPE_PAD_WIDTH);
706
+ }
707
+ }
708
+ static callingMethodDetails(methodBase) {
709
+ let methodName = "<Unknown>";
710
+ let typeName = "";
711
+ if (methodBase) {
712
+ const methodBaseLines = methodBase.split("\n");
713
+ if (methodBaseLines.length > 1) {
714
+ // Skip past internal Logger stack frames to find the real caller
715
+ let indexOfFirstNonLogLine = methodBaseLines
716
+ .slice(1)
717
+ .findIndex((item) => !item.includes(LOGGER_STACK_FRAME_MARKER));
718
+ indexOfFirstNonLogLine =
719
+ indexOfFirstNonLogLine === -1 ? 1 : indexOfFirstNonLogLine + 1;
720
+ methodName = methodBaseLines[indexOfFirstNonLogLine]
721
+ .replace(/\s\s+/g, " ")
722
+ .trim();
723
+ if (methodName.startsWith("at ")) {
724
+ const tempA = methodName.split(" ");
725
+ methodName = tempA.slice(0, 1).concat([tempA.slice(1).join(" ")])[1];
726
+ if (methodName.includes("/") &&
727
+ methodName.includes(":") &&
728
+ methodName.includes(")")) {
729
+ typeName = methodName
730
+ .split("/")[methodName.split("/").length - 1].split(")")[0];
731
+ }
732
+ methodName = methodName.split(" ")[0];
733
+ }
734
+ }
735
+ }
736
+ return `${methodName}${typeName === "" ? "" : `(${typeName})`}`;
737
+ }
738
+ static isValidVideoResolutionNumber(val, min, max, errorMessage) {
739
+ if (typeof val === "number") {
740
+ if (Number.isInteger(val) && val >= min && val <= max)
741
+ return true;
742
+ Logger.writeLine(this.Levels.Warning, errorMessage);
743
+ return false;
744
+ }
745
+ else {
746
+ Logger.writeLine(this.Levels.Error, errorMessage);
747
+ throw new Error(`Resolution number given was a <${typeof val}>! Must only be a number!`);
748
+ }
749
+ }
750
+ static logLevelOk(passedInLogLevel) {
751
+ if (this.panicMode === true)
752
+ return true;
753
+ if (passedInLogLevel === this.Levels.NoOutput)
754
+ return false;
755
+ const withinCurrentLevel = passedInLogLevel <= this.options.loggingCurrentLevel;
756
+ const withinFilterRange = passedInLogLevel >= this.options.filterMinCurrentLevel &&
757
+ passedInLogLevel <= this.options.filterMaxCurrentLevel;
758
+ return withinCurrentLevel || withinFilterRange;
759
+ }
760
+ static processError(errorText) {
761
+ if (this.options.throwErrorIfLogOutputFails) {
762
+ throw new Error(errorText);
763
+ }
764
+ else {
765
+ Logger.writeLine(this.Levels.Error, errorText, {
766
+ suppressMultilinePreamble: true
767
+ });
768
+ }
769
+ }
770
+ /**
771
+ * Truncates a string for safe display in error messages.
772
+ * Shows the first {@link ERROR_DISPLAY_STRING_HEAD_LENGTH} characters, an ellipsis,
773
+ * and the last {@link ERROR_DISPLAY_STRING_TAIL_LENGTH} characters when the string
774
+ * exceeds {@link ERROR_DISPLAY_STRING_MAX_LENGTH}.
775
+ */
776
+ static truncateForDisplay(value) {
777
+ if (typeof value !== "string") {
778
+ return `<Not a string! Is type ${typeof value}>`;
779
+ }
780
+ if (value.length > ERROR_DISPLAY_STRING_MAX_LENGTH) {
781
+ return (value.slice(0, ERROR_DISPLAY_STRING_HEAD_LENGTH) +
782
+ "..." +
783
+ value.slice(-ERROR_DISPLAY_STRING_TAIL_LENGTH));
784
+ }
785
+ return value;
786
+ }
787
+ }
788
+ /**
789
+ * Numeric log level constants used to control and filter log output.
790
+ *
791
+ * Levels are ordered from highest verbosity to lowest:
792
+ * - `Maximum` / `Verbose` — log everything
793
+ * - `FrameworkDebug` — internal framework debug messages
794
+ * - `FrameworkInformation` — internal framework info messages
795
+ * - `TestDebug` — test-level debug messages
796
+ * - `TestInformation` — test-level info messages
797
+ * - `Warning` — warnings and errors
798
+ * - `Error` — errors only
799
+ * - `NoOutput` — suppress all output
800
+ *
801
+ * @example
802
+ * Logger.loggingLevel = Logger.Levels.TestInformation;
803
+ */
804
+ Logger.Levels = {
805
+ /** Maximum verbosity. Same numeric value as `Verbose`. */
806
+ Maximum: Number.MAX_SAFE_INTEGER,
807
+ /** Verbose logging. Same numeric value as `Maximum`. */
808
+ Verbose: Number.MAX_SAFE_INTEGER,
809
+ /** Framework-level debug logs. */
810
+ FrameworkDebug: 6,
811
+ /** Framework-level informational logs. */
812
+ FrameworkInformation: 5,
813
+ /** Test-level debug logs. */
814
+ TestDebug: 4,
815
+ /** Test-level informational logs. */
816
+ TestInformation: 3,
817
+ /** Warnings (and errors). */
818
+ Warning: 2,
819
+ /** Errors only. */
820
+ Error: 1,
821
+ /** No log output is allowed at this level. */
822
+ NoOutput: 0
823
+ };
824
+ // ----------------------------
825
+ // Private Static Properties
826
+ // ----------------------------
827
+ Logger.videoResolutionLimits = {
828
+ minHeight: 180,
829
+ maxHeight: 4320,
830
+ minWidth: 320,
831
+ maxWidth: 7680
832
+ };
833
+ Logger.options = Logger.buildDefaultOptions();
834
+ Logger.startTime = Date.now();
835
+ // ----------------------------
836
+ // Public Static Properties
837
+ // ----------------------------
838
+ /**
839
+ * Callback invoked for every log output (after formatting and preamble generation).
840
+ *
841
+ * If defined, the logger calls this function with:
842
+ * - `message`: The final formatted log line or attached payload.
843
+ * - `mediaType`: Optional MIME-type-style string.
844
+ * Examples:
845
+ * - `"text/plain"` for normal log lines
846
+ * - `"text/html"` for HTML attachments
847
+ * - `"base64:image/png"` for screenshots
848
+ *
849
+ * If the callback throws, the logger catches the error and processes it,
850
+ * reporting to the test suite if required.
851
+ *
852
+ * Set this to `undefined` (or call `Logger.clearOutputCallback()`) to disable callback output.
853
+ *
854
+ * @example
855
+ * Logger.logOutputCallback = (message, mediaType) => {
856
+ * myReporter.attach(message, mediaType ?? "text/plain");
857
+ * };
858
+ */
859
+ Logger.logOutputCallback = undefined;