@oneuptime/common 14.0.2 → 14.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/Models/DatabaseModels/AlertFeed.ts +2 -0
  2. package/Models/DatabaseModels/IncidentAlert.ts +540 -0
  3. package/Models/DatabaseModels/IncidentFeed.ts +2 -0
  4. package/Models/DatabaseModels/Index.ts +2 -0
  5. package/Models/DatabaseModels/Project.ts +60 -0
  6. package/Models/DatabaseModels/RumApplication.ts +52 -1
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.ts +28 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.ts +89 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  10. package/Server/Services/IncidentAlertService.ts +1811 -0
  11. package/Server/Services/IncidentService.ts +210 -10
  12. package/Server/Services/IncidentStateTimelineService.ts +24 -0
  13. package/Server/Services/Index.ts +2 -0
  14. package/Server/Utils/SessionReplay/SessionReplayGateCache.ts +24 -2
  15. package/Tests/App/Dashboard/BulkIncidentLinkActions.test.tsx +869 -0
  16. package/Tests/App/Dashboard/CephMetricTooltips.test.tsx +1777 -0
  17. package/Tests/App/Dashboard/CloudFleetSummary.test.ts +8 -0
  18. package/Tests/App/Dashboard/CloudMetricTooltips.test.tsx +887 -0
  19. package/Tests/App/Dashboard/ContainerHostMetricTooltips.test.tsx +1309 -0
  20. package/Tests/App/Dashboard/DiscoveryScanLivePage.test.tsx +39 -0
  21. package/Tests/App/Dashboard/DockerSwarmClustersListTooltips.test.tsx +414 -0
  22. package/Tests/App/Dashboard/DockerSwarmMetricTooltips.test.ts +796 -0
  23. package/Tests/App/Dashboard/DockerSwarmOverviewTooltips.test.tsx +747 -0
  24. package/Tests/App/Dashboard/ExceptionMonitorEnvironmentFilter.test.tsx +38 -18
  25. package/Tests/App/Dashboard/HostDetailViewTooltips.test.tsx +725 -0
  26. package/Tests/App/Dashboard/HostListColumnTooltips.test.tsx +472 -0
  27. package/Tests/App/Dashboard/HostMetricTooltips.test.ts +709 -0
  28. package/Tests/App/Dashboard/HostOverviewTooltips.test.tsx +698 -0
  29. package/Tests/App/Dashboard/HostTooltipHarness.ts +124 -0
  30. package/Tests/App/Dashboard/HostsListTooltips.test.tsx +395 -0
  31. package/Tests/App/Dashboard/IncidentAlertLinkDialog.test.tsx +691 -0
  32. package/Tests/App/Dashboard/IncidentAlertLinkHelpers.test.ts +458 -0
  33. package/Tests/App/Dashboard/IncidentAlertLinkPages.test.tsx +828 -0
  34. package/Tests/App/Dashboard/IncidentAlertLinkSideMenus.test.tsx +231 -0
  35. package/Tests/App/Dashboard/IncidentCreateFromAlerts.test.tsx +689 -0
  36. package/Tests/App/Dashboard/IoTMetricTooltips.test.tsx +1123 -0
  37. package/Tests/App/Dashboard/KubernetesClusterMetricTooltips.test.ts +837 -0
  38. package/Tests/App/Dashboard/KubernetesClusterOverviewTooltips.test.tsx +699 -0
  39. package/Tests/App/Dashboard/KubernetesContainersTabTooltips.test.tsx +320 -0
  40. package/Tests/App/Dashboard/KubernetesCostTooltips.test.tsx +502 -0
  41. package/Tests/App/Dashboard/KubernetesInsightsTooltips.test.tsx +222 -0
  42. package/Tests/App/Dashboard/KubernetesResourceMetricTooltips.test.ts +561 -0
  43. package/Tests/App/Dashboard/KubernetesResourcePagesTooltips.test.tsx +1168 -0
  44. package/Tests/App/Dashboard/LinkIncidentAlertModal.test.tsx +154 -0
  45. package/Tests/App/Dashboard/MetricDescriptionRules.ts +170 -0
  46. package/Tests/App/Dashboard/MetricDescriptionsCatalog.test.ts +206 -0
  47. package/Tests/App/Dashboard/MetricDescriptionsConsistency.test.ts +275 -0
  48. package/Tests/App/Dashboard/NetworkDeviceTooltips.test.tsx +852 -0
  49. package/Tests/App/Dashboard/NetworkDiagnosticsTooltips.test.tsx +556 -0
  50. package/Tests/App/Dashboard/NetworkMetricTooltips.test.ts +1348 -0
  51. package/Tests/App/Dashboard/NetworkSiteTooltips.test.tsx +679 -0
  52. package/Tests/App/Dashboard/ProxmoxMetricTooltips.test.ts +592 -0
  53. package/Tests/App/Dashboard/ProxmoxOverviewTooltips.test.tsx +1002 -0
  54. package/Tests/App/Dashboard/ProxmoxResourcePageTooltips.test.tsx +923 -0
  55. package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +1066 -0
  56. package/Tests/App/Dashboard/RumOverviewPage.test.tsx +1195 -0
  57. package/Tests/App/Dashboard/ServerlessMetricTooltips.test.tsx +474 -0
  58. package/Tests/App/Dashboard/ServiceMetricTooltips.test.tsx +772 -0
  59. package/Tests/App/Dashboard/SessionReplayAuditTable.test.tsx +56 -1
  60. package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +1207 -0
  61. package/Tests/App/Dashboard/TelemetryResourceTabTooltips.test.tsx +437 -0
  62. package/Tests/App/Dashboard/VMwareMetricTooltips.test.ts +801 -0
  63. package/Tests/App/Dashboard/VMwareOverviewTooltips.test.tsx +601 -0
  64. package/Tests/App/Dashboard/VMwareResourcePagesTooltips.test.tsx +646 -0
  65. package/Tests/App/Dashboard/VMwareTooltipHarness.ts +145 -0
  66. package/Tests/Models/DatabaseModels/IncidentAlertModel.test.ts +554 -0
  67. package/Tests/Models/DatabaseModels/SessionReplayModels.test.ts +117 -0
  68. package/Tests/Server/Infrastructure/Postgres/AddIncidentAlertMigration.test.ts +341 -0
  69. package/Tests/Server/Infrastructure/Postgres/AddSessionReplaySameOriginTracePropagationMigration.test.ts +247 -0
  70. package/Tests/Server/Services/IncidentAlertPostgres.test.ts +903 -0
  71. package/Tests/Server/Services/IncidentAlertRegistration.test.ts +96 -0
  72. package/Tests/Server/Services/IncidentAlertService.test.ts +2225 -0
  73. package/Tests/Server/Services/IncidentAlertStateCascade.test.ts +970 -0
  74. package/Tests/Server/Services/IncidentCreateFromAlerts.test.ts +1284 -0
  75. package/Tests/Server/Utils/SessionReplay/SessionReplayGateCachePolicy.test.ts +103 -0
  76. package/Tests/Types/Rum/WebVitals.test.ts +173 -0
  77. package/Tests/UI/Components/FieldLabelInfoCardTooltip.test.tsx +413 -0
  78. package/Tests/UI/Components/InfoTooltip.test.tsx +242 -0
  79. package/Tests/UI/Components/ModelTable/BaseModelTableBulkDelete.test.tsx +207 -3
  80. package/Tests/UI/Components/ModelTable/BaseModelTableBulkDeleteVerb.test.tsx +367 -0
  81. package/Tests/UI/Components/ModelTable/BaseModelTableCreateButtonTitle.test.tsx +275 -0
  82. package/Tests/UI/Components/TableHeaderTooltip.test.tsx +662 -0
  83. package/Tests/UI/Rum/PrivacySummaryCard.test.tsx +166 -9
  84. package/Tests/UI/Rum/RecordingHealthTooltips.test.tsx +447 -0
  85. package/Tests/UI/Rum/ReplayCorrelationPanel.test.tsx +164 -0
  86. package/Tests/UI/Rum/ReplayFrameCapture.test.ts +6750 -0
  87. package/Tests/UI/Rum/ReplayRail.test.tsx +723 -12
  88. package/Tests/UI/Rum/ReplayRailDetail.test.tsx +86 -0
  89. package/Tests/UI/Rum/ReplayScreenshot.test.ts +2124 -0
  90. package/Tests/UI/Rum/ReplayScreenshotActions.test.tsx +3288 -0
  91. package/Tests/UI/Rum/ReplayStage.test.tsx +62 -0
  92. package/Tests/UI/Rum/ReplayStageOverlays.test.tsx +983 -2
  93. package/Tests/UI/Rum/SessionReplaySettingsPolicyLoading.test.tsx +149 -0
  94. package/Tests/UI/Rum/SessionReplaySetupGuide.test.tsx +107 -6
  95. package/Tests/UI/Rum/SessionReplayUsersTableTooltips.test.tsx +330 -0
  96. package/Tests/UI/Rum/UserFlowTooltips.test.tsx +886 -0
  97. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
  98. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
  99. package/Tests/UI/Utils/PermissionGate.test.ts +76 -0
  100. package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +696 -0
  101. package/Tests/Utils/Rum/SessionTraceState.test.ts +239 -0
  102. package/Types/Incident/IncidentAlertLink.ts +38 -0
  103. package/Types/Permission.ts +48 -0
  104. package/Types/Rum/SessionReplay.ts +25 -6
  105. package/Types/Rum/WebVitals.ts +52 -0
  106. package/UI/Components/Detail/FieldLabel.tsx +8 -0
  107. package/UI/Components/InfoCard/InfoCard.tsx +47 -1
  108. package/UI/Components/ModelTable/BaseModelTable.tsx +95 -17
  109. package/UI/Components/ModelTable/Column.ts +5 -0
  110. package/UI/Components/Table/TableHeader.tsx +47 -2
  111. package/UI/Components/Table/Types/Column.ts +7 -0
  112. package/UI/Components/Tooltip/InfoTooltip.tsx +78 -0
  113. package/UI/Utils/PermissionGate.ts +11 -2
  114. package/Utils/Incident/IncidentFromAlerts.ts +516 -0
  115. package/Utils/Rum/SessionTraceState.ts +212 -0
  116. package/build/dist/Models/DatabaseModels/AlertFeed.js +2 -0
  117. package/build/dist/Models/DatabaseModels/AlertFeed.js.map +1 -1
  118. package/build/dist/Models/DatabaseModels/IncidentAlert.js +553 -0
  119. package/build/dist/Models/DatabaseModels/IncidentAlert.js.map +1 -0
  120. package/build/dist/Models/DatabaseModels/IncidentFeed.js +2 -0
  121. package/build/dist/Models/DatabaseModels/IncidentFeed.js.map +1 -1
  122. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  123. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  124. package/build/dist/Models/DatabaseModels/Project.js +62 -0
  125. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  126. package/build/dist/Models/DatabaseModels/RumApplication.js +53 -1
  127. package/build/dist/Models/DatabaseModels/RumApplication.js.map +1 -1
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js +20 -0
  129. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js.map +1 -0
  130. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js +42 -0
  131. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js.map +1 -0
  132. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  133. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  134. package/build/dist/Server/Services/IncidentAlertService.js +1370 -0
  135. package/build/dist/Server/Services/IncidentAlertService.js.map +1 -0
  136. package/build/dist/Server/Services/IncidentService.js +164 -12
  137. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  138. package/build/dist/Server/Services/IncidentStateTimelineService.js +21 -0
  139. package/build/dist/Server/Services/IncidentStateTimelineService.js.map +1 -1
  140. package/build/dist/Server/Services/Index.js +2 -0
  141. package/build/dist/Server/Services/Index.js.map +1 -1
  142. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js +8 -0
  143. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js.map +1 -1
  144. package/build/dist/Types/Incident/IncidentAlertLink.js +34 -0
  145. package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -0
  146. package/build/dist/Types/Permission.js +42 -0
  147. package/build/dist/Types/Permission.js.map +1 -1
  148. package/build/dist/Types/Rum/WebVitals.js +25 -0
  149. package/build/dist/Types/Rum/WebVitals.js.map +1 -1
  150. package/build/dist/UI/Components/Detail/FieldLabel.js +2 -0
  151. package/build/dist/UI/Components/Detail/FieldLabel.js.map +1 -1
  152. package/build/dist/UI/Components/InfoCard/InfoCard.js +22 -1
  153. package/build/dist/UI/Components/InfoCard/InfoCard.js.map +1 -1
  154. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +66 -14
  155. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  156. package/build/dist/UI/Components/Table/TableHeader.js +18 -2
  157. package/build/dist/UI/Components/Table/TableHeader.js.map +1 -1
  158. package/build/dist/UI/Components/Tooltip/InfoTooltip.js +28 -0
  159. package/build/dist/UI/Components/Tooltip/InfoTooltip.js.map +1 -0
  160. package/build/dist/UI/Utils/PermissionGate.js +4 -2
  161. package/build/dist/UI/Utils/PermissionGate.js.map +1 -1
  162. package/build/dist/Utils/Incident/IncidentFromAlerts.js +306 -0
  163. package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -0
  164. package/build/dist/Utils/Rum/SessionTraceState.js +154 -0
  165. package/build/dist/Utils/Rum/SessionTraceState.js.map +1 -0
  166. package/package.json +1 -1
