@logtape/pretty 1.0.0-dev.231

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,782 @@
1
+ import { suite } from "@alinea/suite";
2
+ import { assert } from "@std/assert/assert";
3
+ import { assertEquals } from "@std/assert/equals";
4
+ import { assertMatch } from "@std/assert/match";
5
+ import { assertStringIncludes } from "@std/assert/string-includes";
6
+ import type { LogRecord } from "@logtape/logtape";
7
+ import {
8
+ type CategoryColorMap,
9
+ getPrettyFormatter,
10
+ prettyFormatter,
11
+ } from "./formatter.ts";
12
+
13
+ const test = suite(import.meta);
14
+
15
+ function createLogRecord(
16
+ level: LogRecord["level"],
17
+ category: string[],
18
+ message: LogRecord["message"],
19
+ timestamp: number = Date.now(),
20
+ ): LogRecord {
21
+ // Convert message array to template strings format for rawMessage
22
+ const rawMessage = typeof message === "string"
23
+ ? message
24
+ : message.filter((_, i) => i % 2 === 0).join("{}");
25
+
26
+ return {
27
+ level,
28
+ category,
29
+ message,
30
+ rawMessage,
31
+ properties: {},
32
+ timestamp,
33
+ };
34
+ }
35
+
36
+ test("prettyFormatter basic output", () => {
37
+ const record = createLogRecord(
38
+ "info",
39
+ ["app", "server"],
40
+ ["Server started on port ", 3000],
41
+ );
42
+
43
+ const output = prettyFormatter(record);
44
+
45
+ // Should contain emoji, level, category, and message
46
+ assertMatch(output, /✨/);
47
+ assertMatch(output, /info/); // Default level format is "full"
48
+ assertMatch(output, /app·server/);
49
+ assertMatch(output, /Server started on port/);
50
+ assertMatch(output, /3000/);
51
+ });
52
+
53
+ test("getPrettyFormatter() with no colors", () => {
54
+ const formatter = getPrettyFormatter({ colors: false });
55
+ const record = createLogRecord(
56
+ "error",
57
+ ["app", "auth"],
58
+ ["Authentication failed"],
59
+ );
60
+
61
+ const output = formatter(record);
62
+
63
+ // Should not contain ANSI escape codes
64
+ assertEquals(output.includes("\x1b["), false);
65
+ assertMatch(output, /❌ error/); // Default level format is "full"
66
+ assertMatch(output, /app·auth/);
67
+ });
68
+
69
+ test("getPrettyFormatter() with custom icons", () => {
70
+ const formatter = getPrettyFormatter({
71
+ icons: {
72
+ info: "ℹ️ ",
73
+ error: "🔥",
74
+ },
75
+ });
76
+
77
+ const infoRecord = createLogRecord("info", ["test"], ["Info message"]);
78
+ const errorRecord = createLogRecord("error", ["test"], ["Error message"]);
79
+
80
+ assertMatch(formatter(infoRecord), /ℹ️/);
81
+ assertMatch(formatter(errorRecord), /🔥/);
82
+ });
83
+
84
+ test("getPrettyFormatter() with no icons", () => {
85
+ const formatter = getPrettyFormatter({ icons: false });
86
+ const record = createLogRecord("info", ["test"], ["Message"]);
87
+
88
+ const output = formatter(record);
89
+
90
+ // Should not contain any emoji
91
+ assertEquals(output.includes("✨"), false);
92
+ assertEquals(output.includes("🐛"), false);
93
+ });
94
+
95
+ test("getPrettyFormatter() with timestamp", () => {
96
+ const timestamp = new Date("2024-01-15T12:34:56Z").getTime();
97
+
98
+ // Time only - note UTC timezone handling
99
+ const timeFormatter = getPrettyFormatter({ timestamp: "time" });
100
+ const record = createLogRecord("info", ["test"], ["Message"], timestamp);
101
+ const timeOutput = timeFormatter(record);
102
+ assertMatch(timeOutput, /\d{2}:\d{2}:\d{2}/);
103
+
104
+ // Date and time
105
+ const datetimeFormatter = getPrettyFormatter({ timestamp: "date-time" });
106
+ const datetimeOutput = datetimeFormatter(record);
107
+ assertMatch(datetimeOutput, /2024-01-15/);
108
+ assertMatch(datetimeOutput, /\d{2}:\d{2}:\d{2}/);
109
+
110
+ // Custom formatter
111
+ const customFormatter = getPrettyFormatter({
112
+ timestamp: (ts) => new Date(ts).toISOString(),
113
+ });
114
+ const customOutput = customFormatter(record);
115
+ assertMatch(customOutput, /2024-01-15T12:34:56/);
116
+
117
+ // Test function returning null
118
+ const nullFormatter = getPrettyFormatter({
119
+ timestamp: () => null,
120
+ });
121
+ const nullOutput = nullFormatter(record);
122
+ assertEquals(nullOutput.includes("2024"), false);
123
+
124
+ // Test none timestamp
125
+ const noneFormatter = getPrettyFormatter({ timestamp: "none" });
126
+ const noneOutput = noneFormatter(record);
127
+ assertEquals(noneOutput.includes("2024"), false);
128
+ });
129
+
130
+ test("getPrettyFormatter() category truncation", () => {
131
+ const formatter = getPrettyFormatter({
132
+ categoryWidth: 15,
133
+ categoryTruncate: "middle",
134
+ });
135
+
136
+ const record = createLogRecord(
137
+ "info",
138
+ ["app", "server", "http", "middleware"],
139
+ ["Request processed"],
140
+ );
141
+
142
+ const output = formatter(record);
143
+ // Category should be truncated and contain app
144
+ assertMatch(output, /app/);
145
+ assertMatch(output, /…/);
146
+ });
147
+
148
+ test("getPrettyFormatter() with null colors", () => {
149
+ const formatter = getPrettyFormatter({
150
+ levelColors: {
151
+ info: null, // No color
152
+ },
153
+ categoryColor: null,
154
+ });
155
+
156
+ const record = createLogRecord("info", ["test"], ["Message"]);
157
+ const result = formatter(record);
158
+ // Should work without errors and have basic formatting
159
+ assertStringIncludes(result, "info"); // Default level format is "full"
160
+ assertStringIncludes(result, "test");
161
+ assertStringIncludes(result, "Message");
162
+ });
163
+
164
+ test("getPrettyFormatter() with values", () => {
165
+ const formatter = getPrettyFormatter();
166
+ const record = createLogRecord(
167
+ "debug",
168
+ ["app"],
169
+ ["User data: ", { id: 123, name: "John" }, ", array: ", [1, 2, 3]],
170
+ );
171
+
172
+ const output = formatter(record);
173
+ assertMatch(output, /User data:/);
174
+ assertMatch(output, /123/);
175
+ assertMatch(output, /John/);
176
+ assertMatch(output, /1.*2.*3/);
177
+ });
178
+
179
+ test("getPrettyFormatter() all log levels", () => {
180
+ const formatter = getPrettyFormatter();
181
+
182
+ const levels: LogRecord["level"][] = [
183
+ "trace",
184
+ "debug",
185
+ "info",
186
+ "warning",
187
+ "error",
188
+ "fatal",
189
+ ];
190
+ const expectedIcons = ["🔍", "🐛", "✨", "⚡", "❌", "💀"];
191
+
192
+ levels.forEach((level, i) => {
193
+ const record = createLogRecord(level, ["test"], [`${level} message`]);
194
+ const output = formatter(record);
195
+
196
+ assertMatch(output, new RegExp(expectedIcons[i]));
197
+ // Check for full level format (default)
198
+ assertMatch(output, new RegExp(level));
199
+ });
200
+ });
201
+
202
+ test("getPrettyFormatter() alignment", () => {
203
+ const formatter = getPrettyFormatter({ align: true, colors: false });
204
+
205
+ const records = [
206
+ createLogRecord("info", ["app"], ["Short"]),
207
+ createLogRecord("warning", ["app"], ["Longer level"]),
208
+ ];
209
+
210
+ const outputs = records.map((r) => formatter(r));
211
+
212
+ // With alignment, warning (longer) should have more padding before the category
213
+ // Just check that both outputs contain the expected content
214
+ assertMatch(outputs[0], /✨ info.*app.*Short/); // Default level format is "full"
215
+ assertMatch(outputs[1], /⚡.*warning.*app.*Longer level/); // Default level format is "full"
216
+ });
217
+
218
+ test("getPrettyFormatter() no alignment", () => {
219
+ const formatter = getPrettyFormatter({ align: false, colors: false });
220
+
221
+ const record = createLogRecord("info", ["app"], ["Message"]);
222
+ const output = formatter(record);
223
+
224
+ // Should still be formatted but without padding
225
+ assertMatch(output, /✨ info app Message/); // Default level format is "full"
226
+ });
227
+
228
+ test("getPrettyFormatter() with hex colors", () => {
229
+ const formatter = getPrettyFormatter({
230
+ levelColors: {
231
+ info: "#00ff00", // Bright green
232
+ error: "#ff0000", // Bright red
233
+ },
234
+ categoryColor: "#888888",
235
+ messageColor: "#cccccc",
236
+ });
237
+
238
+ const record = createLogRecord("info", ["test"], ["Message"]);
239
+ const result = formatter(record);
240
+ // Should contain true color ANSI codes for hex colors
241
+ assertStringIncludes(result, "\x1b[38;2;0;255;0m"); // #00ff00 converted to RGB
242
+ });
243
+
244
+ test("getPrettyFormatter() with rgb colors", () => {
245
+ const formatter = getPrettyFormatter({
246
+ levelColors: {
247
+ info: "rgb(255,128,0)", // Orange
248
+ },
249
+ timestampColor: "rgb(100,100,100)",
250
+ timestamp: "time",
251
+ });
252
+
253
+ const record = createLogRecord("info", ["test"], ["Message"]);
254
+ const result = formatter(record);
255
+ // Should contain true color ANSI codes for RGB colors
256
+ assertStringIncludes(result, "\x1b[38;2;255;128;0m"); // rgb(255,128,0)
257
+ assertStringIncludes(result, "\x1b[38;2;100;100;100m"); // timestamp color
258
+ });
259
+
260
+ test("getPrettyFormatter() with level formats", () => {
261
+ const abbr = getPrettyFormatter({ level: "ABBR" });
262
+ const full = getPrettyFormatter({ level: "FULL" });
263
+ const letter = getPrettyFormatter({ level: "L" });
264
+ const custom = getPrettyFormatter({ level: (level) => `[${level}]` });
265
+
266
+ const record = createLogRecord("info", ["test"], ["Message"]);
267
+ const abbrResult = abbr(record);
268
+ const fullResult = full(record);
269
+ const letterResult = letter(record);
270
+ const customResult = custom(record);
271
+
272
+ assertStringIncludes(abbrResult, "INF");
273
+ assertStringIncludes(fullResult, "INFO");
274
+ assertStringIncludes(letterResult, "I");
275
+ assertStringIncludes(customResult, "[info]");
276
+ });
277
+
278
+ test("getPrettyFormatter() with extended timestamp formats", () => {
279
+ const timestamp = new Date("2023-05-15T10:30:00.000Z").getTime();
280
+ const record = createLogRecord("info", ["test"], ["Message"], timestamp);
281
+
282
+ // Test all TextFormatterOptions timestamp formats
283
+ const dateTimeTimezone = getPrettyFormatter({
284
+ timestamp: "date-time-timezone",
285
+ });
286
+ const dateTimeTz = getPrettyFormatter({ timestamp: "date-time-tz" });
287
+ const dateTime = getPrettyFormatter({ timestamp: "date-time" });
288
+ const timeTimezone = getPrettyFormatter({ timestamp: "time-timezone" });
289
+ const timeTz = getPrettyFormatter({ timestamp: "time-tz" });
290
+ const rfc3339 = getPrettyFormatter({ timestamp: "rfc3339" });
291
+ const dateOnly = getPrettyFormatter({ timestamp: "date" });
292
+ const datetime = getPrettyFormatter({ timestamp: "date-time" });
293
+ const none = getPrettyFormatter({ timestamp: "none" });
294
+ const disabled = getPrettyFormatter({ timestamp: "disabled" });
295
+
296
+ const dateTimeTimezoneResult = dateTimeTimezone(record);
297
+ const dateTimeTzResult = dateTimeTz(record);
298
+ const dateTimeResult = dateTime(record);
299
+ const timeTimezoneResult = timeTimezone(record);
300
+ const timeTzResult = timeTz(record);
301
+ const rfc3339Result = rfc3339(record);
302
+ const dateOnlyResult = dateOnly(record);
303
+ const datetimeResult = datetime(record);
304
+ const noneResult = none(record);
305
+ const disabledResult = disabled(record);
306
+
307
+ // Check that appropriate timestamps are included
308
+ assertStringIncludes(dateTimeTimezoneResult, "2023-05-15");
309
+ assertStringIncludes(dateTimeTimezoneResult, "+00:00");
310
+ assertStringIncludes(dateTimeTzResult, "2023-05-15");
311
+ assertStringIncludes(dateTimeTzResult, "+00");
312
+ assertStringIncludes(dateTimeResult, "2023-05-15");
313
+ assertStringIncludes(timeTimezoneResult, "10:30:00");
314
+ assertStringIncludes(timeTzResult, "10:30:00");
315
+ assertStringIncludes(rfc3339Result, "2023-05-15T10:30:00.000Z");
316
+ assertStringIncludes(dateOnlyResult, "2023-05-15");
317
+ assertStringIncludes(datetimeResult, "2023-05-15 10:30:00");
318
+
319
+ // Check that none/disabled don't include timestamps
320
+ assertEquals(noneResult.includes("2023"), false);
321
+ assertEquals(disabledResult.includes("2023"), false);
322
+ });
323
+
324
+ test("getPrettyFormatter() with styles", () => {
325
+ const formatter = getPrettyFormatter({
326
+ levelStyle: "bold",
327
+ categoryStyle: "italic",
328
+ messageStyle: "underline",
329
+ timestampStyle: "strikethrough",
330
+ timestamp: "time",
331
+ });
332
+
333
+ const record = createLogRecord("info", ["test"], ["Message"]);
334
+ const result = formatter(record);
335
+ // Should contain ANSI style codes
336
+ assertStringIncludes(result, "\x1b[1m"); // bold
337
+ assertStringIncludes(result, "\x1b[3m"); // italic
338
+ assertStringIncludes(result, "\x1b[4m"); // underline
339
+ assertStringIncludes(result, "\x1b[9m"); // strikethrough
340
+ });
341
+
342
+ test("getPrettyFormatter() with custom category separator", () => {
343
+ const formatter = getPrettyFormatter({
344
+ categorySeparator: ">",
345
+ colors: false,
346
+ });
347
+
348
+ const record = createLogRecord("info", ["app", "web", "server"], ["Message"]);
349
+ const result = formatter(record);
350
+ assertStringIncludes(result, "app>web>server");
351
+ });
352
+
353
+ test("getPrettyFormatter() with ANSI colors", () => {
354
+ const formatter = getPrettyFormatter({
355
+ levelColors: {
356
+ info: "green",
357
+ error: "red",
358
+ },
359
+ categoryColor: "blue",
360
+ });
361
+
362
+ const record = createLogRecord("info", ["test"], ["Message"]);
363
+ const result = formatter(record);
364
+ // Should contain ANSI color codes
365
+ assertStringIncludes(result, "\x1b[32m"); // green
366
+ assertStringIncludes(result, "\x1b[34m"); // blue
367
+ });
368
+
369
+ test("Color helper functions with 3-digit hex", () => {
370
+ const formatter = getPrettyFormatter({
371
+ levelColors: {
372
+ info: "#fff", // 3-digit hex
373
+ },
374
+ });
375
+
376
+ const record = createLogRecord("info", ["test"], ["Message"]);
377
+ const result = formatter(record);
378
+ // Should contain converted RGB codes
379
+ assertStringIncludes(result, "\x1b[38;2;255;255;255m"); // #fff -> rgb(255,255,255)
380
+ });
381
+
382
+ test("getPrettyFormatter() with category color mapping", () => {
383
+ const categoryColorMap: CategoryColorMap = new Map([
384
+ [["app", "auth"], "#ff6b6b"], // red for app.auth.*
385
+ [["app", "db"], "#4ecdc4"], // teal for app.db.*
386
+ [["app"], "#45b7d1"], // blue for app.* (fallback)
387
+ [["lib"], "#96ceb4"], // green for lib.*
388
+ ]);
389
+
390
+ const formatter = getPrettyFormatter({
391
+ categoryColorMap,
392
+ colors: true,
393
+ });
394
+
395
+ // Test exact match
396
+ const authRecord = createLogRecord("info", ["app", "auth", "login"], [
397
+ "User logged in",
398
+ ]);
399
+ const authResult = formatter(authRecord);
400
+ assertStringIncludes(authResult, "\x1b[38;2;255;107;107m"); // #ff6b6b
401
+
402
+ // Test prefix fallback
403
+ const miscRecord = createLogRecord("info", ["app", "utils"], [
404
+ "Utility called",
405
+ ]);
406
+ const miscResult = formatter(miscRecord);
407
+ assertStringIncludes(miscResult, "\x1b[38;2;69;183;209m"); // #45b7d1
408
+
409
+ // Test different prefix
410
+ const libRecord = createLogRecord("info", ["lib", "http"], ["HTTP request"]);
411
+ const libResult = formatter(libRecord);
412
+ assertStringIncludes(libResult, "\x1b[38;2;150;206;180m"); // #96ceb4
413
+ });
414
+
415
+ test("Category color mapping precedence", () => {
416
+ const categoryColorMap: CategoryColorMap = new Map([
417
+ [["app", "auth", "jwt"], "#ff0000"], // Most specific
418
+ [["app", "auth"], "#00ff00"], // Less specific
419
+ [["app"], "#0000ff"], // Least specific
420
+ ]);
421
+
422
+ const formatter = getPrettyFormatter({
423
+ categoryColorMap,
424
+ colors: true,
425
+ });
426
+
427
+ // Should match most specific pattern
428
+ const jwtRecord = createLogRecord("info", ["app", "auth", "jwt", "verify"], [
429
+ "Token verified",
430
+ ]);
431
+ const jwtResult = formatter(jwtRecord);
432
+ assertStringIncludes(jwtResult, "\x1b[38;2;255;0;0m"); // #ff0000
433
+
434
+ // Should match less specific pattern
435
+ const authRecord = createLogRecord("info", ["app", "auth", "session"], [
436
+ "Session created",
437
+ ]);
438
+ const authResult = formatter(authRecord);
439
+ assertStringIncludes(authResult, "\x1b[38;2;0;255;0m"); // #00ff00
440
+
441
+ // Should match least specific pattern
442
+ const appRecord = createLogRecord("info", ["app", "server"], [
443
+ "Server started",
444
+ ]);
445
+ const appResult = formatter(appRecord);
446
+ assertStringIncludes(appResult, "\x1b[38;2;0;0;255m"); // #0000ff
447
+ });
448
+
449
+ test("Category color mapping with no match", () => {
450
+ const categoryColorMap: CategoryColorMap = new Map([
451
+ [["app"], "#ff0000"],
452
+ ]);
453
+
454
+ const formatter = getPrettyFormatter({
455
+ categoryColorMap,
456
+ categoryColor: "#00ff00", // fallback color
457
+ colors: true,
458
+ });
459
+
460
+ // Should use fallback color for non-matching category
461
+ const record = createLogRecord("info", ["system", "kernel"], [
462
+ "Kernel message",
463
+ ]);
464
+ const result = formatter(record);
465
+ assertStringIncludes(result, "\x1b[38;2;0;255;0m"); // fallback #00ff00
466
+ });
467
+
468
+ test("Interpolated values with proper color reset/reapply", () => {
469
+ const formatter = getPrettyFormatter({
470
+ messageColor: "#ffffff",
471
+ messageStyle: "dim",
472
+ colors: true,
473
+ });
474
+
475
+ const record = createLogRecord("info", ["test"], [
476
+ "User data: ",
477
+ { id: 123, name: "John" },
478
+ ", status: ",
479
+ "active",
480
+ ]);
481
+
482
+ const result = formatter(record);
483
+
484
+ // Should contain proper color reset/reapply around interpolated values
485
+ // The exact ANSI codes depend on inspect() output, but we should see resets
486
+ assertStringIncludes(result, "\x1b[0m"); // Reset code should be present
487
+ assertStringIncludes(result, "\x1b[2m"); // Dim style should be reapplied
488
+ assertStringIncludes(result, "\x1b[38;2;255;255;255m"); // White color should be reapplied
489
+ });
490
+
491
+ test("Multiple styles combination", () => {
492
+ const formatter = getPrettyFormatter({
493
+ levelStyle: ["bold", "underline"],
494
+ categoryStyle: ["dim", "italic"],
495
+ messageStyle: ["bold", "strikethrough"],
496
+ timestampStyle: ["dim", "underline"],
497
+ timestamp: "time",
498
+ colors: true,
499
+ });
500
+
501
+ const record = createLogRecord("info", ["test"], ["Message"]);
502
+ const result = formatter(record);
503
+
504
+ // Should contain multiple ANSI style codes combined
505
+ assertStringIncludes(result, "\x1b[1m"); // bold
506
+ assertStringIncludes(result, "\x1b[4m"); // underline
507
+ assertStringIncludes(result, "\x1b[2m"); // dim
508
+ assertStringIncludes(result, "\x1b[3m"); // italic
509
+ assertStringIncludes(result, "\x1b[9m"); // strikethrough
510
+ });
511
+
512
+ test("Word wrapping disabled by default", () => {
513
+ const formatter = getPrettyFormatter({
514
+ colors: false,
515
+ });
516
+
517
+ const longMessage =
518
+ "This is a very long message that would normally exceed the typical console width and should not be wrapped when word wrapping is disabled by default.";
519
+ const record = createLogRecord("info", ["test"], [longMessage]);
520
+ const result = formatter(record);
521
+
522
+ // Should not contain any line breaks in the message (only the trailing newline)
523
+ const lines = result.split("\n");
524
+ assertEquals(lines.length, 2); // One content line + one empty line from trailing newline
525
+ assertStringIncludes(lines[0], longMessage);
526
+ });
527
+
528
+ test("Word wrapping with 80", () => {
529
+ const formatter = getPrettyFormatter({
530
+ wordWrap: 80,
531
+ colors: false,
532
+ align: false,
533
+ });
534
+
535
+ const longMessage =
536
+ "This is a very long message that should be wrapped at approximately 80 characters when word wrapping is enabled with the default width setting.";
537
+ const record = createLogRecord("info", ["test"], [longMessage]);
538
+ const result = formatter(record);
539
+
540
+ // Should contain multiple lines due to wrapping
541
+ const lines = result.split("\n");
542
+ assert(lines.length > 2); // More than just content + trailing newline
543
+
544
+ // Each content line should be roughly within the wrap width
545
+ const contentLines = lines.filter((line) => line.length > 0);
546
+ for (const line of contentLines) {
547
+ assert(line.length <= 85); // Allow some tolerance for word boundaries
548
+ }
549
+ });
550
+
551
+ test("Word wrapping with custom width", () => {
552
+ const formatter = getPrettyFormatter({
553
+ wordWrap: 40,
554
+ colors: false,
555
+ align: false,
556
+ });
557
+
558
+ const longMessage =
559
+ "This is a message that should be wrapped at 40 characters maximum width.";
560
+ const record = createLogRecord("info", ["test"], [longMessage]);
561
+ const result = formatter(record);
562
+
563
+ // Should contain multiple lines due to aggressive wrapping
564
+ const lines = result.split("\n");
565
+ assert(lines.length > 2);
566
+
567
+ // Each content line should be within 40 characters
568
+ const contentLines = lines.filter((line) => line.length > 0);
569
+ for (const line of contentLines) {
570
+ assert(line.length <= 45); // Allow some tolerance
571
+ }
572
+ });
573
+
574
+ test("Word wrapping with proper indentation", () => {
575
+ const formatter = getPrettyFormatter({
576
+ wordWrap: 50,
577
+ colors: false,
578
+ align: false,
579
+ });
580
+
581
+ const longMessage =
582
+ "This is a long message that should wrap with proper indentation to align with the message column.";
583
+ const record = createLogRecord("info", ["app"], [longMessage]);
584
+ const result = formatter(record);
585
+
586
+ const lines = result.split("\n");
587
+ const contentLines = lines.filter((line) => line.length > 0);
588
+
589
+ // Should have multiple lines due to wrapping
590
+ assert(contentLines.length > 1);
591
+
592
+ // First line starts with icon
593
+ assert(contentLines[0].startsWith("✨ info"));
594
+
595
+ // Check that lines are properly wrapped at word boundaries
596
+ // With align: false, the format should be "✨ info app message..."
597
+ // and continuation lines should be properly indented
598
+ assert(
599
+ contentLines.length >= 2,
600
+ "Should have at least 2 lines from wrapping",
601
+ );
602
+ });
603
+
604
+ test("getPrettyFormatter() with consistent icon spacing", () => {
605
+ // Test with custom icons of different display widths
606
+ const formatter = getPrettyFormatter({
607
+ icons: {
608
+ info: "ℹ️", // 2 width emoji
609
+ warning: "!", // 1 width character
610
+ error: "🚨🚨", // 4 width (2 emojis)
611
+ },
612
+ colors: false,
613
+ align: true,
614
+ wordWrap: 50,
615
+ });
616
+
617
+ const longMessage = "This is a long message that should wrap consistently";
618
+
619
+ const infoRecord = createLogRecord("info", ["test"], [longMessage]);
620
+ const warningRecord = createLogRecord("warning", ["test"], [longMessage]);
621
+ const errorRecord = createLogRecord("error", ["test"], [longMessage]);
622
+
623
+ const infoResult = formatter(infoRecord);
624
+ const warningResult = formatter(warningRecord);
625
+ const errorResult = formatter(errorRecord);
626
+
627
+ // Split into lines and get continuation lines
628
+ const infoLines = infoResult.split("\n").filter((line) => line.length > 0);
629
+ const warningLines = warningResult.split("\n").filter((line) =>
630
+ line.length > 0
631
+ );
632
+ const errorLines = errorResult.split("\n").filter((line) => line.length > 0);
633
+
634
+ // All should have multiple lines due to wrapping
635
+ assert(infoLines.length > 1, "Info should wrap to multiple lines");
636
+ assert(warningLines.length > 1, "Warning should wrap to multiple lines");
637
+ assert(errorLines.length > 1, "Error should wrap to multiple lines");
638
+
639
+ // Check that continuation lines are indented to the same position
640
+ // despite different icon widths
641
+ if (
642
+ infoLines.length > 1 && warningLines.length > 1 && errorLines.length > 1
643
+ ) {
644
+ const infoIndent = infoLines[1].search(/\S/);
645
+ const warningIndent = warningLines[1].search(/\S/);
646
+ const errorIndent = errorLines[1].search(/\S/);
647
+
648
+ // All continuation lines should start at the same position
649
+ assertEquals(
650
+ infoIndent,
651
+ warningIndent,
652
+ "Info and warning should have same indentation",
653
+ );
654
+ assertEquals(
655
+ warningIndent,
656
+ errorIndent,
657
+ "Warning and error should have same indentation",
658
+ );
659
+ }
660
+ });
661
+
662
+ test("getPrettyFormatter() with automatic width detection", () => {
663
+ const formatter = getPrettyFormatter({
664
+ wordWrap: true, // Auto-detect width
665
+ colors: false,
666
+ });
667
+
668
+ const longMessage =
669
+ "This is a long message that should wrap at the detected terminal width";
670
+ const record = createLogRecord("info", ["test"], [longMessage]);
671
+ const result = formatter(record);
672
+
673
+ // Should have wrapped at some reasonable width
674
+ const lines = result.split("\n").filter((line) => line.length > 0);
675
+ assert(lines.length >= 1, "Should have at least one line");
676
+
677
+ // If wrapping occurred, continuation lines should be properly indented
678
+ if (lines.length > 1) {
679
+ const firstLine = lines[0];
680
+ const continuationLine = lines[1];
681
+
682
+ assert(firstLine.includes("✨"), "First line should contain icon");
683
+ assert(
684
+ continuationLine.startsWith(" "),
685
+ "Continuation line should be indented",
686
+ );
687
+ }
688
+ });
689
+
690
+ test("getPrettyFormatter() with multiline interpolated values", () => {
691
+ const formatter = getPrettyFormatter({
692
+ wordWrap: 60,
693
+ colors: false,
694
+ align: true,
695
+ });
696
+
697
+ // Create an error that will have multiline output
698
+ const error = new Error("Test error message");
699
+ const record = createLogRecord("error", ["test"], [
700
+ "Exception occurred: ",
701
+ error,
702
+ ]);
703
+ const result = formatter(record);
704
+
705
+ const lines = result.split("\n").filter((line) => line.length > 0);
706
+
707
+ // Should have multiple lines due to error stack trace
708
+ assert(
709
+ lines.length >= 2,
710
+ "Should have multiple lines for error with stack trace",
711
+ );
712
+
713
+ // First line should contain our message and start of error
714
+ assert(
715
+ lines[0].includes("Exception occurred:"),
716
+ "First line should contain our message",
717
+ );
718
+ assert(lines[0].includes("Error:"), "First line should contain error start");
719
+
720
+ // Error message might be on first or second line depending on wrapping
721
+ const fullOutput = result;
722
+ assert(
723
+ fullOutput.includes("Test error message"),
724
+ "Output should contain error message",
725
+ );
726
+
727
+ // Check that continuation lines are properly indented (should start with significant whitespace)
728
+ for (let i = 1; i < lines.length; i++) {
729
+ const line = lines[i];
730
+ const trimmedLine = line.trimStart();
731
+ const indentLength = line.length - trimmedLine.length;
732
+ assert(
733
+ indentLength >= 10,
734
+ `Line ${i} should be indented (has ${indentLength} spaces)`,
735
+ );
736
+ }
737
+
738
+ // Should contain stack trace somewhere
739
+ const stackTraceLine = lines.find((line) => line.trim().startsWith("at "));
740
+ assert(stackTraceLine, "Should contain a stack trace line");
741
+ const trimmedStackTrace = stackTraceLine.trimStart();
742
+ const stackIndentLength = stackTraceLine.length - trimmedStackTrace.length;
743
+ assert(stackIndentLength >= 10, "Stack trace should be properly indented");
744
+ });
745
+
746
+ test("getPrettyFormatter() with multiline interpolated values (no align)", () => {
747
+ const formatter = getPrettyFormatter({
748
+ wordWrap: 50,
749
+ colors: false,
750
+ align: false,
751
+ });
752
+
753
+ const error = new Error("Test error");
754
+ const record = createLogRecord("error", ["app"], [
755
+ "Error: ",
756
+ error,
757
+ ]);
758
+ const result = formatter(record);
759
+
760
+ const lines = result.split("\n").filter((line) => line.length > 0);
761
+
762
+ // Should have multiple lines
763
+ assert(lines.length >= 2, "Should have multiple lines for error");
764
+
765
+ // Check that stack trace lines are properly indented relative to the message start
766
+ const firstLine = lines[0];
767
+ assert(
768
+ firstLine.includes("❌ error app Error:"),
769
+ "First line should contain prefix and message start",
770
+ );
771
+
772
+ if (lines.length > 1) {
773
+ const stackTraceLine = lines.find((line) => line.trim().startsWith("at "));
774
+ if (stackTraceLine) {
775
+ // Stack trace should be indented to align with message content
776
+ assert(
777
+ stackTraceLine.length > stackTraceLine.trimStart().length,
778
+ "Stack trace line should be indented",
779
+ );
780
+ }
781
+ }
782
+ });