@oneuptime/common 13.0.3 → 13.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,884 @@
1
+ import { describe, expect, test } from "@jest/globals";
2
+ import {
3
+ SESSION_REPLAY_RECORDER_ACTIVE_WINDOW_MS,
4
+ SESSION_REPLAY_REFUSAL_ALERT_THRESHOLD,
5
+ SESSION_REPLAY_STALE_CHUNK_MS,
6
+ diagnoseRecordingHealth,
7
+ formatBytesForCopy,
8
+ formatCountForCopy,
9
+ formatDurationForCopy,
10
+ formatRelativeAge,
11
+ getTopRefusal,
12
+ parseHealthTimestamp,
13
+ parseRecordingHealthStatus,
14
+ } from "../../../Utils/Rum/SessionReplayHealth";
15
+ import {
16
+ RecordingHealthDiagnosis,
17
+ RecordingHealthStatus,
18
+ SESSION_REPLAY_REFUSAL_REASONS,
19
+ SessionReplayRefusalCount,
20
+ SessionReplayRefusalReason,
21
+ } from "../../../Types/Rum/SessionReplayHealth";
22
+ import SessionReplayCaptureTrigger from "../../../Types/Rum/SessionReplayCaptureTrigger";
23
+ import SessionReplayConsentMode from "../../../Types/Rum/SessionReplayConsentMode";
24
+
25
+ /*
26
+ * Boundary and invalid-input coverage for the recording-health helpers.
27
+ * SessionReplayHealthDiagnosis.test.ts walks the priority table one state
28
+ * at a time; this suite pins the edges between those states and the parse
29
+ * of hostile or partial wire bodies.
30
+ */
31
+
32
+ const NOW: number = Date.UTC(2026, 8, 13, 12, 0, 0);
33
+ const SECOND: number = 1000;
34
+ const MINUTE: number = 60 * SECOND;
35
+ const HOUR: number = 60 * MINUTE;
36
+ const DAY: number = 24 * HOUR;
37
+ const MB: number = 1024 * 1024;
38
+ const GB: number = 1024 * MB;
39
+
40
+ function iso(offsetMs: number): string {
41
+ return new Date(NOW + offsetMs).toISOString();
42
+ }
43
+
44
+ function status(
45
+ overrides?: Partial<RecordingHealthStatus>,
46
+ ): RecordingHealthStatus {
47
+ const base: RecordingHealthStatus = {
48
+ appIdentifier: "acme-web",
49
+ allowedOrigins: ["https://acme.com"],
50
+ policy: {
51
+ isProjectEnabled: true,
52
+ isApplicationEnabled: true,
53
+ captureTrigger: SessionReplayCaptureTrigger.Always,
54
+ samplePercentage: 100,
55
+ consentMode: SessionReplayConsentMode.NotRequired,
56
+ maskingMode: "MaskSensitiveInputsOnly",
57
+ retentionInDays: 7,
58
+ },
59
+ publishedRecorderVersion: "1.2.3",
60
+ lastConfigFetchAt: iso(-5 * SECOND),
61
+ lastChunkReceivedAt: iso(-12 * SECOND),
62
+ lastSessionStartedAt: iso(-2 * MINUTE),
63
+ budgetExceededAt: null,
64
+ sessionsLast24h: 143,
65
+ playableSessionsLast24h: 120,
66
+ refusalsLast24h: [],
67
+ projectBytesUsedToday: 10 * MB,
68
+ dailyByteLimit: GB,
69
+ applicationBytesUsedThisMonth: 200 * MB,
70
+ monthlyBudgetInGB: 2,
71
+ };
72
+
73
+ return { ...base, ...overrides } as RecordingHealthStatus;
74
+ }
75
+
76
+ function withPolicy(
77
+ policy: Partial<RecordingHealthStatus["policy"]>,
78
+ overrides?: Partial<RecordingHealthStatus>,
79
+ ): RecordingHealthStatus {
80
+ const base: RecordingHealthStatus = status(overrides);
81
+
82
+ return {
83
+ ...base,
84
+ policy: { ...base.policy, ...policy },
85
+ } as RecordingHealthStatus;
86
+ }
87
+
88
+ function diagnose(
89
+ value: RecordingHealthStatus | null,
90
+ now: number = NOW,
91
+ ): RecordingHealthDiagnosis {
92
+ return diagnoseRecordingHealth(value, now);
93
+ }
94
+
95
+ describe("exported thresholds", () => {
96
+ test("have the documented values", () => {
97
+ expect(SESSION_REPLAY_REFUSAL_ALERT_THRESHOLD).toBe(5);
98
+ expect(SESSION_REPLAY_STALE_CHUNK_MS).toBe(6 * HOUR);
99
+ expect(SESSION_REPLAY_RECORDER_ACTIVE_WINDOW_MS).toBe(DAY);
100
+ });
101
+
102
+ test("a recorder can be stale before it stops counting as active", () => {
103
+ expect(SESSION_REPLAY_STALE_CHUNK_MS).toBeLessThan(
104
+ SESSION_REPLAY_RECORDER_ACTIVE_WINDOW_MS,
105
+ );
106
+ });
107
+ });
108
+
109
+ describe("formatRelativeAge", () => {
110
+ test.each([
111
+ [0, "just now"],
112
+ [999, "just now"],
113
+ [SECOND, "1s ago"],
114
+ [MINUTE - 1, "59s ago"],
115
+ [MINUTE, "1m ago"],
116
+ [HOUR - 1, "59m ago"],
117
+ [HOUR, "1h ago"],
118
+ [DAY - 1, "23h ago"],
119
+ [DAY, "1d ago"],
120
+ [400 * DAY, "400d ago"],
121
+ ])("an age of %p ms reads %p", (ageMs: number, expected: string) => {
122
+ expect(formatRelativeAge(NOW - ageMs, NOW)).toBe(expected);
123
+ });
124
+
125
+ test("any future timestamp (clock skew) reads just now, however far ahead", () => {
126
+ expect(formatRelativeAge(NOW + 1, NOW)).toBe("just now");
127
+ expect(formatRelativeAge(NOW + 30 * DAY, NOW)).toBe("just now");
128
+ });
129
+
130
+ test.each([
131
+ [NaN, NOW],
132
+ [NOW, NaN],
133
+ [Infinity, NOW],
134
+ [-Infinity, NOW],
135
+ [NOW, Infinity],
136
+ ])(
137
+ "non-finite input (%p, %p) reads unknown, never 0s ago",
138
+ (from: number, now: number) => {
139
+ expect(formatRelativeAge(from, now)).toBe("unknown");
140
+ },
141
+ );
142
+ });
143
+
144
+ describe("formatDurationForCopy", () => {
145
+ test.each([
146
+ [0, "0s"],
147
+ [999, "0s"],
148
+ [SECOND, "1s"],
149
+ [MINUTE - 1, "59s"],
150
+ [MINUTE, "1m"],
151
+ [HOUR - 1, "59m"],
152
+ [HOUR, "1h"],
153
+ [6 * HOUR + 59 * MINUTE, "6h"],
154
+ [DAY - 1, "23h"],
155
+ [DAY, "1d"],
156
+ [45 * DAY, "45d"],
157
+ ])("%p ms reads %p", (durationMs: number, expected: string) => {
158
+ expect(formatDurationForCopy(durationMs)).toBe(expected);
159
+ });
160
+
161
+ test.each([-1, -DAY, NaN, Infinity, -Infinity])(
162
+ "%p reads unknown",
163
+ (durationMs: number) => {
164
+ expect(formatDurationForCopy(durationMs)).toBe("unknown");
165
+ },
166
+ );
167
+ });
168
+
169
+ describe("formatCountForCopy", () => {
170
+ test.each([
171
+ [1, "1"],
172
+ [12, "12"],
173
+ [123, "123"],
174
+ [1234, "1,234"],
175
+ [12345, "12,345"],
176
+ [100000, "100,000"],
177
+ [999999, "999,999"],
178
+ [1000000, "1,000,000"],
179
+ [9876543210, "9,876,543,210"],
180
+ ])("%p reads %p", (count: number, expected: string) => {
181
+ expect(formatCountForCopy(count)).toBe(expected);
182
+ });
183
+
184
+ test("floors fractions rather than rounding up", () => {
185
+ expect(formatCountForCopy(1.9)).toBe("1");
186
+ expect(formatCountForCopy(1999.999)).toBe("1,999");
187
+ });
188
+
189
+ test("clamps negatives to 0", () => {
190
+ expect(formatCountForCopy(-5)).toBe("0");
191
+ expect(formatCountForCopy(-0.5)).toBe("0");
192
+ });
193
+
194
+ test.each([null, NaN, Infinity, -Infinity])(
195
+ "%p reads unknown",
196
+ (count: number | null) => {
197
+ expect(formatCountForCopy(count)).toBe("unknown");
198
+ },
199
+ );
200
+ });
201
+
202
+ describe("formatBytesForCopy", () => {
203
+ test.each([
204
+ [0, "0 MB"],
205
+ [MB / 2, "1 MB"],
206
+ [MB / 2 - 1, "0 MB"],
207
+ [1023 * MB, "1023 MB"],
208
+ [GB, "1 GB"],
209
+ [10 * GB, "10 GB"],
210
+ [1.5 * GB, "1.5 GB"],
211
+ [1.75 * GB, "1.8 GB"],
212
+ ])("%p bytes reads %p", (bytes: number, expected: string) => {
213
+ expect(formatBytesForCopy(bytes)).toBe(expected);
214
+ });
215
+
216
+ test("switches to GB exactly at one gibibyte", () => {
217
+ expect(formatBytesForCopy(GB - 1)).toBe("1024 MB");
218
+ expect(formatBytesForCopy(GB)).toBe("1 GB");
219
+ });
220
+
221
+ test.each([-1, NaN, Infinity, -Infinity])(
222
+ "%p reads unknown",
223
+ (bytes: number) => {
224
+ expect(formatBytesForCopy(bytes)).toBe("unknown");
225
+ },
226
+ );
227
+ });
228
+
229
+ describe("parseHealthTimestamp", () => {
230
+ test("parses ISO strings with a zone offset", () => {
231
+ expect(parseHealthTimestamp("2026-09-13T14:00:00+02:00")).toBe(NOW);
232
+ expect(parseHealthTimestamp("2026-09-13T12:00:00.000Z")).toBe(NOW);
233
+ });
234
+
235
+ test("a date-only ISO string is midnight UTC", () => {
236
+ expect(parseHealthTimestamp("2026-09-13")).toBe(Date.UTC(2026, 8, 13));
237
+ });
238
+
239
+ test("the epoch itself is a real timestamp, not absent", () => {
240
+ expect(parseHealthTimestamp("1970-01-01T00:00:00.000Z")).toBe(0);
241
+ });
242
+
243
+ test.each(["", "not a date", "2026-13-45T99:99:99Z"])(
244
+ "%p is null",
245
+ (value: string) => {
246
+ expect(parseHealthTimestamp(value)).toBeNull();
247
+ },
248
+ );
249
+
250
+ test("a non-string that slipped past the type is null", () => {
251
+ expect(parseHealthTimestamp(NOW as unknown as string)).toBeNull();
252
+ expect(parseHealthTimestamp(undefined as unknown as string)).toBeNull();
253
+ });
254
+ });
255
+
256
+ describe("getTopRefusal", () => {
257
+ test("a single entry is the top one, even with a zero count", () => {
258
+ const only: SessionReplayRefusalCount = {
259
+ reason: "rate-limited",
260
+ count: 0,
261
+ };
262
+
263
+ expect(getTopRefusal([only])).toBe(only);
264
+ });
265
+
266
+ test("ties keep the first entry seen", () => {
267
+ expect(
268
+ getTopRefusal([
269
+ { reason: "not-enabled", count: 7 },
270
+ { reason: "rate-limited", count: 7 },
271
+ ]),
272
+ ).toEqual({ reason: "not-enabled", count: 7 });
273
+ });
274
+
275
+ test("finds the maximum wherever it sits", () => {
276
+ expect(
277
+ getTopRefusal([
278
+ { reason: "not-sampled", count: 1 },
279
+ { reason: "rate-limited", count: 2 },
280
+ { reason: "consent-required", count: 50 },
281
+ ]),
282
+ ).toEqual({ reason: "consent-required", count: 50 });
283
+ });
284
+
285
+ test("returns the entry object itself rather than a copy", () => {
286
+ const refusals: Array<SessionReplayRefusalCount> = [
287
+ { reason: "not-sampled", count: 1 },
288
+ { reason: "rate-limited", count: 2 },
289
+ ];
290
+
291
+ expect(getTopRefusal(refusals)).toBe(refusals[1]);
292
+ });
293
+ });
294
+
295
+ describe("parseRecordingHealthStatus - invalid and partial bodies", () => {
296
+ test.each([undefined, 0, 42, true, false, "{}", [], [{ a: 1 }]])(
297
+ "%p is not a status",
298
+ (raw: unknown) => {
299
+ expect(parseRecordingHealthStatus(raw)).toBeNull();
300
+ },
301
+ );
302
+
303
+ test("an empty object parses to a fully defaulted, switched-off status", () => {
304
+ const parsed: RecordingHealthStatus | null = parseRecordingHealthStatus({});
305
+
306
+ expect(parsed).toEqual({
307
+ appIdentifier: "",
308
+ allowedOrigins: [],
309
+ policy: {
310
+ isProjectEnabled: false,
311
+ isApplicationEnabled: false,
312
+ captureTrigger: "",
313
+ samplePercentage: 0,
314
+ consentMode: "",
315
+ maskingMode: "",
316
+ retentionInDays: null,
317
+ },
318
+ publishedRecorderVersion: null,
319
+ lastConfigFetchAt: null,
320
+ lastChunkReceivedAt: null,
321
+ lastSessionStartedAt: null,
322
+ budgetExceededAt: null,
323
+ sessionsLast24h: null,
324
+ playableSessionsLast24h: null,
325
+ refusalsLast24h: null,
326
+ dropsLast24h: null,
327
+ recorderCapabilities: null,
328
+ projectBytesUsedToday: null,
329
+ dailyByteLimit: 0,
330
+ applicationBytesUsedThisMonth: null,
331
+ monthlyBudgetInGB: null,
332
+ });
333
+ expect(diagnose(parsed).state).toBe("disabled-project");
334
+ });
335
+
336
+ test("booleans accept true, 1 and '1' only", () => {
337
+ const read: (value: unknown) => boolean = (value: unknown): boolean => {
338
+ return parseRecordingHealthStatus({ isProjectAllowed: value })!.policy
339
+ .isProjectEnabled;
340
+ };
341
+
342
+ expect(read(true)).toBe(true);
343
+ expect(read(1)).toBe(true);
344
+ expect(read("1")).toBe(true);
345
+ expect(read("true")).toBe(false);
346
+ expect(read(2)).toBe(false);
347
+ expect(read("yes")).toBe(false);
348
+ expect(read(null)).toBe(false);
349
+ });
350
+
351
+ test("a non-numeric sample percentage or daily limit reads as 0", () => {
352
+ const parsed: RecordingHealthStatus = parseRecordingHealthStatus({
353
+ samplePercentage: "lots",
354
+ dailyByteLimit: null,
355
+ })!;
356
+
357
+ expect(parsed.policy.samplePercentage).toBe(0);
358
+ expect(parsed.dailyByteLimit).toBe(0);
359
+ });
360
+
361
+ test("numeric strings are read as numbers", () => {
362
+ const parsed: RecordingHealthStatus = parseRecordingHealthStatus({
363
+ samplePercentage: "25",
364
+ dailyByteLimit: "1073741824",
365
+ projectBytesUsedToday: "512",
366
+ monthlyBudgetInGB: "3",
367
+ })!;
368
+
369
+ expect(parsed.policy.samplePercentage).toBe(25);
370
+ expect(parsed.dailyByteLimit).toBe(GB);
371
+ expect(parsed.projectBytesUsedToday).toBe(512);
372
+ expect(parsed.monthlyBudgetInGB).toBe(3);
373
+ });
374
+
375
+ test("a counter measured as 0 stays 0; only absent, null, '' or garbage is null", () => {
376
+ const parsed: RecordingHealthStatus = parseRecordingHealthStatus({
377
+ sessionsLast24h: 0,
378
+ playableSessionsLast24h: "",
379
+ projectBytesUsedToday: "NaN",
380
+ applicationBytesUsedThisMonth: null,
381
+ retentionInDays: 0,
382
+ })!;
383
+
384
+ expect(parsed.sessionsLast24h).toBe(0);
385
+ expect(parsed.playableSessionsLast24h).toBeNull();
386
+ expect(parsed.projectBytesUsedToday).toBeNull();
387
+ expect(parsed.applicationBytesUsedThisMonth).toBeNull();
388
+ expect(parsed.policy.retentionInDays).toBe(0);
389
+ });
390
+
391
+ test("nullable strings: empty and non-string values are null", () => {
392
+ const parsed: RecordingHealthStatus = parseRecordingHealthStatus({
393
+ publishedRecorderVersion: "",
394
+ lastConfigFetchAt: 1234,
395
+ lastChunkReceivedAt: { at: "now" },
396
+ budgetExceededAt: false,
397
+ lastSessionStartedAt: iso(0),
398
+ })!;
399
+
400
+ expect(parsed.publishedRecorderVersion).toBeNull();
401
+ expect(parsed.lastConfigFetchAt).toBeNull();
402
+ expect(parsed.lastChunkReceivedAt).toBeNull();
403
+ expect(parsed.budgetExceededAt).toBeNull();
404
+ expect(parsed.lastSessionStartedAt).toBe(iso(0));
405
+ });
406
+
407
+ test("allowed origins and capabilities are stringified; a non-array origin list is empty", () => {
408
+ expect(
409
+ parseRecordingHealthStatus({
410
+ allowedOrigins: ["https://a.com", 7, true],
411
+ recorderCapabilities: ["clicks", 3],
412
+ }),
413
+ ).toMatchObject({
414
+ allowedOrigins: ["https://a.com", "7", "true"],
415
+ recorderCapabilities: ["clicks", "3"],
416
+ });
417
+
418
+ expect(
419
+ parseRecordingHealthStatus({
420
+ allowedOrigins: "https://a.com",
421
+ recorderCapabilities: "clicks",
422
+ }),
423
+ ).toMatchObject({ allowedOrigins: [], recorderCapabilities: null });
424
+ });
425
+
426
+ test("a refusal list that is not an array is unknown, not empty", () => {
427
+ expect(
428
+ parseRecordingHealthStatus({ refusalsLast24h: { "not-sampled": 3 } })!
429
+ .refusalsLast24h,
430
+ ).toBeNull();
431
+ expect(
432
+ parseRecordingHealthStatus({ refusalsLast24h: "[]" })!.refusalsLast24h,
433
+ ).toBeNull();
434
+ });
435
+
436
+ test("refusal entries without a usable count or with a malformed shape are dropped; a 0 count is kept", () => {
437
+ const parsed: RecordingHealthStatus = parseRecordingHealthStatus({
438
+ refusalsLast24h: [
439
+ { reason: "not-sampled", count: 0 },
440
+ { reason: "rate-limited", count: "abc" },
441
+ { reason: "rate-limited", count: null },
442
+ { reason: "rate-limited", count: "" },
443
+ { reason: "ORIGIN-NOT-ALLOWED", count: 9 },
444
+ { count: 9 },
445
+ "origin-not-allowed",
446
+ 42,
447
+ ["origin-not-allowed", 9],
448
+ { reason: "consent-required", count: "12" },
449
+ ],
450
+ })!;
451
+
452
+ expect(parsed.refusalsLast24h).toEqual([
453
+ { reason: "not-sampled", count: 0 },
454
+ { reason: "consent-required", count: 12 },
455
+ ]);
456
+ });
457
+
458
+ test("every reason in the closed vocabulary survives the parse", () => {
459
+ const parsed: RecordingHealthStatus = parseRecordingHealthStatus({
460
+ refusalsLast24h: SESSION_REPLAY_REFUSAL_REASONS.map(
461
+ (reason: SessionReplayRefusalReason, index: number) => {
462
+ return { reason: reason, count: index };
463
+ },
464
+ ),
465
+ })!;
466
+
467
+ expect(
468
+ parsed.refusalsLast24h!.map((entry: SessionReplayRefusalCount) => {
469
+ return entry.reason;
470
+ }),
471
+ ).toEqual([...SESSION_REPLAY_REFUSAL_REASONS]);
472
+ });
473
+
474
+ test("drops keep unknown reasons but skip blank or non-string reasons and missing counts", () => {
475
+ const parsed: RecordingHealthStatus = parseRecordingHealthStatus({
476
+ dropsLast24h: [
477
+ { reason: "scrub-incomplete", count: 12 },
478
+ { reason: "brand-new-reason", count: "3" },
479
+ { reason: "", count: 4 },
480
+ { reason: 5, count: 4 },
481
+ { reason: "over-cap" },
482
+ null,
483
+ ],
484
+ })!;
485
+
486
+ expect(parsed.dropsLast24h).toEqual([
487
+ { reason: "scrub-incomplete", count: 12 },
488
+ { reason: "brand-new-reason", count: 3 },
489
+ ]);
490
+ });
491
+
492
+ test("does not mutate the raw body", () => {
493
+ const raw: Record<string, unknown> = {
494
+ isProjectAllowed: 1,
495
+ refusalsLast24h: [{ reason: "not-sampled", count: "3" }],
496
+ };
497
+ const snapshot: string = JSON.stringify(raw);
498
+
499
+ parseRecordingHealthStatus(raw);
500
+
501
+ expect(JSON.stringify(raw)).toBe(snapshot);
502
+ });
503
+ });
504
+
505
+ describe("diagnoseRecordingHealth - budget boundaries", () => {
506
+ test("daily: usage exactly at the limit is paused; one byte under is not", () => {
507
+ expect(
508
+ diagnose(status({ projectBytesUsedToday: GB, dailyByteLimit: GB })).state,
509
+ ).toBe("budget-paused");
510
+ expect(
511
+ diagnose(status({ projectBytesUsedToday: GB - 1, dailyByteLimit: GB }))
512
+ .state,
513
+ ).toBe("healthy");
514
+ });
515
+
516
+ test("daily: a zero limit means no limit, whatever the usage", () => {
517
+ expect(
518
+ diagnose(status({ projectBytesUsedToday: 50 * GB, dailyByteLimit: 0 }))
519
+ .state,
520
+ ).toBe("healthy");
521
+ });
522
+
523
+ test("daily: quantifies both the limit and the usage", () => {
524
+ const diagnosis: RecordingHealthDiagnosis = diagnose(
525
+ status({ projectBytesUsedToday: 2.5 * GB, dailyByteLimit: 2 * GB }),
526
+ );
527
+
528
+ expect(diagnosis).toEqual({
529
+ state: "budget-paused",
530
+ severity: "error",
531
+ title: "Uploads paused for today",
532
+ detail:
533
+ "This project used its 2 GB daily budget for session replay (2.5 GB used). Uploads resume tomorrow.",
534
+ action: { label: "Review the budget", target: "budget" },
535
+ });
536
+ });
537
+
538
+ test("monthly: usage exactly at the budget still counts as paused; one byte under clears the stamp", () => {
539
+ expect(
540
+ diagnose(
541
+ status({
542
+ budgetExceededAt: iso(-HOUR),
543
+ monthlyBudgetInGB: 2,
544
+ applicationBytesUsedThisMonth: 2 * GB,
545
+ }),
546
+ ).state,
547
+ ).toBe("budget-paused");
548
+ expect(
549
+ diagnose(
550
+ status({
551
+ budgetExceededAt: iso(-HOUR),
552
+ monthlyBudgetInGB: 2,
553
+ applicationBytesUsedThisMonth: 2 * GB - 1,
554
+ }),
555
+ ).state,
556
+ ).toBe("healthy");
557
+ });
558
+
559
+ test("monthly: a zero budget cannot clear the stamp", () => {
560
+ expect(
561
+ diagnose(
562
+ status({
563
+ budgetExceededAt: iso(-HOUR),
564
+ monthlyBudgetInGB: 0,
565
+ applicationBytesUsedThisMonth: 0,
566
+ }),
567
+ ).state,
568
+ ).toBe("budget-paused");
569
+ });
570
+
571
+ test("monthly: an unknown budget says 'its monthly budget' without a number", () => {
572
+ const diagnosis: RecordingHealthDiagnosis = diagnose(
573
+ status({ budgetExceededAt: iso(-3 * HOUR), monthlyBudgetInGB: null }),
574
+ );
575
+
576
+ expect(diagnosis.title).toBe("Uploads paused 3h ago");
577
+ expect(diagnosis.detail).toBe(
578
+ "This application used its monthly budget. Uploads resume next month, or as soon as the budget is raised.",
579
+ );
580
+ });
581
+
582
+ test("monthly: an unparseable stamp still pauses, but the title claims no age", () => {
583
+ const diagnosis: RecordingHealthDiagnosis = diagnose(
584
+ status({
585
+ budgetExceededAt: "yesterday-ish",
586
+ applicationBytesUsedThisMonth: null,
587
+ }),
588
+ );
589
+
590
+ expect(diagnosis.state).toBe("budget-paused");
591
+ expect(diagnosis.title).toBe("Uploads paused");
592
+ });
593
+
594
+ test("monthly wins over daily when both are spent", () => {
595
+ const diagnosis: RecordingHealthDiagnosis = diagnose(
596
+ status({
597
+ budgetExceededAt: iso(-DAY),
598
+ applicationBytesUsedThisMonth: null,
599
+ projectBytesUsedToday: GB,
600
+ dailyByteLimit: GB,
601
+ }),
602
+ );
603
+
604
+ expect(diagnosis.action?.label).toBe("Raise the budget");
605
+ expect(diagnosis.title).toBe("Uploads paused 1d ago");
606
+ });
607
+ });
608
+
609
+ describe("diagnoseRecordingHealth - refusal boundaries", () => {
610
+ test("exactly the threshold is refusing; one below is not", () => {
611
+ expect(
612
+ diagnose(
613
+ status({
614
+ refusalsLast24h: [
615
+ {
616
+ reason: "rate-limited",
617
+ count: SESSION_REPLAY_REFUSAL_ALERT_THRESHOLD,
618
+ },
619
+ ],
620
+ }),
621
+ ).state,
622
+ ).toBe("refusing");
623
+ expect(
624
+ diagnose(
625
+ status({
626
+ refusalsLast24h: [
627
+ {
628
+ reason: "rate-limited",
629
+ count: SESSION_REPLAY_REFUSAL_ALERT_THRESHOLD - 1,
630
+ },
631
+ ],
632
+ }),
633
+ ).state,
634
+ ).toBe("healthy");
635
+ });
636
+
637
+ test("the threshold applies to the top reason, not to the sum of all reasons", () => {
638
+ expect(
639
+ diagnose(
640
+ status({
641
+ refusalsLast24h: [
642
+ { reason: "rate-limited", count: 4 },
643
+ { reason: "not-sampled", count: 4 },
644
+ { reason: "origin-not-allowed", count: 4 },
645
+ ],
646
+ }),
647
+ ).state,
648
+ ).toBe("healthy");
649
+ });
650
+
651
+ test("a reason with no fix in the product carries no action key at all", () => {
652
+ const diagnosis: RecordingHealthDiagnosis = diagnose(
653
+ status({ refusalsLast24h: [{ reason: "rate-limited", count: 1200 }] }),
654
+ );
655
+
656
+ expect(diagnosis.title).toBe("1,200 uploads refused in 24h: rate limited");
657
+ expect(diagnosis.severity).toBe("warning");
658
+ expect(Object.prototype.hasOwnProperty.call(diagnosis, "action")).toBe(
659
+ false,
660
+ );
661
+ });
662
+
663
+ test("no refusal title ever shows the bare kebab-case reason code", () => {
664
+ for (const reason of SESSION_REPLAY_REFUSAL_REASONS) {
665
+ const diagnosis: RecordingHealthDiagnosis = diagnose(
666
+ status({ refusalsLast24h: [{ reason: reason, count: 10 }] }),
667
+ );
668
+
669
+ expect(diagnosis.state).toBe("refusing");
670
+ expect(diagnosis.title).not.toContain(reason);
671
+ expect(diagnosis.detail.length).toBeGreaterThan(0);
672
+ }
673
+ });
674
+
675
+ test("a parsed wire body with an origin problem diagnoses end to end", () => {
676
+ const parsed: RecordingHealthStatus | null = parseRecordingHealthStatus({
677
+ isProjectAllowed: true,
678
+ isApplicationEnabled: true,
679
+ appIdentifier: "acme-web",
680
+ samplePercentage: 100,
681
+ dailyByteLimit: GB,
682
+ lastConfigFetchAt: iso(-MINUTE),
683
+ refusalsLast24h: [
684
+ { reason: "origin-not-allowed", count: "340" },
685
+ { reason: "not-sampled", count: 2 },
686
+ ],
687
+ });
688
+
689
+ expect(diagnose(parsed)).toEqual({
690
+ state: "refusing",
691
+ severity: "warning",
692
+ title: "340 uploads refused in 24h: origin not allowed",
693
+ detail:
694
+ "Requests came from an origin that is not in your allowed origins.",
695
+ action: { label: "Edit allowed origins", target: "allowed-origins" },
696
+ });
697
+ });
698
+ });
699
+
700
+ describe("diagnoseRecordingHealth - loading and staleness boundaries", () => {
701
+ test("disabled-app omits the 'for' suffix when the identifier is unknown", () => {
702
+ expect(
703
+ diagnose(
704
+ withPolicy({ isApplicationEnabled: false }, { appIdentifier: "" }),
705
+ ).title,
706
+ ).toBe("Session replay is switched off");
707
+ });
708
+
709
+ test("never-loaded omits the 'for' suffix when the identifier is unknown", () => {
710
+ expect(
711
+ diagnose(
712
+ status({
713
+ appIdentifier: "",
714
+ lastConfigFetchAt: null,
715
+ lastChunkReceivedAt: null,
716
+ }),
717
+ ).title,
718
+ ).toBe("The recorder has never loaded");
719
+ });
720
+
721
+ test("two unparseable stamps are never-loaded, not a crash", () => {
722
+ expect(
723
+ diagnose(
724
+ status({ lastConfigFetchAt: "nope", lastChunkReceivedAt: "nope" }),
725
+ ).state,
726
+ ).toBe("never-loaded");
727
+ });
728
+
729
+ test("a negative sample percentage is treated like 0%", () => {
730
+ const diagnosis: RecordingHealthDiagnosis = diagnose(
731
+ withPolicy(
732
+ { samplePercentage: -10 },
733
+ { lastChunkReceivedAt: null, lastConfigFetchAt: iso(-MINUTE) },
734
+ ),
735
+ );
736
+
737
+ expect(diagnosis.state).toBe("loaded-never-uploaded");
738
+ expect(diagnosis.action).toEqual({
739
+ label: "Set sampling to 100%",
740
+ target: "app-settings",
741
+ });
742
+ });
743
+
744
+ test("a config fetch stamped in the future still reads as just now", () => {
745
+ expect(
746
+ diagnose(
747
+ status({
748
+ lastChunkReceivedAt: null,
749
+ lastConfigFetchAt: iso(5 * MINUTE),
750
+ }),
751
+ ).title,
752
+ ).toBe("The recorder loaded just now but nothing has been uploaded");
753
+ });
754
+
755
+ test("the active window is inclusive: a policy fetch exactly 24h ago still means stale", () => {
756
+ const base: Partial<RecordingHealthStatus> = {
757
+ lastChunkReceivedAt: iso(-2 * DAY),
758
+ };
759
+
760
+ expect(
761
+ diagnose(
762
+ status({
763
+ ...base,
764
+ lastConfigFetchAt: iso(-SESSION_REPLAY_RECORDER_ACTIVE_WINDOW_MS),
765
+ }),
766
+ ).state,
767
+ ).toBe("stale");
768
+ expect(
769
+ diagnose(
770
+ status({
771
+ ...base,
772
+ lastConfigFetchAt: iso(-SESSION_REPLAY_RECORDER_ACTIVE_WINDOW_MS - 1),
773
+ }),
774
+ ).state,
775
+ ).toBe("healthy-quiet");
776
+ });
777
+
778
+ test("stale carries the policy-specific explanation and action", () => {
779
+ const diagnosis: RecordingHealthDiagnosis = diagnose(
780
+ withPolicy(
781
+ { consentMode: SessionReplayConsentMode.RequireExplicit },
782
+ {
783
+ lastChunkReceivedAt: iso(-7 * HOUR),
784
+ lastConfigFetchAt: iso(-2 * MINUTE),
785
+ },
786
+ ),
787
+ );
788
+
789
+ expect(diagnosis.state).toBe("stale");
790
+ expect(diagnosis.title).toBe(
791
+ "No chunk for 7h while the recorder keeps loading",
792
+ );
793
+ expect(diagnosis.detail).toContain(
794
+ "The recorder last fetched its policy 2m ago, but the last chunk arrived 7h ago.",
795
+ );
796
+ expect(diagnosis.detail).toContain("Waiting for consent");
797
+ expect(diagnosis.action).toEqual({
798
+ label: "How consent works",
799
+ target: "docs-consent",
800
+ });
801
+ });
802
+
803
+ test("an older server (no config stamp) with a recent chunk is simply healthy", () => {
804
+ expect(
805
+ diagnose(
806
+ status({
807
+ lastConfigFetchAt: null,
808
+ lastChunkReceivedAt: iso(-SESSION_REPLAY_STALE_CHUNK_MS),
809
+ }),
810
+ ).state,
811
+ ).toBe("healthy");
812
+ });
813
+
814
+ test("a chunk stamped in the future (clock skew) is healthy and 'just now'", () => {
815
+ const diagnosis: RecordingHealthDiagnosis = diagnose(
816
+ status({ lastChunkReceivedAt: iso(30 * SECOND) }),
817
+ );
818
+
819
+ expect(diagnosis.state).toBe("healthy");
820
+ expect(diagnosis.detail.startsWith("Last chunk just now - ")).toBe(true);
821
+ });
822
+ });
823
+
824
+ describe("diagnoseRecordingHealth - healthy copy", () => {
825
+ test("unknown session counter says so rather than printing 0", () => {
826
+ expect(
827
+ diagnose(status({ sessionsLast24h: null, playableSessionsLast24h: 5 }))
828
+ .detail,
829
+ ).toBe("Last chunk 12s ago - sessions in 24h: unknown - sampling 100%.");
830
+ });
831
+
832
+ test("an unknown playable counter drops the parenthetical", () => {
833
+ expect(
834
+ diagnose(status({ sessionsLast24h: 2500, playableSessionsLast24h: null }))
835
+ .detail,
836
+ ).toBe("Last chunk 12s ago - 2,500 sessions in 24h - sampling 100%.");
837
+ });
838
+
839
+ test("zero sessions is plural and a measured 0 playable is shown", () => {
840
+ expect(
841
+ diagnose(
842
+ withPolicy(
843
+ { samplePercentage: 25 },
844
+ { sessionsLast24h: 0, playableSessionsLast24h: 0 },
845
+ ),
846
+ ).detail,
847
+ ).toBe(
848
+ "Last chunk 12s ago - 0 sessions in 24h (0 playable) - sampling 25%.",
849
+ );
850
+ });
851
+
852
+ test("healthy offers no action", () => {
853
+ const diagnosis: RecordingHealthDiagnosis = diagnose(status());
854
+
855
+ expect(diagnosis.severity).toBe("ok");
856
+ expect(diagnosis.action).toBeUndefined();
857
+ });
858
+ });
859
+
860
+ describe("diagnoseRecordingHealth - purity", () => {
861
+ test("does not mutate the status it is given", () => {
862
+ const input: RecordingHealthStatus = status({
863
+ refusalsLast24h: [
864
+ { reason: "not-sampled", count: 1 },
865
+ { reason: "origin-not-allowed", count: 9 },
866
+ ],
867
+ budgetExceededAt: iso(-HOUR),
868
+ applicationBytesUsedThisMonth: 0,
869
+ });
870
+ const snapshot: string = JSON.stringify(input);
871
+
872
+ diagnose(input);
873
+
874
+ expect(JSON.stringify(input)).toBe(snapshot);
875
+ });
876
+
877
+ test("returns an equal diagnosis for repeated calls with the same input", () => {
878
+ const input: RecordingHealthStatus = status({
879
+ refusalsLast24h: [{ reason: "not-enabled", count: 10 }],
880
+ });
881
+
882
+ expect(diagnose(input)).toEqual(diagnose(input));
883
+ });
884
+ });