@logtape/logtape 1.1.4 → 1.1.6

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/src/sink.test.ts DELETED
@@ -1,1708 +0,0 @@
1
- import { suite } from "@alinea/suite";
2
- import { assert } from "@std/assert/assert";
3
- import { assertEquals } from "@std/assert/equals";
4
- import { assertInstanceOf } from "@std/assert/instance-of";
5
- import { assertThrows } from "@std/assert/throws";
6
- import { delay } from "@std/async/delay";
7
- import makeConsoleMock from "consolemock";
8
- import { debug, error, fatal, info, trace, warning } from "./fixtures.ts";
9
- import { defaultTextFormatter } from "./formatter.ts";
10
- import type { LogLevel } from "./level.ts";
11
- import type { LogRecord } from "./record.ts";
12
- import {
13
- type AsyncSink,
14
- fingersCrossed,
15
- fromAsyncSink,
16
- getConsoleSink,
17
- getStreamSink,
18
- type Sink,
19
- withFilter,
20
- } from "./sink.ts";
21
-
22
- const test = suite(import.meta);
23
-
24
- test("withFilter()", () => {
25
- const buffer: LogRecord[] = [];
26
- const sink = withFilter(buffer.push.bind(buffer), "warning");
27
- sink(trace);
28
- sink(debug);
29
- sink(info);
30
- sink(warning);
31
- sink(error);
32
- sink(fatal);
33
- assertEquals(buffer, [warning, error, fatal]);
34
- });
35
-
36
- interface ConsoleMock extends Console {
37
- history(): unknown[];
38
- }
39
-
40
- test("getStreamSink()", async () => {
41
- let buffer: string = "";
42
- const decoder = new TextDecoder();
43
- const sink = getStreamSink(
44
- new WritableStream({
45
- write(chunk: Uint8Array) {
46
- buffer += decoder.decode(chunk);
47
- return Promise.resolve();
48
- },
49
- }),
50
- );
51
- sink(trace);
52
- sink(debug);
53
- sink(info);
54
- sink(warning);
55
- sink(error);
56
- sink(fatal);
57
- await sink[Symbol.asyncDispose]();
58
- assertEquals(
59
- buffer,
60
- `\
61
- 2023-11-14 22:13:20.000 +00:00 [TRC] my-app·junk: Hello, 123 & 456!
62
- 2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!
63
- 2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!
64
- 2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456!
65
- 2023-11-14 22:13:20.000 +00:00 [ERR] my-app·junk: Hello, 123 & 456!
66
- 2023-11-14 22:13:20.000 +00:00 [FTL] my-app·junk: Hello, 123 & 456!
67
- `,
68
- );
69
- });
70
-
71
- test("getStreamSink() with nonBlocking - simple boolean", async () => {
72
- let buffer: string = "";
73
- const decoder = new TextDecoder();
74
- const sink = getStreamSink(
75
- new WritableStream({
76
- write(chunk: Uint8Array) {
77
- buffer += decoder.decode(chunk);
78
- return Promise.resolve();
79
- },
80
- }),
81
- { nonBlocking: true },
82
- );
83
-
84
- // Check that it returns AsyncDisposable
85
- assertInstanceOf(sink, Function);
86
- assert(Symbol.asyncDispose in sink);
87
-
88
- // Add records - they should not be written immediately
89
- sink(trace);
90
- sink(debug);
91
- assertEquals(buffer, ""); // Not written yet
92
-
93
- // Wait for flush interval (default 100ms)
94
- await delay(150);
95
- assertEquals(
96
- buffer,
97
- `2023-11-14 22:13:20.000 +00:00 [TRC] my-app·junk: Hello, 123 & 456!
98
- 2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!
99
- `,
100
- );
101
-
102
- await sink[Symbol.asyncDispose]();
103
- });
104
-
105
- test("getStreamSink() with nonBlocking - custom buffer config", async () => {
106
- let buffer: string = "";
107
- const decoder = new TextDecoder();
108
- const sink = getStreamSink(
109
- new WritableStream({
110
- write(chunk: Uint8Array) {
111
- buffer += decoder.decode(chunk);
112
- return Promise.resolve();
113
- },
114
- }),
115
- {
116
- nonBlocking: {
117
- bufferSize: 2,
118
- flushInterval: 50,
119
- },
120
- },
121
- );
122
-
123
- // Add records up to buffer size
124
- sink(trace);
125
- assertEquals(buffer, ""); // Not flushed yet
126
-
127
- sink(debug); // This should trigger immediate flush (buffer size = 2)
128
- await delay(10); // Small delay for async flush
129
- assertEquals(
130
- buffer,
131
- `2023-11-14 22:13:20.000 +00:00 [TRC] my-app·junk: Hello, 123 & 456!
132
- 2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!
133
- `,
134
- );
135
-
136
- // Add more records
137
- const prevLength = buffer.length;
138
- sink(info);
139
- assertEquals(buffer.length, prevLength); // Not flushed yet
140
-
141
- // Wait for flush interval
142
- await delay(60);
143
- assertEquals(
144
- buffer.substring(prevLength),
145
- `2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!
146
- `,
147
- );
148
-
149
- await sink[Symbol.asyncDispose]();
150
- });
151
-
152
- test("getStreamSink() with nonBlocking - no operations after dispose", async () => {
153
- let buffer: string = "";
154
- const decoder = new TextDecoder();
155
- const sink = getStreamSink(
156
- new WritableStream({
157
- write(chunk: Uint8Array) {
158
- buffer += decoder.decode(chunk);
159
- return Promise.resolve();
160
- },
161
- }),
162
- { nonBlocking: true },
163
- );
164
-
165
- // Dispose immediately
166
- await sink[Symbol.asyncDispose]();
167
-
168
- // Try to add records after dispose
169
- sink(trace);
170
- sink(debug);
171
-
172
- // No records should be written
173
- assertEquals(buffer, "");
174
- });
175
-
176
- test("getStreamSink() with nonBlocking - error handling", async () => {
177
- const sink = getStreamSink(
178
- new WritableStream({
179
- write() {
180
- return Promise.reject(new Error("Write error"));
181
- },
182
- }),
183
- { nonBlocking: true },
184
- );
185
-
186
- // Should not throw when adding records
187
- sink(trace);
188
- sink(info);
189
- sink(error);
190
-
191
- // Wait for flush - errors should be silently ignored
192
- await delay(150);
193
-
194
- // Dispose - should not throw
195
- await sink[Symbol.asyncDispose]();
196
- });
197
-
198
- test("getStreamSink() with nonBlocking - flush on dispose", async () => {
199
- let buffer: string = "";
200
- const decoder = new TextDecoder();
201
- const sink = getStreamSink(
202
- new WritableStream({
203
- write(chunk: Uint8Array) {
204
- buffer += decoder.decode(chunk);
205
- return Promise.resolve();
206
- },
207
- }),
208
- {
209
- nonBlocking: {
210
- bufferSize: 100,
211
- flushInterval: 5000, // Very long interval
212
- },
213
- },
214
- );
215
-
216
- // Add records
217
- sink(trace);
218
- sink(debug);
219
- sink(info);
220
- assertEquals(buffer, ""); // Not flushed yet due to large buffer and long interval
221
-
222
- // Dispose should flush all remaining records
223
- await sink[Symbol.asyncDispose]();
224
- assertEquals(
225
- buffer,
226
- `2023-11-14 22:13:20.000 +00:00 [TRC] my-app·junk: Hello, 123 & 456!
227
- 2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!
228
- 2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!
229
- `,
230
- );
231
- });
232
-
233
- test("getStreamSink() with nonBlocking - buffer overflow protection", async () => {
234
- let buffer: string = "";
235
- const decoder = new TextDecoder();
236
- let recordsReceived = 0;
237
- const sink = getStreamSink(
238
- new WritableStream({
239
- write(chunk: Uint8Array) {
240
- const text = decoder.decode(chunk);
241
- buffer += text;
242
- // Count how many log records we actually receive
243
- recordsReceived += text.split("\n").filter((line) =>
244
- line.trim() !== ""
245
- ).length;
246
- return Promise.resolve();
247
- },
248
- }),
249
- {
250
- nonBlocking: {
251
- bufferSize: 3,
252
- flushInterval: 50, // Short interval to ensure flushes happen
253
- },
254
- },
255
- );
256
-
257
- // Add many more records than maxBufferSize (6) very rapidly
258
- // This should trigger multiple flushes and potentially overflow protection
259
- for (let i = 0; i < 20; i++) {
260
- sink(trace);
261
- }
262
-
263
- // Wait for all flushes to complete
264
- await delay(200);
265
-
266
- // Force final flush
267
- await sink[Symbol.asyncDispose]();
268
-
269
- // Due to overflow protection, we should receive fewer than 20 records
270
- // The exact number depends on timing, but some should be dropped
271
- assert(
272
- recordsReceived < 20,
273
- `Expected < 20 records due to potential overflow, got ${recordsReceived}`,
274
- );
275
- assert(recordsReceived > 0, "Expected some records to be logged");
276
- });
277
-
278
- test("getStreamSink() with nonBlocking - high volume non-blocking behavior", async () => {
279
- let buffer: string = "";
280
- const decoder = new TextDecoder();
281
- const sink = getStreamSink(
282
- new WritableStream({
283
- write(chunk: Uint8Array) {
284
- buffer += decoder.decode(chunk);
285
- return Promise.resolve();
286
- },
287
- }),
288
- {
289
- nonBlocking: {
290
- bufferSize: 3,
291
- flushInterval: 50,
292
- },
293
- },
294
- );
295
-
296
- // Simulate rapid logging - this should not block
297
- const startTime = performance.now();
298
- for (let i = 0; i < 100; i++) {
299
- sink(trace);
300
- }
301
- const endTime = performance.now();
302
-
303
- // Adding logs should be very fast (non-blocking)
304
- const duration = endTime - startTime;
305
- assert(
306
- duration < 100,
307
- `Adding 100 logs took ${duration}ms, should be much faster`,
308
- );
309
-
310
- // Wait for flushes to complete
311
- await delay(200);
312
-
313
- // Should have logged some records
314
- assert(buffer.length > 0, "Expected some records to be logged");
315
-
316
- await sink[Symbol.asyncDispose]();
317
- });
318
-
319
- test("getConsoleSink()", () => {
320
- // @ts-ignore: consolemock is not typed
321
- const mock: ConsoleMock = makeConsoleMock();
322
- const sink = getConsoleSink({ console: mock });
323
- sink(trace);
324
- sink(debug);
325
- sink(info);
326
- sink(warning);
327
- sink(error);
328
- sink(fatal);
329
- assertEquals(mock.history(), [
330
- {
331
- DEBUG: [
332
- "%c22:13:20.000 %cTRC%c %cmy-app·junk %cHello, %o & %o!",
333
- "color: gray;",
334
- "background-color: gray; color: white;",
335
- "background-color: default;",
336
- "color: gray;",
337
- "color: default;",
338
- 123,
339
- 456,
340
- ],
341
- },
342
- {
343
- DEBUG: [
344
- "%c22:13:20.000 %cDBG%c %cmy-app·junk %cHello, %o & %o!",
345
- "color: gray;",
346
- "background-color: gray; color: white;",
347
- "background-color: default;",
348
- "color: gray;",
349
- "color: default;",
350
- 123,
351
- 456,
352
- ],
353
- },
354
- {
355
- INFO: [
356
- "%c22:13:20.000 %cINF%c %cmy-app·junk %cHello, %o & %o!",
357
- "color: gray;",
358
- "background-color: white; color: black;",
359
- "background-color: default;",
360
- "color: gray;",
361
- "color: default;",
362
- 123,
363
- 456,
364
- ],
365
- },
366
- {
367
- WARN: [
368
- "%c22:13:20.000 %cWRN%c %cmy-app·junk %cHello, %o & %o!",
369
- "color: gray;",
370
- "background-color: orange; color: black;",
371
- "background-color: default;",
372
- "color: gray;",
373
- "color: default;",
374
- 123,
375
- 456,
376
- ],
377
- },
378
- {
379
- ERROR: [
380
- "%c22:13:20.000 %cERR%c %cmy-app·junk %cHello, %o & %o!",
381
- "color: gray;",
382
- "background-color: red; color: white;",
383
- "background-color: default;",
384
- "color: gray;",
385
- "color: default;",
386
- 123,
387
- 456,
388
- ],
389
- },
390
- {
391
- ERROR: [
392
- "%c22:13:20.000 %cFTL%c %cmy-app·junk %cHello, %o & %o!",
393
- "color: gray;",
394
- "background-color: maroon; color: white;",
395
- "background-color: default;",
396
- "color: gray;",
397
- "color: default;",
398
- 123,
399
- 456,
400
- ],
401
- },
402
- ]);
403
-
404
- assertThrows(
405
- () => sink({ ...info, level: "invalid" as LogLevel }),
406
- TypeError,
407
- "Invalid log level: invalid.",
408
- );
409
-
410
- // @ts-ignore: consolemock is not typed
411
- const mock2: ConsoleMock = makeConsoleMock();
412
- const sink2 = getConsoleSink({
413
- console: mock2,
414
- formatter: defaultTextFormatter,
415
- });
416
- sink2(trace);
417
- sink2(debug);
418
- sink2(info);
419
- sink2(warning);
420
- sink2(error);
421
- sink2(fatal);
422
- assertEquals(mock2.history(), [
423
- {
424
- DEBUG: [
425
- "2023-11-14 22:13:20.000 +00:00 [TRC] my-app·junk: Hello, 123 & 456!",
426
- ],
427
- },
428
- {
429
- DEBUG: [
430
- "2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!",
431
- ],
432
- },
433
- {
434
- INFO: [
435
- "2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!",
436
- ],
437
- },
438
- {
439
- WARN: [
440
- "2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456!",
441
- ],
442
- },
443
- {
444
- ERROR: [
445
- "2023-11-14 22:13:20.000 +00:00 [ERR] my-app·junk: Hello, 123 & 456!",
446
- ],
447
- },
448
- {
449
- ERROR: [
450
- "2023-11-14 22:13:20.000 +00:00 [FTL] my-app·junk: Hello, 123 & 456!",
451
- ],
452
- },
453
- ]);
454
-
455
- // @ts-ignore: consolemock is not typed
456
- const mock3: ConsoleMock = makeConsoleMock();
457
- const sink3 = getConsoleSink({
458
- console: mock3,
459
- levelMap: {
460
- trace: "log",
461
- debug: "log",
462
- info: "log",
463
- warning: "log",
464
- error: "log",
465
- fatal: "log",
466
- },
467
- formatter: defaultTextFormatter,
468
- });
469
- sink3(trace);
470
- sink3(debug);
471
- sink3(info);
472
- sink3(warning);
473
- sink3(error);
474
- sink3(fatal);
475
- assertEquals(mock3.history(), [
476
- {
477
- LOG: [
478
- "2023-11-14 22:13:20.000 +00:00 [TRC] my-app·junk: Hello, 123 & 456!",
479
- ],
480
- },
481
- {
482
- LOG: [
483
- "2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!",
484
- ],
485
- },
486
- {
487
- LOG: [
488
- "2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!",
489
- ],
490
- },
491
- {
492
- LOG: [
493
- "2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456!",
494
- ],
495
- },
496
- {
497
- LOG: [
498
- "2023-11-14 22:13:20.000 +00:00 [ERR] my-app·junk: Hello, 123 & 456!",
499
- ],
500
- },
501
- {
502
- LOG: [
503
- "2023-11-14 22:13:20.000 +00:00 [FTL] my-app·junk: Hello, 123 & 456!",
504
- ],
505
- },
506
- ]);
507
- });
508
-
509
- test("getConsoleSink() with nonBlocking - simple boolean", async () => {
510
- // @ts-ignore: consolemock is not typed
511
- const mock: ConsoleMock = makeConsoleMock();
512
- const sink = getConsoleSink({ console: mock, nonBlocking: true });
513
-
514
- // Check that it returns a Disposable
515
- assertInstanceOf(sink, Function);
516
- assert(Symbol.dispose in sink);
517
-
518
- // Add records - they should not be logged immediately
519
- sink(trace);
520
- sink(debug);
521
- assertEquals(mock.history().length, 0); // Not logged yet
522
-
523
- // Wait for flush interval (default 100ms)
524
- await delay(150);
525
- assertEquals(mock.history().length, 2); // Now they should be logged
526
-
527
- // Dispose the sink
528
- (sink as Sink & Disposable)[Symbol.dispose]();
529
- });
530
-
531
- test("getConsoleSink() with nonBlocking - custom buffer config", async () => {
532
- // @ts-ignore: consolemock is not typed
533
- const mock: ConsoleMock = makeConsoleMock();
534
- const sink = getConsoleSink({
535
- console: mock,
536
- nonBlocking: {
537
- bufferSize: 3,
538
- flushInterval: 50,
539
- },
540
- });
541
-
542
- // Add records up to buffer size
543
- sink(trace);
544
- sink(debug);
545
- assertEquals(mock.history().length, 0); // Not flushed yet
546
-
547
- sink(info); // This should trigger scheduled flush (buffer size = 3)
548
- await delay(10); // Wait for scheduled flush to execute
549
- assertEquals(mock.history().length, 3); // Flushed due to buffer size
550
-
551
- // Add more records
552
- sink(warning);
553
- assertEquals(mock.history().length, 3); // Not flushed yet
554
-
555
- // Wait for flush interval
556
- await delay(60);
557
- assertEquals(mock.history().length, 4); // Flushed due to interval
558
-
559
- // Dispose and check remaining records are flushed
560
- sink(error);
561
- sink(fatal);
562
- (sink as Sink & Disposable)[Symbol.dispose]();
563
- assertEquals(mock.history().length, 6); // All records flushed on dispose
564
- });
565
-
566
- test("getConsoleSink() with nonBlocking - no operations after dispose", () => {
567
- // @ts-ignore: consolemock is not typed
568
- const mock: ConsoleMock = makeConsoleMock();
569
- const sink = getConsoleSink({ console: mock, nonBlocking: true });
570
-
571
- // Dispose immediately
572
- (sink as Sink & Disposable)[Symbol.dispose]();
573
-
574
- // Try to add records after dispose
575
- sink(trace);
576
- sink(debug);
577
-
578
- // No records should be logged
579
- assertEquals(mock.history().length, 0);
580
- });
581
-
582
- test("getConsoleSink() with nonBlocking - error handling", async () => {
583
- const errorConsole = {
584
- ...console,
585
- debug: () => {
586
- throw new Error("Console error");
587
- },
588
- info: () => {
589
- throw new Error("Console error");
590
- },
591
- warn: () => {
592
- throw new Error("Console error");
593
- },
594
- error: () => {
595
- throw new Error("Console error");
596
- },
597
- };
598
-
599
- const sink = getConsoleSink({
600
- console: errorConsole,
601
- nonBlocking: true,
602
- });
603
-
604
- // Should not throw when adding records
605
- sink(trace);
606
- sink(info);
607
- sink(error);
608
-
609
- // Wait for flush - errors should be silently ignored
610
- await delay(150);
611
-
612
- // Dispose - should not throw
613
- (sink as Sink & Disposable)[Symbol.dispose]();
614
- });
615
-
616
- test("getConsoleSink() with nonBlocking - buffer overflow protection", async () => {
617
- // @ts-ignore: consolemock is not typed
618
- const mock: ConsoleMock = makeConsoleMock();
619
- const sink = getConsoleSink({
620
- console: mock,
621
- nonBlocking: {
622
- bufferSize: 5,
623
- flushInterval: 1000, // Long interval to prevent automatic flushing
624
- },
625
- });
626
-
627
- // Add more records than 2x buffer size (which should trigger overflow protection)
628
- for (let i = 0; i < 12; i++) {
629
- sink(trace);
630
- }
631
-
632
- // Should have dropped oldest records, keeping buffer size manageable
633
- // Wait a bit for any scheduled flushes
634
- await delay(10);
635
-
636
- // Force flush by disposing
637
- (sink as Sink & Disposable)[Symbol.dispose]();
638
-
639
- // Should have logged records, but not more than maxBufferSize (10)
640
- const historyLength = mock.history().length;
641
- assert(historyLength <= 10, `Expected <= 10 records, got ${historyLength}`);
642
- assert(historyLength > 0, "Expected some records to be logged");
643
- });
644
-
645
- test("getConsoleSink() with nonBlocking - high volume non-blocking behavior", async () => {
646
- // @ts-ignore: consolemock is not typed
647
- const mock: ConsoleMock = makeConsoleMock();
648
- const sink = getConsoleSink({
649
- console: mock,
650
- nonBlocking: {
651
- bufferSize: 3,
652
- flushInterval: 50,
653
- },
654
- });
655
-
656
- // Simulate rapid logging - this should not block
657
- const startTime = performance.now();
658
- for (let i = 0; i < 100; i++) {
659
- sink(trace);
660
- }
661
- const endTime = performance.now();
662
-
663
- // Adding logs should be very fast (non-blocking)
664
- const duration = endTime - startTime;
665
- assert(
666
- duration < 100,
667
- `Adding 100 logs took ${duration}ms, should be much faster`,
668
- );
669
-
670
- // Wait for flushes to complete
671
- await delay(200);
672
-
673
- // Should have logged some records
674
- assert(mock.history().length > 0, "Expected some records to be logged");
675
-
676
- (sink as Sink & Disposable)[Symbol.dispose]();
677
- });
678
-
679
- test("fromAsyncSink() - basic functionality", async () => {
680
- const buffer: LogRecord[] = [];
681
- const asyncSink: AsyncSink = async (record) => {
682
- await delay(10);
683
- buffer.push(record);
684
- };
685
-
686
- const sink = fromAsyncSink(asyncSink);
687
-
688
- sink(trace);
689
- sink(debug);
690
- sink(info);
691
-
692
- // Records should not be in buffer immediately
693
- assertEquals(buffer.length, 0);
694
-
695
- // Wait for async operations to complete
696
- await sink[Symbol.asyncDispose]();
697
-
698
- // All records should be in buffer in order
699
- assertEquals(buffer.length, 3);
700
- assertEquals(buffer, [trace, debug, info]);
701
- });
702
-
703
- test("fromAsyncSink() - promise chaining preserves order", async () => {
704
- const buffer: LogRecord[] = [];
705
- const delays = [50, 10, 30]; // Different delays for each call
706
- let callIndex = 0;
707
-
708
- const asyncSink: AsyncSink = async (record) => {
709
- const delayTime = delays[callIndex % delays.length];
710
- callIndex++;
711
- await delay(delayTime);
712
- buffer.push(record);
713
- };
714
-
715
- const sink = fromAsyncSink(asyncSink);
716
-
717
- sink(trace);
718
- sink(debug);
719
- sink(info);
720
-
721
- await sink[Symbol.asyncDispose]();
722
-
723
- // Despite different delays, order should be preserved
724
- assertEquals(buffer.length, 3);
725
- assertEquals(buffer, [trace, debug, info]);
726
- });
727
-
728
- test("fromAsyncSink() - error handling", async () => {
729
- const buffer: LogRecord[] = [];
730
- let errorCount = 0;
731
-
732
- const asyncSink: AsyncSink = async (record) => {
733
- if (record.level === "error") {
734
- errorCount++;
735
- throw new Error("Async sink error");
736
- }
737
- await Promise.resolve(); // Ensure it's async
738
- buffer.push(record);
739
- };
740
-
741
- const sink = fromAsyncSink(asyncSink);
742
-
743
- sink(trace);
744
- sink(error); // This will throw in async sink
745
- sink(info);
746
-
747
- await sink[Symbol.asyncDispose]();
748
-
749
- // Error should be caught and not break the chain
750
- assertEquals(errorCount, 1);
751
- assertEquals(buffer.length, 2);
752
- assertEquals(buffer, [trace, info]);
753
- });
754
-
755
- test("fromAsyncSink() - multiple dispose calls", async () => {
756
- const buffer: LogRecord[] = [];
757
- const asyncSink: AsyncSink = async (record) => {
758
- await delay(10);
759
- buffer.push(record);
760
- };
761
-
762
- const sink = fromAsyncSink(asyncSink);
763
-
764
- sink(trace);
765
- sink(debug);
766
-
767
- // First dispose
768
- await sink[Symbol.asyncDispose]();
769
- assertEquals(buffer.length, 2);
770
-
771
- // Second dispose should be safe
772
- await sink[Symbol.asyncDispose]();
773
- assertEquals(buffer.length, 2);
774
-
775
- // Third dispose should be safe
776
- await sink[Symbol.asyncDispose]();
777
- assertEquals(buffer.length, 2);
778
- });
779
-
780
- test("fromAsyncSink() - concurrent calls", async () => {
781
- const buffer: LogRecord[] = [];
782
- let concurrentCalls = 0;
783
- let maxConcurrentCalls = 0;
784
-
785
- const asyncSink: AsyncSink = async (record) => {
786
- concurrentCalls++;
787
- maxConcurrentCalls = Math.max(maxConcurrentCalls, concurrentCalls);
788
- await delay(20);
789
- buffer.push(record);
790
- concurrentCalls--;
791
- };
792
-
793
- const sink = fromAsyncSink(asyncSink);
794
-
795
- // Fire multiple calls rapidly
796
- for (let i = 0; i < 5; i++) {
797
- sink(trace);
798
- }
799
-
800
- await sink[Symbol.asyncDispose]();
801
-
802
- // Due to promise chaining, max concurrent calls should be 1
803
- assertEquals(maxConcurrentCalls, 1);
804
- assertEquals(buffer.length, 5);
805
- });
806
-
807
- test("fromAsyncSink() - works with synchronous exceptions", async () => {
808
- const buffer: LogRecord[] = [];
809
- let errorCount = 0;
810
-
811
- const asyncSink: AsyncSink = async (record) => {
812
- if (record.level === "fatal") {
813
- errorCount++;
814
- // Synchronous throw before any await
815
- throw new Error("Sync error in async sink");
816
- }
817
- await delay(10);
818
- buffer.push(record);
819
- };
820
-
821
- const sink = fromAsyncSink(asyncSink);
822
-
823
- sink(trace);
824
- sink(fatal); // This will throw synchronously in async sink
825
- sink(info);
826
-
827
- await sink[Symbol.asyncDispose]();
828
-
829
- // Error should still be caught
830
- assertEquals(errorCount, 1);
831
- assertEquals(buffer.length, 2);
832
- assertEquals(buffer, [trace, info]);
833
- });
834
-
835
- test("fromAsyncSink() - very long async operations", async () => {
836
- const buffer: LogRecord[] = [];
837
- const asyncSink: AsyncSink = async (record) => {
838
- await delay(100); // Longer delay
839
- buffer.push(record);
840
- };
841
-
842
- const sink = fromAsyncSink(asyncSink);
843
-
844
- sink(trace);
845
- sink(debug);
846
-
847
- // Don't wait, just dispose immediately
848
- const disposePromise = sink[Symbol.asyncDispose]();
849
-
850
- // Buffer should still be empty
851
- assertEquals(buffer.length, 0);
852
-
853
- // Wait for dispose to complete
854
- await disposePromise;
855
-
856
- // Now all records should be processed
857
- assertEquals(buffer.length, 2);
858
- assertEquals(buffer, [trace, debug]);
859
- });
860
-
861
- test("fromAsyncSink() - empty async sink", async () => {
862
- const asyncSink: AsyncSink = async () => {
863
- // Do nothing
864
- };
865
-
866
- const sink = fromAsyncSink(asyncSink);
867
-
868
- // Should not throw
869
- sink(trace);
870
- sink(debug);
871
-
872
- await sink[Symbol.asyncDispose]();
873
-
874
- // Test passes if no errors thrown
875
- assert(true);
876
- });
877
-
878
- test("fingersCrossed() - basic functionality", () => {
879
- const buffer: LogRecord[] = [];
880
- const sink = fingersCrossed(buffer.push.bind(buffer));
881
-
882
- // Add debug and info logs - should be buffered
883
- sink(trace);
884
- sink(debug);
885
- sink(info);
886
- assertEquals(buffer.length, 0); // Not flushed yet
887
-
888
- // Add warning - still buffered (default trigger is error)
889
- sink(warning);
890
- assertEquals(buffer.length, 0);
891
-
892
- // Add error - should trigger flush
893
- sink(error);
894
- assertEquals(buffer, [trace, debug, info, warning, error]);
895
-
896
- // After trigger, logs pass through directly
897
- sink(fatal);
898
- assertEquals(buffer, [trace, debug, info, warning, error, fatal]);
899
- });
900
-
901
- test("fingersCrossed() - custom trigger level", () => {
902
- const buffer: LogRecord[] = [];
903
- const sink = fingersCrossed(buffer.push.bind(buffer), {
904
- triggerLevel: "warning",
905
- });
906
-
907
- // Add logs below warning
908
- sink(trace);
909
- sink(debug);
910
- sink(info);
911
- assertEquals(buffer.length, 0);
912
-
913
- // Warning should trigger flush
914
- sink(warning);
915
- assertEquals(buffer, [trace, debug, info, warning]);
916
-
917
- // Subsequent logs pass through
918
- sink(error);
919
- assertEquals(buffer, [trace, debug, info, warning, error]);
920
- });
921
-
922
- test("fingersCrossed() - buffer overflow protection", () => {
923
- const buffer: LogRecord[] = [];
924
- const sink = fingersCrossed(buffer.push.bind(buffer), {
925
- maxBufferSize: 3,
926
- });
927
-
928
- // Add more logs than buffer size
929
- sink(trace);
930
- sink(debug);
931
- sink(info);
932
- sink(warning); // Should drop trace
933
- assertEquals(buffer.length, 0); // Still buffered
934
-
935
- // Trigger flush
936
- sink(error);
937
- // Should only have last 3 records + error
938
- assertEquals(buffer, [debug, info, warning, error]);
939
- });
940
-
941
- test("fingersCrossed() - multiple trigger events", () => {
942
- const buffer: LogRecord[] = [];
943
- const sink = fingersCrossed(buffer.push.bind(buffer));
944
-
945
- // First batch
946
- sink(debug);
947
- sink(info);
948
- sink(error); // Trigger
949
- assertEquals(buffer, [debug, info, error]);
950
-
951
- // After trigger, everything passes through
952
- sink(debug);
953
- assertEquals(buffer, [debug, info, error, debug]);
954
-
955
- sink(error); // Another error
956
- assertEquals(buffer, [debug, info, error, debug, error]);
957
- });
958
-
959
- test("fingersCrossed() - trigger includes fatal", () => {
960
- const buffer: LogRecord[] = [];
961
- const sink = fingersCrossed(buffer.push.bind(buffer), {
962
- triggerLevel: "error",
963
- });
964
-
965
- sink(debug);
966
- sink(info);
967
- assertEquals(buffer.length, 0);
968
-
969
- // Fatal should also trigger (since it's >= error)
970
- sink(fatal);
971
- assertEquals(buffer, [debug, info, fatal]);
972
- });
973
-
974
- test("fingersCrossed() - category isolation descendant mode", () => {
975
- const buffer: LogRecord[] = [];
976
- const sink = fingersCrossed(buffer.push.bind(buffer), {
977
- isolateByCategory: "descendant",
978
- });
979
-
980
- // Create test records with different categories
981
- const appDebug: LogRecord = {
982
- ...debug,
983
- category: ["app"],
984
- };
985
- const appModuleDebug: LogRecord = {
986
- ...debug,
987
- category: ["app", "module"],
988
- };
989
- const appModuleSubDebug: LogRecord = {
990
- ...debug,
991
- category: ["app", "module", "sub"],
992
- };
993
- const otherDebug: LogRecord = {
994
- ...debug,
995
- category: ["other"],
996
- };
997
- const appError: LogRecord = {
998
- ...error,
999
- category: ["app"],
1000
- };
1001
-
1002
- // Buffer logs in different categories
1003
- sink(appDebug);
1004
- sink(appModuleDebug);
1005
- sink(appModuleSubDebug);
1006
- sink(otherDebug);
1007
- assertEquals(buffer.length, 0);
1008
-
1009
- // Trigger in parent category
1010
- sink(appError);
1011
-
1012
- // Should flush parent and all descendants, but not other
1013
- assertEquals(buffer.length, 4); // app, app.module, app.module.sub, and trigger
1014
- assert(buffer.includes(appDebug));
1015
- assert(buffer.includes(appModuleDebug));
1016
- assert(buffer.includes(appModuleSubDebug));
1017
- assert(buffer.includes(appError));
1018
- assert(!buffer.includes(otherDebug));
1019
- });
1020
-
1021
- test("fingersCrossed() - category isolation ancestor mode", () => {
1022
- const buffer: LogRecord[] = [];
1023
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1024
- isolateByCategory: "ancestor",
1025
- });
1026
-
1027
- // Create test records
1028
- const appDebug: LogRecord = {
1029
- ...debug,
1030
- category: ["app"],
1031
- };
1032
- const appModuleDebug: LogRecord = {
1033
- ...debug,
1034
- category: ["app", "module"],
1035
- };
1036
- const appModuleSubDebug: LogRecord = {
1037
- ...debug,
1038
- category: ["app", "module", "sub"],
1039
- };
1040
- const appModuleSubError: LogRecord = {
1041
- ...error,
1042
- category: ["app", "module", "sub"],
1043
- };
1044
-
1045
- // Buffer logs
1046
- sink(appDebug);
1047
- sink(appModuleDebug);
1048
- sink(appModuleSubDebug);
1049
- assertEquals(buffer.length, 0);
1050
-
1051
- // Trigger in child category
1052
- sink(appModuleSubError);
1053
-
1054
- // Should flush child and all ancestors
1055
- assertEquals(buffer.length, 4);
1056
- assert(buffer.includes(appDebug));
1057
- assert(buffer.includes(appModuleDebug));
1058
- assert(buffer.includes(appModuleSubDebug));
1059
- assert(buffer.includes(appModuleSubError));
1060
- });
1061
-
1062
- test("fingersCrossed() - category isolation both mode", () => {
1063
- const buffer: LogRecord[] = [];
1064
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1065
- isolateByCategory: "both",
1066
- });
1067
-
1068
- // Create test records
1069
- const rootDebug: LogRecord = {
1070
- ...debug,
1071
- category: ["app"],
1072
- };
1073
- const parentDebug: LogRecord = {
1074
- ...debug,
1075
- category: ["app", "parent"],
1076
- };
1077
- const siblingDebug: LogRecord = {
1078
- ...debug,
1079
- category: ["app", "sibling"],
1080
- };
1081
- const childDebug: LogRecord = {
1082
- ...debug,
1083
- category: ["app", "parent", "child"],
1084
- };
1085
- const unrelatedDebug: LogRecord = {
1086
- ...debug,
1087
- category: ["other"],
1088
- };
1089
- const parentError: LogRecord = {
1090
- ...error,
1091
- category: ["app", "parent"],
1092
- };
1093
-
1094
- // Buffer logs
1095
- sink(rootDebug);
1096
- sink(parentDebug);
1097
- sink(siblingDebug);
1098
- sink(childDebug);
1099
- sink(unrelatedDebug);
1100
- assertEquals(buffer.length, 0);
1101
-
1102
- // Trigger in middle category
1103
- sink(parentError);
1104
-
1105
- // Should flush ancestors and descendants, but not siblings or unrelated
1106
- assertEquals(buffer.length, 4);
1107
- assert(buffer.includes(rootDebug)); // Ancestor
1108
- assert(buffer.includes(parentDebug)); // Same
1109
- assert(buffer.includes(childDebug)); // Descendant
1110
- assert(buffer.includes(parentError)); // Trigger
1111
- assert(!buffer.includes(siblingDebug)); // Sibling
1112
- assert(!buffer.includes(unrelatedDebug)); // Unrelated
1113
- });
1114
-
1115
- test("fingersCrossed() - custom category matcher", () => {
1116
- const buffer: LogRecord[] = [];
1117
-
1118
- // Custom matcher: only flush if categories share first element
1119
- const customMatcher = (
1120
- trigger: readonly string[],
1121
- buffered: readonly string[],
1122
- ): boolean => {
1123
- return trigger[0] === buffered[0];
1124
- };
1125
-
1126
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1127
- isolateByCategory: customMatcher,
1128
- });
1129
-
1130
- // Create test records
1131
- const app1Debug: LogRecord = {
1132
- ...debug,
1133
- category: ["app", "module1"],
1134
- };
1135
- const app2Debug: LogRecord = {
1136
- ...debug,
1137
- category: ["app", "module2"],
1138
- };
1139
- const otherDebug: LogRecord = {
1140
- ...debug,
1141
- category: ["other", "module"],
1142
- };
1143
- const appError: LogRecord = {
1144
- ...error,
1145
- category: ["app", "module3"],
1146
- };
1147
-
1148
- // Buffer logs
1149
- sink(app1Debug);
1150
- sink(app2Debug);
1151
- sink(otherDebug);
1152
- assertEquals(buffer.length, 0);
1153
-
1154
- // Trigger
1155
- sink(appError);
1156
-
1157
- // Should flush all with same first category element
1158
- assertEquals(buffer.length, 3);
1159
- assert(buffer.includes(app1Debug));
1160
- assert(buffer.includes(app2Debug));
1161
- assert(buffer.includes(appError));
1162
- assert(!buffer.includes(otherDebug));
1163
- });
1164
-
1165
- test("fingersCrossed() - isolated buffers maintain separate states", () => {
1166
- const buffer: LogRecord[] = [];
1167
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1168
- isolateByCategory: "descendant",
1169
- });
1170
-
1171
- // Create test records
1172
- const app1Debug: LogRecord = {
1173
- ...debug,
1174
- category: ["app1"],
1175
- };
1176
- const app1Error: LogRecord = {
1177
- ...error,
1178
- category: ["app1"],
1179
- };
1180
- const app2Debug: LogRecord = {
1181
- ...debug,
1182
- category: ["app2"],
1183
- };
1184
- const app2Info: LogRecord = {
1185
- ...info,
1186
- category: ["app2"],
1187
- };
1188
-
1189
- // Buffer in app1
1190
- sink(app1Debug);
1191
-
1192
- // Trigger app1
1193
- sink(app1Error);
1194
- assertEquals(buffer, [app1Debug, app1Error]);
1195
-
1196
- // Buffer in app2 (should still be buffering)
1197
- sink(app2Debug);
1198
- assertEquals(buffer, [app1Debug, app1Error]); // app2 still buffered
1199
-
1200
- // Add more to triggered app1 (should pass through)
1201
- sink(app1Debug);
1202
- assertEquals(buffer, [app1Debug, app1Error, app1Debug]);
1203
-
1204
- // app2 still buffering
1205
- sink(app2Info);
1206
- assertEquals(buffer, [app1Debug, app1Error, app1Debug]); // app2 still buffered
1207
- });
1208
-
1209
- test("fingersCrossed() - chronological order in category isolation", () => {
1210
- const buffer: LogRecord[] = [];
1211
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1212
- isolateByCategory: "both",
1213
- });
1214
-
1215
- // Create test records with different timestamps
1216
- const app1: LogRecord = {
1217
- ...debug,
1218
- category: ["app"],
1219
- timestamp: 1000,
1220
- };
1221
- const app2: LogRecord = {
1222
- ...debug,
1223
- category: ["app", "sub"],
1224
- timestamp: 2000,
1225
- };
1226
- const app3: LogRecord = {
1227
- ...info,
1228
- category: ["app"],
1229
- timestamp: 3000,
1230
- };
1231
- const app4: LogRecord = {
1232
- ...debug,
1233
- category: ["app", "sub"],
1234
- timestamp: 4000,
1235
- };
1236
- const appError: LogRecord = {
1237
- ...error,
1238
- category: ["app"],
1239
- timestamp: 5000,
1240
- };
1241
-
1242
- // Add out of order
1243
- sink(app3);
1244
- sink(app1);
1245
- sink(app4);
1246
- sink(app2);
1247
-
1248
- // Trigger
1249
- sink(appError);
1250
-
1251
- // Should be sorted by timestamp
1252
- assertEquals(buffer, [app1, app2, app3, app4, appError]);
1253
- });
1254
-
1255
- test("fingersCrossed() - empty buffer trigger", () => {
1256
- const buffer: LogRecord[] = [];
1257
- const sink = fingersCrossed(buffer.push.bind(buffer));
1258
-
1259
- // Trigger immediately without any buffered logs
1260
- sink(error);
1261
- assertEquals(buffer, [error]);
1262
-
1263
- // Continue to pass through
1264
- sink(debug);
1265
- assertEquals(buffer, [error, debug]);
1266
- });
1267
-
1268
- test("fingersCrossed() - buffer size per category in isolation mode", () => {
1269
- const buffer: LogRecord[] = [];
1270
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1271
- maxBufferSize: 2,
1272
- isolateByCategory: "descendant",
1273
- });
1274
-
1275
- // Create records for different categories
1276
- const app1Trace: LogRecord = { ...trace, category: ["app1"] };
1277
- const app1Debug: LogRecord = { ...debug, category: ["app1"] };
1278
- const app1Info: LogRecord = { ...info, category: ["app1"] };
1279
- const app2Trace: LogRecord = { ...trace, category: ["app2"] };
1280
- const app2Debug: LogRecord = { ...debug, category: ["app2"] };
1281
- const app1Error: LogRecord = { ...error, category: ["app1"] };
1282
-
1283
- // Fill app1 buffer beyond max
1284
- sink(app1Trace);
1285
- sink(app1Debug);
1286
- sink(app1Info); // Should drop app1Trace
1287
-
1288
- // Fill app2 buffer
1289
- sink(app2Trace);
1290
- sink(app2Debug);
1291
-
1292
- // Trigger app1
1293
- sink(app1Error);
1294
-
1295
- // Should only have last 2 from app1 + error
1296
- assertEquals(buffer.length, 3);
1297
- assert(!buffer.some((r) => r === app1Trace)); // Dropped
1298
- assert(buffer.includes(app1Debug));
1299
- assert(buffer.includes(app1Info));
1300
- assert(buffer.includes(app1Error));
1301
- // app2 records should not be flushed
1302
- assert(!buffer.includes(app2Trace));
1303
- assert(!buffer.includes(app2Debug));
1304
- });
1305
-
1306
- test("fingersCrossed() - edge case: trigger level not in severity order", () => {
1307
- const buffer: LogRecord[] = [];
1308
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1309
- triggerLevel: "trace", // Lowest level triggers immediately
1310
- });
1311
-
1312
- // Everything should pass through immediately
1313
- sink(trace);
1314
- assertEquals(buffer, [trace]);
1315
-
1316
- sink(debug);
1317
- assertEquals(buffer, [trace, debug]);
1318
- });
1319
-
1320
- test("fingersCrossed() - edge case: invalid trigger level", () => {
1321
- const buffer: LogRecord[] = [];
1322
-
1323
- // Should throw TypeError during sink creation
1324
- assertThrows(
1325
- () => {
1326
- fingersCrossed(buffer.push.bind(buffer), {
1327
- triggerLevel: "invalid" as LogLevel,
1328
- });
1329
- },
1330
- TypeError,
1331
- "Invalid triggerLevel",
1332
- );
1333
- });
1334
-
1335
- test("fingersCrossed() - edge case: very large buffer size", () => {
1336
- const buffer: LogRecord[] = [];
1337
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1338
- maxBufferSize: Number.MAX_SAFE_INTEGER,
1339
- });
1340
-
1341
- // Add many records
1342
- for (let i = 0; i < 1000; i++) {
1343
- sink(debug);
1344
- }
1345
- assertEquals(buffer.length, 0); // Still buffered
1346
-
1347
- sink(error);
1348
- assertEquals(buffer.length, 1001); // All 1000 + error
1349
- });
1350
-
1351
- test("fingersCrossed() - edge case: zero buffer size", () => {
1352
- const buffer: LogRecord[] = [];
1353
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1354
- maxBufferSize: 0,
1355
- });
1356
-
1357
- // Nothing should be buffered
1358
- sink(debug);
1359
- sink(info);
1360
- assertEquals(buffer.length, 0);
1361
-
1362
- // Trigger should still work
1363
- sink(error);
1364
- assertEquals(buffer, [error]); // Only the trigger
1365
- });
1366
-
1367
- test("fingersCrossed() - edge case: negative buffer size", () => {
1368
- const buffer: LogRecord[] = [];
1369
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1370
- maxBufferSize: -1,
1371
- });
1372
-
1373
- // Should behave like zero
1374
- sink(debug);
1375
- sink(info);
1376
- assertEquals(buffer.length, 0);
1377
-
1378
- sink(error);
1379
- assertEquals(buffer, [error]);
1380
- });
1381
-
1382
- test("fingersCrossed() - edge case: same record logged multiple times", () => {
1383
- const buffer: LogRecord[] = [];
1384
- const sink = fingersCrossed(buffer.push.bind(buffer));
1385
-
1386
- // Log same record multiple times
1387
- sink(debug);
1388
- sink(debug);
1389
- sink(debug);
1390
- assertEquals(buffer.length, 0);
1391
-
1392
- sink(error);
1393
- // All instances should be preserved
1394
- assertEquals(buffer.length, 4);
1395
- assertEquals(buffer, [debug, debug, debug, error]);
1396
- });
1397
-
1398
- test("fingersCrossed() - edge case: empty category array", () => {
1399
- const buffer: LogRecord[] = [];
1400
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1401
- isolateByCategory: "both",
1402
- });
1403
-
1404
- const emptyCategory: LogRecord = {
1405
- ...debug,
1406
- category: [],
1407
- };
1408
-
1409
- const normalCategory: LogRecord = {
1410
- ...info,
1411
- category: ["app"],
1412
- };
1413
-
1414
- const emptyError: LogRecord = {
1415
- ...error,
1416
- category: [],
1417
- };
1418
-
1419
- sink(emptyCategory);
1420
- sink(normalCategory);
1421
- assertEquals(buffer.length, 0);
1422
-
1423
- // Trigger with empty category
1424
- sink(emptyError);
1425
-
1426
- // Only empty category should flush (no ancestors/descendants)
1427
- assertEquals(buffer.length, 2);
1428
- assert(buffer.includes(emptyCategory));
1429
- assert(buffer.includes(emptyError));
1430
- assert(!buffer.includes(normalCategory));
1431
- });
1432
-
1433
- test("fingersCrossed() - edge case: category with special characters", () => {
1434
- const buffer: LogRecord[] = [];
1435
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1436
- isolateByCategory: "descendant",
1437
- });
1438
-
1439
- // Category with null character (our separator)
1440
- const specialCategory: LogRecord = {
1441
- ...debug,
1442
- category: ["app\0special", "sub"],
1443
- };
1444
-
1445
- const normalCategory: LogRecord = {
1446
- ...info,
1447
- category: ["app"],
1448
- };
1449
-
1450
- const specialError: LogRecord = {
1451
- ...error,
1452
- category: ["app\0special"],
1453
- };
1454
-
1455
- sink(specialCategory);
1456
- sink(normalCategory);
1457
- assertEquals(buffer.length, 0);
1458
-
1459
- // Should still work correctly despite special characters
1460
- sink(specialError);
1461
-
1462
- assertEquals(buffer.length, 2);
1463
- assert(buffer.includes(specialCategory));
1464
- assert(buffer.includes(specialError));
1465
- assert(!buffer.includes(normalCategory));
1466
- });
1467
-
1468
- test("fingersCrossed() - edge case: rapid alternating triggers", () => {
1469
- const buffer: LogRecord[] = [];
1470
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1471
- isolateByCategory: "both",
1472
- });
1473
-
1474
- const app1Debug: LogRecord = { ...debug, category: ["app1"] };
1475
- const app2Debug: LogRecord = { ...debug, category: ["app2"] };
1476
- const app1Error: LogRecord = { ...error, category: ["app1"] };
1477
- const app2Error: LogRecord = { ...error, category: ["app2"] };
1478
-
1479
- // Rapidly alternate between categories and triggers
1480
- sink(app1Debug);
1481
- sink(app2Debug);
1482
- sink(app1Error); // Trigger app1
1483
- assertEquals(buffer.length, 2); // app1Debug + app1Error
1484
-
1485
- sink(app2Error); // Trigger app2
1486
- assertEquals(buffer.length, 4); // Previous + app2Debug + app2Error
1487
-
1488
- // After both triggered, everything passes through
1489
- sink(app1Debug);
1490
- sink(app2Debug);
1491
- assertEquals(buffer.length, 6);
1492
- });
1493
-
1494
- test("fingersCrossed() - edge case: custom matcher throws error", () => {
1495
- const buffer: LogRecord[] = [];
1496
-
1497
- const errorMatcher = (): boolean => {
1498
- throw new Error("Matcher error");
1499
- };
1500
-
1501
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1502
- isolateByCategory: errorMatcher,
1503
- });
1504
-
1505
- const app1Debug: LogRecord = { ...debug, category: ["app1"] };
1506
- const app2Debug: LogRecord = { ...debug, category: ["app2"] };
1507
- const app1Error: LogRecord = { ...error, category: ["app1"] };
1508
-
1509
- sink(app1Debug);
1510
- sink(app2Debug);
1511
-
1512
- // Should handle error gracefully and still trigger
1513
- try {
1514
- sink(app1Error);
1515
- } catch {
1516
- // Should not throw to caller
1517
- }
1518
-
1519
- // At minimum, trigger record should be sent
1520
- assert(buffer.includes(app1Error));
1521
- });
1522
-
1523
- test("fingersCrossed() - edge case: circular category references", () => {
1524
- const buffer: LogRecord[] = [];
1525
-
1526
- // Custom matcher that creates circular logic
1527
- const circularMatcher = (
1528
- _trigger: readonly string[],
1529
- _buffered: readonly string[],
1530
- ): boolean => {
1531
- // Always return true, creating a circular flush
1532
- return true;
1533
- };
1534
-
1535
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1536
- isolateByCategory: circularMatcher,
1537
- });
1538
-
1539
- const app1: LogRecord = { ...debug, category: ["app1"] };
1540
- const app2: LogRecord = { ...debug, category: ["app2"] };
1541
- const app3: LogRecord = { ...debug, category: ["app3"] };
1542
- const trigger: LogRecord = { ...error, category: ["trigger"] };
1543
-
1544
- sink(app1);
1545
- sink(app2);
1546
- sink(app3);
1547
- assertEquals(buffer.length, 0);
1548
-
1549
- // Should flush all despite circular logic
1550
- sink(trigger);
1551
- assertEquals(buffer.length, 4);
1552
-
1553
- // All buffers should be cleared after flush
1554
- const newDebug: LogRecord = { ...debug, category: ["new"] };
1555
- sink(newDebug);
1556
- assertEquals(buffer.length, 4); // New category should be buffered
1557
- });
1558
-
1559
- test("fingersCrossed() - edge case: timestamps in wrong order", () => {
1560
- const buffer: LogRecord[] = [];
1561
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1562
- isolateByCategory: "both",
1563
- });
1564
-
1565
- const future: LogRecord = {
1566
- ...debug,
1567
- category: ["app"],
1568
- timestamp: Date.now() + 10000, // Future
1569
- };
1570
-
1571
- const past: LogRecord = {
1572
- ...info,
1573
- category: ["app", "sub"],
1574
- timestamp: Date.now() - 10000, // Past
1575
- };
1576
-
1577
- const present: LogRecord = {
1578
- ...warning,
1579
- category: ["app"],
1580
- timestamp: Date.now(),
1581
- };
1582
-
1583
- const trigger: LogRecord = {
1584
- ...error,
1585
- category: ["app"],
1586
- timestamp: Date.now() + 5000,
1587
- };
1588
-
1589
- // Add in random order
1590
- sink(future);
1591
- sink(past);
1592
- sink(present);
1593
-
1594
- // Trigger
1595
- sink(trigger);
1596
-
1597
- // Should be sorted by timestamp
1598
- assertEquals(buffer[0], past);
1599
- assertEquals(buffer[1], present);
1600
- assertEquals(buffer[2], future);
1601
- assertEquals(buffer[3], trigger);
1602
- });
1603
-
1604
- test("fingersCrossed() - edge case: NaN and Infinity in timestamps", () => {
1605
- const buffer: LogRecord[] = [];
1606
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1607
- isolateByCategory: "both",
1608
- });
1609
-
1610
- const nanTime: LogRecord = {
1611
- ...debug,
1612
- category: ["app"],
1613
- timestamp: NaN,
1614
- };
1615
-
1616
- const infinityTime: LogRecord = {
1617
- ...info,
1618
- category: ["app"],
1619
- timestamp: Infinity,
1620
- };
1621
-
1622
- const negInfinityTime: LogRecord = {
1623
- ...warning,
1624
- category: ["app"],
1625
- timestamp: -Infinity,
1626
- };
1627
-
1628
- const normalTime: LogRecord = {
1629
- ...error,
1630
- category: ["app"],
1631
- timestamp: 1000,
1632
- };
1633
-
1634
- sink(nanTime);
1635
- sink(infinityTime);
1636
- sink(negInfinityTime);
1637
-
1638
- // Should handle special values without crashing
1639
- sink(normalTime);
1640
-
1641
- // Check all records are present (order might vary with NaN)
1642
- assertEquals(buffer.length, 4);
1643
- assert(buffer.includes(nanTime));
1644
- assert(buffer.includes(infinityTime));
1645
- assert(buffer.includes(negInfinityTime));
1646
- assert(buffer.includes(normalTime));
1647
- });
1648
-
1649
- test("fingersCrossed() - edge case: undefined properties in record", () => {
1650
- const buffer: LogRecord[] = [];
1651
- const sink = fingersCrossed(buffer.push.bind(buffer));
1652
-
1653
- const weirdRecord: LogRecord = {
1654
- ...debug,
1655
- properties: {
1656
- normal: "value",
1657
- undef: undefined,
1658
- nullish: null,
1659
- nan: NaN,
1660
- inf: Infinity,
1661
- },
1662
- };
1663
-
1664
- sink(weirdRecord);
1665
- sink(error);
1666
-
1667
- // Should preserve all properties as-is
1668
- assertEquals(buffer[0].properties, weirdRecord.properties);
1669
- });
1670
-
1671
- test("fingersCrossed() - edge case: very deep category hierarchy", () => {
1672
- const buffer: LogRecord[] = [];
1673
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1674
- isolateByCategory: "both",
1675
- });
1676
-
1677
- // Create very deep hierarchy
1678
- const deepCategory = Array.from({ length: 100 }, (_, i) => `level${i}`);
1679
- const parentCategory = deepCategory.slice(0, 50);
1680
-
1681
- const deepRecord: LogRecord = {
1682
- ...debug,
1683
- category: deepCategory,
1684
- };
1685
-
1686
- const parentRecord: LogRecord = {
1687
- ...info,
1688
- category: parentCategory,
1689
- };
1690
-
1691
- const deepError: LogRecord = {
1692
- ...error,
1693
- category: deepCategory,
1694
- };
1695
-
1696
- sink(deepRecord);
1697
- sink(parentRecord);
1698
- assertEquals(buffer.length, 0);
1699
-
1700
- // Should handle deep hierarchies
1701
- sink(deepError);
1702
-
1703
- // Both should flush (ancestor relationship)
1704
- assertEquals(buffer.length, 3);
1705
- assert(buffer.includes(deepRecord));
1706
- assert(buffer.includes(parentRecord));
1707
- assert(buffer.includes(deepError));
1708
- });