@@ -372,3 +372,106 @@ describe("SessionReplayGateCache.resolvePolicy names the refusal", (): void => {
372
372
  expect(resolution.refusal).toBeNull();
373
373
  });
374
374
  });
375
+
376
+ /*
377
+ * RumApplication.sessionReplaySameOriginTracePropagation: the per-app switch
378
+ * for the recorder's automatic same-origin traceparent + tracestate. The
379
+ * column is NOT NULL DEFAULT true (on for existing applications too), so
380
+ * the gate reads anything but an explicit false as on - the model-default
381
+ * rule of the fallbacks above, not the feature-off rule of the origin list.
382
+ * Only an operator who switched it off gets it off.
383
+ */
384
+ describe("SessionReplayGateCache same-origin trace propagation switch", (): void => {
385
+ beforeEach((): void => {
386
+ jest.clearAllMocks();
387
+ projectFindOneByMock.mockResolvedValue(projectRow());
388
+ });
389
+
390
+ async function resolveWith(
391
+ overrides?: Record<string, unknown>,
392
+ ): Promise<SessionReplayGatePolicy | null> {
393
+ appFindOneByMock.mockResolvedValue(appRow(overrides));
394
+
395
+ return await SessionReplayGateCache.getPolicy({
396
+ projectId: PROJECT_ID,
397
+ appIdentifier: "checkout-web",
398
+ });
399
+ }
400
+
401
+ it("selects the column from RumApplication", async (): Promise<void> => {
402
+ await resolveWith();
403
+
404
+ expect(appFindOneByMock).toHaveBeenCalled();
405
+
406
+ const select: Record<string, unknown> = (
407
+ appFindOneByMock.mock.calls[0]?.[0] as { select: Record<string, unknown> }
408
+ ).select;
409
+
410
+ expect(select["sessionReplaySameOriginTracePropagation"]).toBe(true);
411
+
412
+ /* Next to the cross-origin list it complements, which is still read. */
413
+ expect(select["sessionReplayTracePropagationOrigins"]).toBe(true);
414
+ });
415
+
416
+ it("an explicit false turns it off", async (): Promise<void> => {
417
+ const policy: SessionReplayGatePolicy | null = await resolveWith({
418
+ sessionReplaySameOriginTracePropagation: false,
419
+ });
420
+
421
+ expect(policy).not.toBeNull();
422
+ expect(policy?.sameOriginTracePropagation).toBe(false);
423
+ });
424
+
425
+ it("an explicit true keeps it on", async (): Promise<void> => {
426
+ const policy: SessionReplayGatePolicy | null = await resolveWith({
427
+ sessionReplaySameOriginTracePropagation: true,
428
+ });
429
+
430
+ expect(policy?.sameOriginTracePropagation).toBe(true);
431
+ });
432
+
433
+ it("an absent column reads as the DB default, on", async (): Promise<void> => {
434
+ const policy: SessionReplayGatePolicy | null = await resolveWith();
435
+
436
+ expect(policy).not.toBeNull();
437
+ expect(policy?.sameOriginTracePropagation).toBe(true);
438
+ });
439
+
440
+ it("a null column reads as the DB default, on", async (): Promise<void> => {
441
+ const policy: SessionReplayGatePolicy | null = await resolveWith({
442
+ sessionReplaySameOriginTracePropagation: null,
443
+ });
444
+
445
+ expect(policy?.sameOriginTracePropagation).toBe(true);
446
+ });
447
+
448
+ it("is independent of the cross-origin list: empty origins still propagate same-origin", async (): Promise<void> => {
449
+ const policy: SessionReplayGatePolicy | null = await resolveWith({
450
+ sessionReplayTracePropagationOrigins: [],
451
+ });
452
+
453
+ expect(policy?.tracePropagationOrigins).toEqual([]);
454
+ expect(policy?.sameOriginTracePropagation).toBe(true);
455
+ });
456
+
457
+ it("switching it off leaves the cross-origin list alone", async (): Promise<void> => {
458
+ const policy: SessionReplayGatePolicy | null = await resolveWith({
459
+ sessionReplaySameOriginTracePropagation: false,
460
+ sessionReplayTracePropagationOrigins: ["https://api.example.com"],
461
+ });
462
+
463
+ expect(policy?.sameOriginTracePropagation).toBe(false);
464
+ expect(policy?.tracePropagationOrigins).toEqual([
465
+ "https://api.example.com",
466
+ ]);
467
+ });
468
+
469
+ it("a refused application yields no policy at all, so nothing can propagate", async (): Promise<void> => {
470
+ const policy: SessionReplayGatePolicy | null = await resolveWith({
471
+ isSessionReplayEnabled: false,
472
+ sessionReplaySameOriginTracePropagation: true,
473
+ });
474
+
475
+ expect(policy).toBeNull();
476
+ });
477
+ });
@@ -0,0 +1,173 @@
1
+ import { describe, expect, test } from "@jest/globals";
2
+ import {
3
+ describeWebVitalThresholds,
4
+ formatWebVitalThreshold,
5
+ WebVitalDefinition,
6
+ WebVitalDefinitions,
7
+ } from "../../../Types/Rum/WebVitals";
8
+
9
+ /*
10
+ * The web-vital definitions carry the plain-language description the RUM
11
+ * overview shows in each vital's (i) tooltip, and the thresholds its
12
+ * Good / Needs work / Poor chip is judged against. The tooltip's limits are
13
+ * derived from the thresholds rather than typed into the description, so a
14
+ * threshold change cannot leave the tooltip quoting the old number.
15
+ */
16
+
17
+ function definition(key: string): WebVitalDefinition {
18
+ const found: WebVitalDefinition | undefined = WebVitalDefinitions.find(
19
+ (d: WebVitalDefinition): boolean => {
20
+ return d.key === key;
21
+ },
22
+ );
23
+
24
+ if (!found) {
25
+ throw new Error(`No web vital "${key}"`);
26
+ }
27
+
28
+ return found;
29
+ }
30
+
31
+ describe("WebVitalDefinitions descriptions", () => {
32
+ test("every vital has a description", () => {
33
+ for (const d of WebVitalDefinitions) {
34
+ expect(typeof d.description).toBe("string");
35
+ expect(d.description.trim().length).toBeGreaterThan(20);
36
+ }
37
+ });
38
+
39
+ test.each(
40
+ WebVitalDefinitions.map(
41
+ (d: WebVitalDefinition): [string, WebVitalDefinition] => {
42
+ return [d.key, d];
43
+ },
44
+ ),
45
+ )(
46
+ "%s: the description is one or two short sentences a tooltip can hold",
47
+ (_: string, d: WebVitalDefinition) => {
48
+ expect(d.description.length).toBeLessThanOrEqual(220);
49
+ expect(d.description).toMatch(/[.]$/);
50
+ expect(d.description).toBe(d.description.trim());
51
+ expect(d.description).not.toMatch(/\s{2,}/);
52
+ },
53
+ );
54
+
55
+ test.each(
56
+ WebVitalDefinitions.map(
57
+ (d: WebVitalDefinition): [string, WebVitalDefinition] => {
58
+ return [d.key, d];
59
+ },
60
+ ),
61
+ )(
62
+ "%s: the description does not quote a threshold (those are derived)",
63
+ (_: string, d: WebVitalDefinition) => {
64
+ /* No "2.5 s", "200 ms", "0.1" style numbers typed into the prose. */
65
+ expect(d.description).not.toMatch(/\d+(\.\d+)?\s?(ms|s)\b/);
66
+ expect(d.description).not.toMatch(/\b0\.\d+/);
67
+ },
68
+ );
69
+
70
+ test("the descriptions are distinct - no vital borrows another's text", () => {
71
+ const texts: Set<string> = new Set(
72
+ WebVitalDefinitions.map((d: WebVitalDefinition) => {
73
+ return d.description;
74
+ }),
75
+ );
76
+
77
+ expect(texts.size).toBe(WebVitalDefinitions.length);
78
+ });
79
+
80
+ test("each description explains its own vital in plain words", () => {
81
+ expect(definition("lcp").description).toMatch(/biggest|largest/i);
82
+ expect(definition("inp").description).toMatch(/click|tap|key/i);
83
+ expect(definition("cls").description).toMatch(/jump|shift|mov/i);
84
+ expect(definition("cls").description).toMatch(/lower is better/i);
85
+ expect(definition("fcp").description).toMatch(/first/i);
86
+ expect(definition("ttfb").description).toMatch(/first byte/i);
87
+ expect(definition("ttfb").description).toMatch(/server/i);
88
+ });
89
+
90
+ test("CLS says it is a score, not a time", () => {
91
+ expect(definition("cls").unit).toBe("score");
92
+ expect(definition("cls").description).toMatch(/score/i);
93
+ expect(definition("cls").description).toMatch(/not a time/i);
94
+ });
95
+ });
96
+
97
+ describe("formatWebVitalThreshold", () => {
98
+ test.each([
99
+ [200, "200 ms"],
100
+ [500, "500 ms"],
101
+ [800, "800 ms"],
102
+ [999, "999 ms"],
103
+ [1000, "1 s"],
104
+ [1800, "1.8 s"],
105
+ [2500, "2.5 s"],
106
+ [3000, "3 s"],
107
+ [4000, "4 s"],
108
+ [1234, "1.23 s"],
109
+ [0, "0 ms"],
110
+ ])("%d ms reads as %s", (value: number, expected: string) => {
111
+ expect(formatWebVitalThreshold(value, "ms")).toBe(expected);
112
+ });
113
+
114
+ test.each([
115
+ [0.1, "0.1"],
116
+ [0.25, "0.25"],
117
+ [0, "0"],
118
+ [1, "1"],
119
+ ])(
120
+ "a score of %d reads as %s, with no unit",
121
+ (value: number, expected: string) => {
122
+ expect(formatWebVitalThreshold(value, "score")).toBe(expected);
123
+ },
124
+ );
125
+ });
126
+
127
+ describe("describeWebVitalThresholds", () => {
128
+ test("words the published limits for each vital", () => {
129
+ expect(describeWebVitalThresholds(definition("lcp"))).toBe(
130
+ "Good below 2.5 s; poor at 4 s or more.",
131
+ );
132
+ expect(describeWebVitalThresholds(definition("inp"))).toBe(
133
+ "Good below 200 ms; poor at 500 ms or more.",
134
+ );
135
+ expect(describeWebVitalThresholds(definition("cls"))).toBe(
136
+ "Good below 0.1; poor at 0.25 or more.",
137
+ );
138
+ expect(describeWebVitalThresholds(definition("fcp"))).toBe(
139
+ "Good below 1.8 s; poor at 3 s or more.",
140
+ );
141
+ expect(describeWebVitalThresholds(definition("ttfb"))).toBe(
142
+ "Good below 800 ms; poor at 1.8 s or more.",
143
+ );
144
+ });
145
+
146
+ test("follows the thresholds, so a changed limit changes the words", () => {
147
+ expect(
148
+ describeWebVitalThresholds({
149
+ unit: "ms",
150
+ thresholds: { warn: 1200, danger: 2600 },
151
+ }),
152
+ ).toBe("Good below 1.2 s; poor at 2.6 s or more.");
153
+
154
+ expect(
155
+ describeWebVitalThresholds({
156
+ unit: "score",
157
+ thresholds: { warn: 0.05, danger: 0.5 },
158
+ }),
159
+ ).toBe("Good below 0.05; poor at 0.5 or more.");
160
+ });
161
+
162
+ test("the wording matches how the overview rates a value (strict below warn is Good, at danger is Poor)", () => {
163
+ /*
164
+ * WebVitalsCard: value < warn -> Good, value < danger -> Needs work,
165
+ * otherwise Poor. "Good below X" and "poor at Y or more" are exactly
166
+ * those two comparisons.
167
+ */
168
+ const words: string = describeWebVitalThresholds(definition("lcp"));
169
+
170
+ expect(words).toContain("below");
171
+ expect(words).toContain("or more");
172
+ });
173
+ });
@@ -0,0 +1,413 @@
1
+ import "@testing-library/jest-dom";
2
+ import {
3
+ act,
4
+ cleanup,
5
+ fireEvent,
6
+ render,
7
+ screen,
8
+ } from "@testing-library/react";
9
+ import userEvent from "@testing-library/user-event";
10
+ import React from "react";
11
+ import {
12
+ afterEach,
13
+ beforeEach,
14
+ describe,
15
+ expect,
16
+ jest,
17
+ test,
18
+ } from "@jest/globals";
19
+ import FieldLabelElement from "../../../UI/Components/Detail/FieldLabel";
20
+ import InfoCard from "../../../UI/Components/InfoCard/InfoCard";
21
+ import Route from "../../../Types/API/Route";
22
+ import getJestMockFunction, { MockFunction } from "../../MockType";
23
+
24
+ /*
25
+ * The (i) beside a summary card's title: FieldLabel draws it when given a
26
+ * `tooltip`, and InfoCard - the stat card on every Kubernetes, Proxmox and
27
+ * Ceph detail page - hands its own `tooltip` to FieldLabel.
28
+ *
29
+ * Some InfoCards are clickable (the card is a role="button" that navigates
30
+ * or toggles a filter). Asking what a number means must not activate the
31
+ * card: a click on the (i) is swallowed, and so are Enter and Space.
32
+ */
33
+
34
+ const READY: string =
35
+ "Pods that passed their readiness check and receive traffic, out of the pods the workload wants.";
36
+
37
+ async function hover(trigger: HTMLElement): Promise<void> {
38
+ fireEvent.mouseEnter(trigger);
39
+ await act(async () => {
40
+ jest.advanceTimersByTime(200);
41
+ });
42
+ }
43
+
44
+ beforeEach(() => {
45
+ jest.useFakeTimers();
46
+ });
47
+
48
+ afterEach(() => {
49
+ cleanup();
50
+ jest.useRealTimers();
51
+ });
52
+
53
+ describe("FieldLabel tooltip", () => {
54
+ test("without a tooltip there is no (i)", () => {
55
+ render(<FieldLabelElement title="Ready" />);
56
+
57
+ expect(screen.getByText("Ready")).toBeInTheDocument();
58
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
59
+ });
60
+
61
+ test.each([
62
+ ["empty", ""],
63
+ ["whitespace", " "],
64
+ ])("an %s tooltip draws no (i)", (_: string, tooltip: string) => {
65
+ render(<FieldLabelElement title="Ready" tooltip={tooltip} />);
66
+
67
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
68
+ });
69
+
70
+ test("with a tooltip, an (i) named after the title sits beside it", () => {
71
+ render(<FieldLabelElement title="Ready" tooltip={READY} />);
72
+
73
+ const info: HTMLElement = screen.getByRole("button", {
74
+ name: "About Ready",
75
+ });
76
+
77
+ expect(info).toBeInTheDocument();
78
+ expect(info.closest("label")).toHaveTextContent("Ready");
79
+ // The label is uppercase and tracked; the (i) resets both.
80
+ expect(info).toHaveClass("normal-case", "tracking-normal");
81
+ });
82
+
83
+ test("the text is not in the page until someone reaches for it", () => {
84
+ render(<FieldLabelElement title="Ready" tooltip={READY} />);
85
+
86
+ expect(screen.queryByText(READY)).not.toBeInTheDocument();
87
+ });
88
+
89
+ test("hovering the (i) shows the text", async () => {
90
+ render(<FieldLabelElement title="Ready" tooltip={READY} />);
91
+
92
+ await hover(screen.getByRole("button", { name: "About Ready" }));
93
+
94
+ expect(screen.getByRole("tooltip")).toHaveTextContent(READY);
95
+ });
96
+
97
+ test("keyboard focus shows the text too", async () => {
98
+ render(<FieldLabelElement title="Ready" tooltip={READY} />);
99
+
100
+ const info: HTMLElement = screen.getByRole("button", {
101
+ name: "About Ready",
102
+ });
103
+
104
+ act(() => {
105
+ info.focus();
106
+ });
107
+ await act(async () => {
108
+ jest.advanceTimersByTime(200);
109
+ });
110
+
111
+ expect(screen.getByRole("tooltip")).toHaveTextContent(READY);
112
+ });
113
+
114
+ test("a visible description and a tooltip can sit together", () => {
115
+ render(
116
+ <FieldLabelElement
117
+ title="Ready"
118
+ description="Shown under the title."
119
+ tooltip={READY}
120
+ />,
121
+ );
122
+
123
+ expect(screen.getByText("Shown under the title.")).toBeInTheDocument();
124
+ expect(
125
+ screen.getByRole("button", { name: "About Ready" }),
126
+ ).toBeInTheDocument();
127
+ });
128
+
129
+ test("no title means no label and so no (i)", () => {
130
+ render(<FieldLabelElement tooltip={READY} />);
131
+
132
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
133
+ });
134
+
135
+ test("the (i) is not inside the side link", () => {
136
+ render(
137
+ <FieldLabelElement
138
+ title="Ready"
139
+ tooltip={READY}
140
+ sideLink={{ text: "View pods", url: new Route("/pods") }}
141
+ />,
142
+ );
143
+
144
+ const info: HTMLElement = screen.getByRole("button", {
145
+ name: "About Ready",
146
+ });
147
+
148
+ expect(info.closest("a")).toBeNull();
149
+ });
150
+ });
151
+
152
+ describe("InfoCard tooltip", () => {
153
+ test("a card without a tooltip draws no (i)", () => {
154
+ render(<InfoCard title="Ready" value="3/3" />);
155
+
156
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
157
+ });
158
+
159
+ test("a card passes its tooltip to the title", async () => {
160
+ render(<InfoCard title="Ready" value="3/3" tooltip={READY} />);
161
+
162
+ const info: HTMLElement = screen.getByRole("button", {
163
+ name: "About Ready",
164
+ });
165
+
166
+ await hover(info);
167
+
168
+ expect(screen.getByRole("tooltip")).toHaveTextContent(READY);
169
+ expect(screen.getByText("3/3")).toBeInTheDocument();
170
+ });
171
+
172
+ test("an inert card stays inert - the (i) is the only control", () => {
173
+ const { container } = render(
174
+ <InfoCard title="Ready" value="3/3" tooltip={READY} />,
175
+ );
176
+ const card: HTMLElement = container.firstElementChild as HTMLElement;
177
+
178
+ expect(screen.getAllByRole("button")).toHaveLength(1);
179
+ expect(card).not.toHaveAttribute("role");
180
+ expect(card).not.toHaveAttribute("tabindex");
181
+ });
182
+
183
+ test("the card's value can be an element and still sits beside the (i)", () => {
184
+ render(
185
+ <InfoCard
186
+ title="CPU"
187
+ value={<span data-testid="cpu-value">42%</span>}
188
+ tooltip="Processor time the pod used, as a share of one core."
189
+ />,
190
+ );
191
+
192
+ expect(screen.getByTestId("cpu-value")).toHaveTextContent("42%");
193
+ expect(
194
+ screen.getByRole("button", { name: "About CPU" }),
195
+ ).toBeInTheDocument();
196
+ });
197
+ });
198
+
199
+ describe("a clickable InfoCard with a tooltip", () => {
200
+ interface Rendered {
201
+ card: HTMLElement;
202
+ info: HTMLElement;
203
+ onClick: MockFunction;
204
+ }
205
+
206
+ function renderClickable(isSelected?: boolean): Rendered {
207
+ const onClick: MockFunction = getJestMockFunction();
208
+
209
+ render(
210
+ <InfoCard
211
+ title="Ready"
212
+ value="3/3"
213
+ tooltip={READY}
214
+ onClick={onClick}
215
+ isSelected={isSelected}
216
+ ariaLabel="Show ready pods"
217
+ />,
218
+ );
219
+
220
+ return {
221
+ card: screen.getByRole("button", { name: "Show ready pods" }),
222
+ info: screen.getByRole("button", { name: "About Ready" }),
223
+ onClick,
224
+ };
225
+ }
226
+
227
+ /*
228
+ * A button's children are presentational, so an (i) inside the card's own
229
+ * button would be unreachable for a screen reader. The card is a plain
230
+ * container with a real button laid over it, and the (i) is a sibling of
231
+ * that button, lifted above it.
232
+ */
233
+ test("the (i) is its own button, never nested in the card's button", () => {
234
+ const { card, info } = renderClickable();
235
+
236
+ expect(card).not.toContainElement(info);
237
+ expect(info.closest("[role='button']")).toBeNull();
238
+ expect(card.tagName).toBe("BUTTON");
239
+ expect(card).toHaveAttribute("type", "button");
240
+ expect(info.tagName).toBe("BUTTON");
241
+ expect(info).toHaveAttribute("type", "button");
242
+ expect(card.parentElement).toContainElement(info);
243
+ });
244
+
245
+ test("the card's button covers the whole card and the (i) sits above it", () => {
246
+ const { card, info } = renderClickable();
247
+
248
+ expect(card).toHaveClass("absolute", "inset-0");
249
+ expect(card.parentElement).toHaveClass("relative");
250
+ expect(info).toHaveClass("relative", "z-10");
251
+ });
252
+
253
+ test("clicking the (i) does not activate the card", () => {
254
+ const { info, onClick } = renderClickable();
255
+
256
+ fireEvent.click(info);
257
+
258
+ expect(onClick).not.toHaveBeenCalled();
259
+ });
260
+
261
+ test.each([
262
+ ["Enter", "Enter"],
263
+ ["Space", " "],
264
+ ])(
265
+ "pressing %s on the (i) does not activate the card",
266
+ (_: string, key: string) => {
267
+ const { info, onClick } = renderClickable();
268
+
269
+ fireEvent.keyDown(info, { key });
270
+
271
+ expect(onClick).not.toHaveBeenCalled();
272
+ },
273
+ );
274
+
275
+ test.each([["{Enter}"], ["[Space]"]])(
276
+ "a real keyboard %s on the focused (i) does not activate the card",
277
+ async (keys: string) => {
278
+ const user: ReturnType<typeof userEvent.setup> = userEvent.setup({
279
+ advanceTimers: (ms: number) => {
280
+ jest.advanceTimersByTime(ms);
281
+ },
282
+ });
283
+ const { info, onClick } = renderClickable();
284
+
285
+ act(() => {
286
+ info.focus();
287
+ });
288
+ await user.keyboard(keys);
289
+
290
+ expect(onClick).not.toHaveBeenCalled();
291
+ },
292
+ );
293
+
294
+ test("clicking the card itself still activates it", () => {
295
+ const { card, onClick } = renderClickable();
296
+
297
+ fireEvent.click(card);
298
+
299
+ expect(onClick).toHaveBeenCalledTimes(1);
300
+ });
301
+
302
+ test("the title and value are not inside the card's button - they stay readable text", () => {
303
+ const { card } = renderClickable();
304
+
305
+ expect(card).not.toContainElement(screen.getByText("Ready"));
306
+ expect(card).not.toContainElement(screen.getByText("3/3"));
307
+ expect(card).toBeEmptyDOMElement();
308
+ });
309
+
310
+ test.each([["{Enter}"], ["[Space]"]])(
311
+ "a real keyboard %s on the focused card activates it",
312
+ async (keys: string) => {
313
+ const user: ReturnType<typeof userEvent.setup> = userEvent.setup({
314
+ advanceTimers: (ms: number) => {
315
+ jest.advanceTimersByTime(ms);
316
+ },
317
+ });
318
+ const { card, onClick } = renderClickable();
319
+
320
+ act(() => {
321
+ card.focus();
322
+ });
323
+ await user.keyboard(keys);
324
+
325
+ expect(onClick).toHaveBeenCalledTimes(1);
326
+ },
327
+ );
328
+
329
+ test("Tab reaches the card first and then its (i)", async () => {
330
+ const user: ReturnType<typeof userEvent.setup> = userEvent.setup({
331
+ advanceTimers: (ms: number) => {
332
+ jest.advanceTimersByTime(ms);
333
+ },
334
+ });
335
+ const { card, info } = renderClickable();
336
+
337
+ await user.tab();
338
+ expect(card).toHaveFocus();
339
+
340
+ await user.tab();
341
+ expect(info).toHaveFocus();
342
+ });
343
+
344
+ test("other keys on the (i) still reach the card and are ignored by it", () => {
345
+ const { info, onClick } = renderClickable();
346
+
347
+ fireEvent.keyDown(info, { key: "a" });
348
+ fireEvent.keyDown(info, { key: "Tab" });
349
+
350
+ expect(onClick).not.toHaveBeenCalled();
351
+ });
352
+
353
+ test("hovering the (i) of a clickable card shows the text without activating it", async () => {
354
+ const { info, onClick } = renderClickable();
355
+
356
+ await hover(info);
357
+
358
+ expect(screen.getByRole("tooltip")).toHaveTextContent(READY);
359
+ expect(onClick).not.toHaveBeenCalled();
360
+ });
361
+
362
+ test("a toggle card keeps its pressed state when it gains a tooltip", () => {
363
+ const { card } = renderClickable(true);
364
+
365
+ expect(card).toHaveAttribute("aria-pressed", "true");
366
+ });
367
+
368
+ test("the card keeps its own accessible name when it has one", () => {
369
+ const { card } = renderClickable();
370
+
371
+ expect(card).toHaveAttribute("aria-label", "Show ready pods");
372
+ });
373
+
374
+ test("without an aria-label, the card is named by its title and value - the (i)'s 'About' name does not leak in", () => {
375
+ render(
376
+ <InfoCard title="Ready" value="3/3" tooltip={READY} onClick={() => {}} />,
377
+ );
378
+
379
+ expect(
380
+ screen.getByRole("button", { name: "Ready 3/3" }),
381
+ ).toBeInTheDocument();
382
+ expect(
383
+ screen.getByRole("button", { name: "About Ready" }),
384
+ ).toBeInTheDocument();
385
+ });
386
+
387
+ test("an element value has no text to borrow, so the card is named by its title", () => {
388
+ render(
389
+ <InfoCard
390
+ title="Ready"
391
+ value={<span>3 of 3</span>}
392
+ tooltip={READY}
393
+ onClick={() => {}}
394
+ />,
395
+ );
396
+
397
+ expect(screen.getByRole("button", { name: "Ready" })).toBeInTheDocument();
398
+ });
399
+
400
+ test("a whitespace-only tooltip leaves a clickable card exactly as it was", () => {
401
+ render(
402
+ <InfoCard title="Ready" value="3/3" tooltip=" " onClick={() => {}} />,
403
+ );
404
+
405
+ const card: HTMLElement = screen.getByRole("button");
406
+
407
+ expect(card.tagName).toBe("DIV");
408
+ expect(card).toHaveAttribute("tabindex", "0");
409
+ expect(
410
+ screen.queryByRole("button", { name: /About/ }),
411
+ ).not.toBeInTheDocument();
412
+ });
413
+ });